UNPKG

cl-react-graph

Version:
368 lines (326 loc) • 10.6 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 cloneDeep from 'lodash/cloneDeep'; import merge from 'lodash/merge'; import colorScheme from './colors'; import { IBarChartDataSet, IChartAdaptor, } from './Histogram'; import { IPieChartProps, IPieDataItem, } from './PieChart'; import tips, { makeTip } from './tip'; import { onMouseOut } from './utils/mouseOver'; import { DeepPartial } from './utils/types'; interface IPieDataset { count: number; groupLabel: string; label: string; } // Function to calculate pie chart paths from data const thisPie = pie() .sort(null) .value((d: any) => d.count); const outerRadius = (props, 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)); }; const innerRadius = (props, 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)); }; const getArc = (props, i: number) => { return arc() .outerRadius(outerRadius(props, i)) .innerRadius(innerRadius(props, i)); } 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, hover: { lighten: 0.1, }, labels: { display: true, displayFn: (d) => 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 storedWidth: number; let storedHeight: number; const PieChartD3 = { create(el: Element, newProps: DeepPartial<IPieChartProps> = {}) { merge(props, newProps); previousData = props.data.counts.map((set: IBarChartDataSet, 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(i); }); this.update(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(newProps: DeepPartial<IPieChartProps>) { if (!props.data) { return; } merge(props, newProps); if (newProps.data) { props.data = cloneDeep(newProps.data) as IPieChartProps['data']; } if (props.colorScheme) { props.colorScheme = props.colorScheme; } if (!props.data.bins) { return; } this.drawCharts(); }, drawCharts() { const { data, visible } = props; dataSets = data.counts.map((set: IBarChartDataSet) => { 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 = data.counts[i]?.colors ?? props.colorScheme; this.drawChart(dataSet, i, data.bins, theme); this.drawLabels(dataSet, i, data.bins, theme); }); previousData = dataSets; }, drawChartBg(i: number) { const { backgroundColor, width, height } = props; const tau = 2 * Math.PI; // http://tauday.com/tau-manifesto const bgArc = arc() .innerRadius(innerRadius(props, i)) .outerRadius(outerRadius(props, i)) .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]) { svg .append('g') .attr('class', 'pie-container') svg .append('g') .attr('class', 'pie-labels') containers[i] = svg; } }, drawChart(data, i: number, bins: string[], theme: string[]) { const { width, height, tip, tipContentFn, hover } = props; // Formatted pie chart arcs based on previous current data. // If data has changed length (i.e. swapping the entire dataset) use the new data. const prev = previousData[i].length === data.length ? previousData[i] : data; const arcs = thisPie(prev); const colors = scaleOrdinal(theme); // Set the domain to get consistent colours colors.domain(Array.from(new Array(data.length).keys()).map(s => s.toString())); // Stack multiple charts in concentric circles const thisArc = getArc(props, i); const path = containers[i].select('.pie-container').selectAll('path') .data(thisPie(data)); path.enter() .append('path') .attr('transform', (d) => { return 'translate(' + Number(width) / 2 + ',' + height / 2 + ')'; }) .attr('stroke', '#FFF') .attr('fill', (_, j) => colors(j)) .on('mouseover', (d: PieArcDatum<IPieDataItem>, ix: number, nodes: any[]) => { if (hover) { select(nodes[ix]) .attr('fill-opacity', 1) .transition('easeIn') .attr('fill-opacity', 0.7); } tipContent.html(() => tipContentFn(bins, ix, d.data.count, d.data.groupLabel)); tip.fx.in(tipContainer); }) .on('mousemove', () => tip.fx.move(tipContainer)) .on('mouseout', onMouseOut({ tip, tipContainer, colors })) .attr('d', thisArc) .style('opacity', 0) .transition() .duration(500) .style('opacity', 1); // Fade in when adding (merge) path .merge(path) .on('mouseover', (d: PieArcDatum<IPieDataItem>, ix: number, nodes: any[]) => { if (hover) { select(nodes[ix]) .attr('fill-opacity', 1) .transition('easeIn') .attr('fill-opacity', 0.7); } tipContent.html(() => tipContentFn(bins, ix, d.data.count, d.data.groupLabel)); tip.fx.in(tipContainer); }) .on('mousemove', () => tip.fx.move(tipContainer)) .on('mouseout', onMouseOut({ tip, tipContainer, colors })) .transition() .delay(400) .duration(500) .attrTween('d', arcTween(thisArc)); path.exit().transition() .attrTween('d', arcTween(thisArc)) .duration(500) .style('opacity', 0).remove(); }, drawLabels(data, i: number, bins: string[], theme: string[]) { const { labels, width, height } = props; const thisArc = getArc(props, i); const path2 = containers[i].select('.pie-labels').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); }) .style('opacity', 0) .text((d, ix: number) => d.value === 0 ? '' : labels.displayFn(d, ix)) .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) => labels.display === false || d.value === 0 ? 0 : 1); 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 + ')'; }) .text((d, ix: number) => d.value === 0 ? '' : labels.displayFn(d, ix)) .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.value === 0 ? 0 : 1; }); path2.exit().transition() .duration(500) .style('opacity', 0); }, /** * Any necessary clean up */ destroy() { 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)); }; }; }