UNPKG

cl-react-graph

Version:
337 lines (296 loc) 9.47 kB
import 'd3-transition'; import { interpolate } from 'd3-interpolate'; import { scaleOrdinal } from 'd3-scale'; import { select, Selection, } from 'd3-selection'; import { arc, Arc, pie, PieArcDatum, } from 'd3-shape'; import get from 'lodash.get'; import merge from 'lodash.merge'; import colorScheme from './colors'; import { IChartAdaptor, IHistogramDataSet, } from './Histogram'; import { IPieChartProps, IPieDataItem, } from './PieChart'; import tips, { makeTip } from './tip'; import { DeepPartial } from './utils/types'; interface IPieDataset { count: number; groupLabel: string; label: string; } export const pieChartD3 = ((): IChartAdaptor<IPieChartProps> => { let tipContainer; let tipContent; const props: IPieChartProps = { backgroundColor: '#ddd', className: 'piechart-d3', colorScheme, data: { bins: [], counts: [], }, donutWidth: 0, height: 200, labels: { display: true, displayFn: (d, ix) => d.value, }, margin: { bottom: 0, left: 10, right: 0, top: 10, }, tip: tips, tipContainer: 'body', tipContentFn: (bins: string[], i: number, d: number, groupLabel): string => { return groupLabel + ': ' + bins[i] + '<br />' + d; }, visible: {}, width: 200, }; let containers: any[]; let svg: Selection<any, any, any, any>; let dataSets: IPieDataset[][]; let previousData: any; let current: any; let storedWidth: number; let storedHeight: number; const PieChartD3 = { create(el: Element, newProps: DeepPartial<IPieChartProps> = {}) { merge(props, newProps); previousData = props.data.counts.map((set: IHistogramDataSet, setIndex: number) => { return set.data .map((count, i) => ({ count, groupLabel: set.label, label: props.data.bins[i], })); }); this._makeSvg(el); containers = []; previousData.forEach((dataSet, i) => { this.drawChartBg(props.data, i); }); this.update(el, props); }, _makeSvg(el: Element) { 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 } = props; // Reference to svg element containing chart svg = select(el).append('svg') .attr('class', className) .attr('width', width) .attr('height', height) .attr('viewBox', `0 0 ${width} ${height}`) .append('g') .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); const r = makeTip(props.tipContainer, tipContainer); tipContent = r.tipContent; tipContainer = r.tipContainer; }, update(el: Element, newProps: DeepPartial<IPieChartProps>) { if (!props.data) { return; } merge(props, newProps); if (props.colorScheme) { props.colorScheme = props.colorScheme; } if (!props.data.bins) { return; } this.drawCharts(); }, outerRadius(setIndex = 0) { const { donutWidth = 0, width, height } = props; const radius = Math.min(Number(width), height) / 2; return donutWidth === 0 ? radius - 10 : radius - 10 - (setIndex * (donutWidth + 10)); }, innerRadius(setIndex = 0) { const { donutWidth = 0, width, height } = props; const radius = Math.min(Number(width), height) / 2; return donutWidth === 0 ? 0 : radius - 10 - donutWidth - (setIndex * (donutWidth + 10)); }, drawCharts() { const { data, visible } = props; dataSets = data.counts.map((set: IHistogramDataSet) => { return set.data .map((count, i) => ({ count: visible[data.bins[i]] !== false ? count : 0, groupLabel: set.label, label: data.bins[i], })); }); dataSets.forEach((dataSet, i) => { const theme = get(data.counts[i], 'colors', props.colorScheme); this.drawChart(dataSet, i, data.bins, theme); }); previousData = dataSets; }, drawChartBg(data, i) { const { backgroundColor, width, height } = props; const tau = 2 * Math.PI; // http://tauday.com/tau-manifesto const outerRadius = this.outerRadius(i); const innerRadius = this.innerRadius(i); const bgArc = arc() .innerRadius(innerRadius) .outerRadius(outerRadius) .startAngle(0) .endAngle(tau); const container = svg .append('g') .attr('class', 'pie-bg'); const background = container.append('path') .attr('class', 'pie-background') .style('fill', backgroundColor); background.enter() .attr('transform', 'translate(' + Number(width) / 2 + ',' + height / 2 + ')') .attr('d', bgArc); background.merge(background); if (!containers[i]) { containers[i] = svg .append('g') .attr('class', 'pie-container'); } }, drawChart(data, i: number, bins: string[], theme: string[]) { const { labels, width, height, tip, tipContentFn } = props; // Stack multiple charts in concentric circles const outerRadius = this.outerRadius(i); const innerRadius = this.innerRadius(i); // Function to calculate pie chart paths from data const thisPie = pie() .sort(null) .value((d: any) => d.count); // Formated pie chart arcs based on previous current data const arcs = thisPie(previousData[i]); const colors = scaleOrdinal(theme); const thisArc = arc() .outerRadius(outerRadius) .innerRadius(innerRadius); const path = containers[i].selectAll('path') .data(thisPie(data)); const g = path.enter().append('g') .attr('class', 'arc'); g.append('path') .attr('transform', 'translate(' + Number(width) / 2 + ',' + height / 2 + ')') .attr('stroke', '#FFF') .attr('fill', (d, j) => colors(j)) .each((d, j) => { current = arcs[j]; }) // store the initial angles .attr('d', thisArc) .on('mouseover', (d: PieArcDatum<IPieDataItem>, ix: number) => { tipContent.html(() => tipContentFn(bins, ix, d.data.count, d.data.groupLabel)); tip.fx.in(tipContainer); }) .on('mousemove', () => tip.fx.move(tipContainer)) .on('mouseout', () => tip.fx.out(tipContainer)) .style('opacity', 0) .transition() .duration(500) .style('opacity', 1); // Fade in when adding (merge) path .merge(path) .on('mouseover', (d: PieArcDatum<IPieDataItem>, ix: number) => { tipContent.html(() => tipContentFn(bins, ix, d.data.count, d.data.groupLabel)); tip.fx.in(tipContainer); }) .on('mousemove', () => tip.fx.move(tipContainer)) .on('mouseout', () => tip.fx.out(tipContainer)) .transition() .delay(400) .duration(500) .attr('fill', (d, j) => colors(j)) .attrTween('d', arcTween(thisArc)); const path2 = containers[i].selectAll('text.label') .data(thisPie(data)); path2.enter().append('text') .attr('class', 'label') .each(() => { // Store initial offset incase we change chart heights. storedHeight = height; storedWidth = Number(width); }) .attr('transform', (d) => { const centroid = thisArc.centroid(d); const x = centroid[0] + (storedWidth / 2); const y = centroid[1] + (storedHeight / 2); return 'translate(' + x + ',' + y + ')'; }) .each((d: any) => { // Store current value to work out fx transition opacities current = d; }) .text((d, ix) => { if (d.value === 0) { return ''; } return labels.displayFn(d, ix); }); path2 .merge(path2) .transition() .duration(500) .style('opacity', 0) .transition() .attr('transform', (d) => { const centroid = thisArc.centroid(d); const x = centroid[0] + (storedWidth / 2); const y = centroid[1] + (storedHeight / 2); return 'translate(' + x + ',' + y + ')'; }) .transition() .duration(500) .style('opacity', (d) => { // Only show if the new value is not 0 and labels are set to be displayed return labels.display === false || d.data.value === 0 ? 0 : 1; }); path2.exit().remove(); path.exit().transition() .duration(500) .style('opacity', 0).remove(); }, /** * Any necessary clean up */ destroy(el: Element) { svg.selectAll('svg > *').remove(); }, }; return PieChartD3; }); // Returns a tween for a transition’s "d" attribute, transitioning any selected // arcs from their current angle to the specified new angle. function arcTween(this: any, thisArc: Arc<any, any>) { return function (this: any, d) { const i = interpolate(this._current, d); this._current = i(0); return function (this: any, t) { return thisArc(i(t)); }; }; }