UNPKG

cl-react-graph

Version:
445 lines (402 loc) 12.5 kB
import { extent } from 'd3-array'; import { axisBottom, axisLeft, } from 'd3-axis'; import { Selection } from 'd3-selection'; import { area, curveCatmullRom, CurveFactory, line, } from 'd3-shape'; import { timeFormat, timeParse, } from 'd3-time-format'; import cloneDeep from 'lodash/cloneDeep'; import mergeWith from 'lodash/mergeWith'; import attrs from './d3/attrs'; import { drawGrid, gridHeight, gridWidth, yAxisWidth as getYAxisWidth, } from './grid'; import { IChartAdaptor } from './Histogram'; import { IChartPoint, ILineChartDataSet, ILineChartProps, ILineProps, ISVGPoint, } from './LineChart'; import tips, { makeTip } from './tip'; import { axis as defaultAxis, grid as defaultGrid, lineStyle, } from './utils/defaults'; import { rangeAffordance, ticks, } from './utils/domain'; import { buildScales } from './utils/scales'; import { makeGrid, makeScales, makeSvg, sizeSVG, TSelection, } from './utils/svg'; import { DeepPartial } from './utils/types'; const ZERO_SUBSTITUTE: number = 1e-6; export const lineChartD3 = ((): IChartAdaptor<ILineChartProps> => { let svg: TSelection; let tipContainer: any; let xParseTime: (x: string) => Date | null; let xFormatTime: (v: any) => string; let tipContent: { html: (fn: any) => string, }; const lineProps: ILineProps = { curveType: curveCatmullRom, fill: { fill: '#eee', show: false, }, show: true, stroke: '#005870', strokeDashArray: '5 5', strokeDashOffset: 0, }; const pointProps: ISVGPoint = { ...lineStyle, fill: 'rgba(255, 255, 255, 0)', radius: 4, show: true, stroke: '#005870', }; const props: ILineChartProps = { axis: cloneDeep(defaultAxis), className: 'line-chart-d3', data: [], grid: defaultGrid, height: 250, margin: { bottom: 0, left: 5, right: 0, top: 5, }, 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; } }, visible: {}, width: 200, }; const datumProps = { line: lineProps, point: pointProps, }; const curve = ( dataset: ILineChartDataSet<any>, x: (x: number) => number, y: (y: number) => number, ) => line() .curve(dataset.line.curveType) .x((d: any) => x(d.x)) .y((d: any) => y(d.y)); let container: Selection<SVGElement, any, any, any>; let lineContainer: TSelection; let gridX: TSelection; let gridY: TSelection; let xScale: any; // AnyScale; let yScale: any; // AnyScale; let xAxisContainer: TSelection; let yAxisContainer: TSelection; let xAxisLabel: TSelection; let yAxisLabel: TSelection; const xOffset = (d: ILineChartDataSet<any>) => { return d.point.show ? d.point.radius / 2 : 0; }; const LineChartD3 = { /** * Initialization */ create(el: Element, newProps: DeepPartial<ILineChartProps> = {}) { this.mergeProps(newProps); svg = makeSvg(el, svg); const { margin, width, height, className } = props; sizeSVG(svg, { margin, width, height, className }); const r = makeTip(props.tipContainer, tipContainer); tipContent = r.tipContent; tipContainer = r.tipContainer; [xScale, yScale] = buildScales(props.axis); [xAxisContainer, yAxisContainer, xAxisLabel, yAxisLabel] = makeScales(svg); [gridX, gridY] = makeGrid(svg); const yAxisWidth = getYAxisWidth(props.axis); container = svg .append<SVGElement>('g') .attr('class', 'linechart-container') .attr('transform', `translate(${yAxisWidth}, 0)`) lineContainer = container .append<SVGElement>('g') .attr('class', 'line-container'); this.update(props); }, mergeProps(newProps: DeepPartial<ILineChartProps>) { const customerizer = (objValue, srcValue, key, object, source, stack) => { if (key === 'data') { return srcValue; } } mergeWith(props, newProps, customerizer); }, /** * Iterate over the dataset drawing points for sets marked as * requiring points. */ _drawDataPointSet(data: ILineChartProps['data']) { const { tip, tipContentFn } = props; const pointContainer = container.selectAll<SVGElement, {}>('g').data(data); // Don't ask why but we must reference tipContentFn as props.tipContentFn otherwise // it doesn't update with props changes const onMouseOver = (d: any) => { tipContent.html(() => tipContentFn([d as any], 0, 0, '')); tip.fx.in(tipContainer); }; const onMouseMove = () => tip.fx.move(tipContainer); const onMouseOut = () => tip.fx.out(tipContainer); const points = pointContainer.enter() .append<SVGElement>('g') .attr('class', (d, i: number) => 'point-container' + i) .merge(pointContainer) .selectAll<SVGElement, {}>('circle') .data((d: ILineChartDataSet<any>) => { return d.data.map((dx) => ({ ...dx, point: d.point, })); }); // UPDATE - Update old elements as needed. points.attr('class', 'update'); // ENTER + UPDATE // After merging the entered elements with the update selection, // apply operations to both. points.enter().append<SVGElement>('circle') .attr('class', 'enter') .on('mouseover', onMouseOver) .on('mousemove', onMouseMove) .on('mouseout', onMouseOut) .merge(points) .attr('class', 'point') .attr('cy', (d) => yScale(d.y)) .attr('r', (d, i: number) => 0) .attr('fill', (d) => d.point.fill) .attr('stroke', (d) => d.point.stroke) .attr('cx', (d) => { return xScale(d.x); }) .transition() .duration(400) .attr('r', (d: ILineChartDataSet<any>) => xOffset(d) * 2) .delay(650); // EXIT - Remove old elements as needed. pointContainer.exit().remove(); points.exit().remove(); }, /** * Draw the chart axes */ drawAxes() { const { axis, data } = props; const valuesCount = data.reduce((a: number, b): number => { return b.data.length > a ? b.data.length : a; }, 0) const w = gridWidth(props); const h = gridHeight(props); const ys: any[] = []; const xs: any[] = []; const yAxis = axisLeft<number>(yScale); const xAxis = axisBottom<number | string>(xScale); ticks({ axis: xAxis, valuesCount, axisLength: w, axisConfig: axis.x, scaleBand: xScale, limitByValues: true, }); ticks({ axis: yAxis, valuesCount, axisLength: h, axisConfig: axis.y, scaleBand: yScale, limitByValues: true, }); const yAxisWidth = getYAxisWidth(axis); data.forEach((datum) => { datum.data.forEach((d) => { let parsedY = d.y; let parsedX = d.x; if (axis.y.scale === 'LOG' && d.y === 0) { parsedY = ZERO_SUBSTITUTE; } if (axis.x.scale === 'LOG' && d.x === 0) { parsedX = ZERO_SUBSTITUTE; } ys.push(parsedY); xs.push(parsedX); }); }); const yDomain = rangeAffordance(extent(ys), axis.y); const xDomain = rangeAffordance(extent(xs), axis.x); xScale .domain(xDomain) .rangeRound([0, w]); yScale.domain(yDomain) .range([h, 0]); yAxisContainer .attr('transform', `translate(${yAxisWidth}, 0)`) .transition() .call(yAxis); xAxisContainer .attr('transform', `translate(${yAxisWidth + axis.y.style['stroke-width']},${h})`) .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 as any); attrs(svg.selectAll('.x-axis .domain, .x-axis .tick line'), axis.x.style); attrs(svg.selectAll('.x-axis .tick text'), axis.x.text.style as any); }, /** * Iterate over data and update lines */ _drawLines(data: ILineChartDataSet<any>[], oldData: ILineChartDataSet<any>[]) { // Remove old lines oldData.forEach((d, i) => { const keep = data.find(((newD) => newD.label === d.label)); if (keep === undefined) { const l = d.label.replace(/[^a-z]/gi, ''); lineContainer.select(`.line-${l}`) .remove(); } }) // change the line data.forEach((d, i) => { const selector = `line-${d.label.replace(/[^a-z]/gi, '')}`; let selection = lineContainer.select(`.${selector}`) if (selection.empty()) { lineContainer.append('path') .attr('class', selector); selection = lineContainer.select(`.${selector}`) } selection .attr('fill', 'none') .attr('stroke-dashoffset', d.line.strokeDashOffset) .attr('stroke-dasharray', d.line.strokeDashOffset) .attr('stroke', d.line.stroke) .transition() .duration(500) .attr('d', curve(d, xScale, yScale)(d.data as any) as any) .delay(50); }); }, /** * Iterates ove data and updates area fills */ drawAreas(data: ILineChartDataSet<any>[], oldData: ILineChartDataSet<any>[]) { const h = gridHeight(props); const thisArea = (dataset: ILineChartDataSet<any>) => area() .curve(dataset.line.curveType as CurveFactory) .x((d: any) => xScale(d.x)) .y0((d) => h) .y1((d: any) => yScale(d.y)); // Remove old lines oldData.forEach((d, i) => { const keep = data.find(((newD) => newD.label === d.label)); if (keep === undefined) { const l = d.label.replace(/[^a-z]/gi, ''); lineContainer.select(`.fill-${l}`).remove(); } }) // change the line data .forEach((d, i) => { const selector = `fill-${d.label.replace(/[^a-z]/gi, '')}`; let selection = lineContainer.select(`.${selector}`); if (selection.empty()) { lineContainer.append('path') .attr('class', selector); selection = lineContainer.select(`.${selector}`) } selection .attr('fill', d.line.fill.fill) .style('opacity', d.line.show && d.line.fill.show ? 1 : 0) .transition() .duration(500) .delay(50) .attr('d', thisArea(d)(d.data) as any); }); }, /** * Get a max count of values in each data set */ valuesCount(data: ILineChartProps['data']): number { return data.reduce((a: number, b): number => { return b.data.length > a ? b.data.length : a; }, 0); }, /** * Update chart */ update(newProps: DeepPartial<ILineChartProps>) { if (!newProps.data) { return; } const oldData = [...props.data]; this.mergeProps(newProps); const { margin, width, height, className } = props; sizeSVG(svg, { margin, width, height, className }); [xScale, yScale] = buildScales(props.axis); let data = [...props.data]; xParseTime = timeParse(props.axis.x.dateFormat); xFormatTime = timeFormat(props.axis.x.dateFormat); data = data.map((datum) => { if (props.axis.x.scale === 'TIME') { datum.data = datum.data.map((d) => { const newd: IChartPoint<any> = { ...d, x: typeof d.x === 'object' ? d.x : xParseTime(d.x.toString()), }; return newd; }); } // Assign in default line & point styles return Object.assign({}, datumProps, datum); }); this.drawAxes(); this._drawLines(data, oldData); this.drawAreas(data, oldData); drawGrid(xScale, yScale, gridX, gridY, props, this.valuesCount(data)); this._drawDataPointSet(data); }, /** * Any necessary clean up */ destroy() { svg.selectAll('svg > *').remove(); }, }; return LineChartD3; });