UNPKG

cl-react-graph

Version:
484 lines (435 loc) 14.5 kB
import { axisBottom, axisLeft, } from 'd3-axis'; import { scaleBand, scaleLinear, scaleOrdinal, } from 'd3-scale'; import { Selection } from 'd3-selection'; import merge from 'lodash/merge'; import colorScheme from './colors'; import attrs from './d3/attrs'; import { drawGrid, gridHeight, gridWidth, xAxisHeight, yAxisWidth, } from './grid'; import { EGroupedBarLayout, IAxis, IChartAdaptor, IHistogramProps, } from './Histogram'; import tips, { makeTip } from './tip'; import { barMargin, getBarWidth, groupedBarsUseSameXAxisValue, groupedMargin, } from './utils/bars'; import { annotationAxisDefaults, axis as defaultAxis, grid, } from './utils/defaults'; import { appendDomainRange, isStacked, maxValueCount, ticks, } from './utils/domain'; import { onClick, onMouseOut, onMouseOver, } from './utils/mouseOver'; import { makeGrid, makeScales, makeSvg, sizeSVG, TSelection, } from './utils/svg'; import { DeepPartial } from './utils/types'; export interface IGroupDataItem { label: string; groupLabel?: string; colorRef?: string; // String which can be used to return same colour value value: number; side?: 'left' | 'right'; // For Tornados } export type IGroupData = IGroupDataItem[][]; export const histogramD3 = ((): IChartAdaptor<IHistogramProps> => { let svg: Selection<any, any, any, any>; let tipContainer; let tipContent; const y = scaleLinear(); const x = scaleBand(); const xAnnotations = scaleBand(); const innerScaleBand = scaleBand(); let container: Selection<SVGElement, any, any, any>; let dataSets: IGroupData; let gridX: TSelection; let gridY: TSelection; let yAxisContainer: TSelection; let xAxisContainer: TSelection; let xAnnotationAxisContainer: TSelection; let yAnnotationAxisContainer: TSelection; let xAxisLabel: any; let yAxisLabel: any; let percentBarLabel: any; const props: IHistogramProps = { axis: defaultAxis, bar: { groupMargin: 0.1, margin: 0, overlayMargin: 5, width: 50, }, className: 'histogram-d3', colorScheme, annotations: [], data: { bins: [], counts: [], }, delay: 0, domain: { max: null, min: null, }, duration: 400, grid, groupLayout: EGroupedBarLayout.GROUPED, height: 200, margin: { bottom: 0, left: 5, right: 0, top: 5, }, showBinPercentages: [], stacked: false, // Deprecated use groupLayout instead stroke: { color: '#005870', dasharray: '', linecap: 'butt', width: 0, }, tip: tips, tipContainer: 'body', tipContentFn: (bins: string[], i: number, d: number): string => bins[i] + '<br />' + d, visible: {}, width: 200, }; const HistogramD3 = { /** * Initialization */ create(el: Element, newProps: DeepPartial<IHistogramProps> = {}) { 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; [gridX, gridY] = makeGrid(svg); [xAxisContainer, yAxisContainer, xAxisLabel, yAxisLabel, xAnnotationAxisContainer, yAnnotationAxisContainer] = makeScales(svg); container = svg .append<SVGElement>('g') .attr('class', 'histogram-container'); this.update(el, newProps); }, mergeProps(newProps: DeepPartial<IHistogramProps>) { merge(props, newProps); if (newProps.data) { props.data = newProps.data as IHistogramProps['data']; } if (newProps.colorScheme) { props.colorScheme = newProps.colorScheme; } }, /** * Draw scales */ drawAxes() { const { axis, bar, annotations, domain, groupLayout, stacked, data, margin, height } = props; const valuesCount = maxValueCount(data.counts); const w = gridWidth(props); const h = gridHeight(props); const dataLabels = data.counts.map((c) => c.label); const annotationsEnabled = annotations && annotations.length === data.bins.length; x .domain(data.bins) .rangeRound([0, w]) .paddingInner(groupedMargin(bar)); innerScaleBand .domain(groupedBarsUseSameXAxisValue({ groupLayout, stacked }) ? ['main'] : dataLabels) .rangeRound([0, x.bandwidth()]) .paddingInner(barMargin(props.bar)); const xAxis = axisBottom<string>(x); const yAxis = axisLeft<number>(y); const axisXAnnotationAllowance: IAxis = { ...axis.x, tickSize: 5, } /** X-Axis (label axis) set up */ ticks({ axis: xAxis, valuesCount, axisLength: w, axisConfig: annotationsEnabled ? axisXAnnotationAllowance : axis.x, scaleBand: x, limitByValues: true, }); xAxisContainer .attr('transform', 'translate(' + (yAxisWidth(axis) + axis.y.style['stroke-width']) + ',' + (height - xAxisHeight(props.axis) - (margin.left * 2)) + ')') .call(xAxis); /** X-Axis 2 (bottom axis) for annoations if annotations data sent (and match bin length) */ if (annotations && annotations.length === data.bins.length) { xAnnotations .domain(data.bins) .rangeRound([0, w]) .paddingInner(groupedMargin(bar)); const annotationAxis = axisBottom<string>(xAnnotations); ticks({ axis: annotationAxis, valuesCount: annotations.length, axisLength: w, axisConfig: annotationAxisDefaults, scaleBand: xAnnotations, limitByValues: true, }); // Override the default axis bin labels with the custom annotations annotationAxis.tickFormat((d, i) => annotations[i].value); xAnnotationAxisContainer .attr('transform', 'translate(' + (yAxisWidth(axis) + axis.y.style['stroke-width']) + ',' + (h + 14) + ')') .call(annotationAxis); // Annotation Axis styling attrs(svg.selectAll('.x-axis-bottom .domain, .x-axis-bottom .tick line'), axis.x.style); attrs(svg.selectAll('.x-axis-bottom .tick text'), axis.x.text.style as any); // Style the annotations with their specific color xAnnotationAxisContainer .selectAll('g.tick') .select('text') .style('fill', (d, i) => annotations[i].color) .style('font-size', '0.475rem'); xAnnotationAxisContainer .selectAll('line') .style('opacity', 0); // Hide the line for the annotations axis xAnnotationAxisContainer.call(g => g.select(".domain").remove()); } /** Y-Axis (value axis) set up */ appendDomainRange({ data: dataSets, domain, range: [h, 0], scale: y, stacked: isStacked({ groupLayout, stacked }) }); ticks({ axis: yAxis, valuesCount, axisLength: w, axisConfig: axis.y, scaleBand: y, }); yAxisContainer .attr('transform', 'translate(' + yAxisWidth(axis) + ', 0)') .transition() .call(yAxis); 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); }, /** * Draw a single data set into the chart */ updateChart( bins: string[], groupData: IGroupData, ) { const { axis, annotations, data, height, width, margin, delay, duration, tip, groupLayout, showBinPercentages, stacked } = props; const stackedOffset = (d: IGroupDataItem, stackIndex: number): number => { const thisGroupData = groupData.find((gData) => { return gData.find((dx) => dx.label === d.label) !== undefined; }); const oSet = thisGroupData ? thisGroupData .filter((_, i) => i < stackIndex) .reduce((prev, next) => prev + next.value, 0) : 0; const offset = isStacked({ groupLayout, stacked }) && stackIndex > 0 ? oSet : 0; return y(d.value + offset); } const calculateXPosition = (d: IGroupDataItem, stackIndex: number, offset?: number): number => { const totalWidth = innerScaleBand.bandwidth(); const barWidth = getBarWidth(stackIndex, props.groupLayout, props.bar, innerScaleBand); const overlaidXPos = (totalWidth / 2) - (barWidth / 2); const finalXPos = (props.groupLayout === EGroupedBarLayout.OVERLAID) ? overlaidXPos : Number(innerScaleBand(String(d.groupLabel))); return offset ? finalXPos + offset : finalXPos; } const colors = scaleOrdinal(props.colorScheme); const gHeight = gridHeight(props); const g = container .selectAll<SVGElement, {}>('g') .data(groupData); const bars = g.enter() .append<SVGElement>('g') .merge(g) .attr('transform', (d: any[]) => { let xd = x(d[0].label); if (xd === undefined) { xd = 0; } const xDelta = yAxisWidth(axis) + axis.y.style['stroke-width'] + xd; return `translate(${xDelta}, 0)`; }) .selectAll<SVGElement, {}>('rect') .data((d) => d); bars .enter() .append<SVGElement>('rect') .attr('height', 0) .attr('y', stackedOffset) .attr('class', 'bar') .on('click', onClick(props.onClick)) .on('mouseover', onMouseOver({ bins, hover: props.bar.hover, colors, tipContentFn: props.tipContentFn, tipContent, tip, tipContainer })) .on('mousemove', () => tip.fx.move(tipContainer)) .on('mouseout', onMouseOut({ tip, tipContainer, colors })) .merge(bars) .attr('x', (d: IGroupDataItem, i: number) => calculateXPosition(d, i)) .attr('width', (d, i) => getBarWidth(i, props.groupLayout, props.bar, innerScaleBand)) .attr('fill', (d, i) => colors(String(i))) .transition() .duration(duration) .delay(delay) .attr('y', stackedOffset) // Hide bar's bottom border .attr('stroke-dasharray', (d: IGroupDataItem, i): string => { const currentHeight = gHeight - (y(d.value)); const barWidth = getBarWidth(i, props.groupLayout, props.bar, innerScaleBand); return `${barWidth} 0 ${currentHeight} ${barWidth}`; }) .attr('height', (d: IGroupDataItem): number => gHeight - (y(d.value))); // We need to show the bar percentage splits if flag enabled if (showBinPercentages) { const percents = g.enter() .append<SVGElement>('g') .merge(g) .attr('transform', (d: any[]) => { let xd = x(d[0].label); if (xd === undefined) { xd = 0; } const xDelta = yAxisWidth(axis) + axis.y.style['stroke-width'] + xd; return `translate(${xDelta}, 0)`; }) .selectAll<SVGElement, {}>('text') .data((d) => d); percents .enter() .append<SVGElement>('text') .attr('class', 'percentage-label') .attr('y', stackedOffset) .data((d) => d) .merge(percents) .text((d, i) => { // To show the correct percentage split we need to total all the other values in this count set const total = groupData.reduce((prev, group) => prev + group[i].value, 0); const percentage = d.value === 0 ? 0 : Math.round((d.value / total) * 100); return showBinPercentages[i] ? `${percentage}%` : ''; }) .style('text-anchor', 'middle') .style('font-size', '0.675rem') .attr('fill', (d, i) => colors(String(i))) .attr('x', (d: IGroupDataItem, i: number) => { const barWidthForOffset = getBarWidth(i, props.groupLayout, props.bar, innerScaleBand); return calculateXPosition(d, i, barWidthForOffset / 2); }) .attr('dy', -2); percents.exit().remove(); }; bars.exit().remove(); g.exit().remove(); const xText = xAxisLabel .selectAll('text') .data([axis.x.label]); xText.enter().append('text') .attr('class', 'x-axis-label') .merge(xText) .attr('transform', 'translate(' + (Number(width) / 2) + ' ,' + ((height - xAxisHeight(props.axis) - (margin.left * 2)) + axis.x.margin) + ')') .style('text-anchor', 'middle') .text((d) => d); const yText = yAxisLabel .selectAll('text') .data([axis.y.label]); yText.enter().append('text') .attr('class', 'y-axis-label') .merge(yText) .attr('transform', 'rotate(-90)') .attr('y', 0) .attr('x', 0 - (gHeight / 2 - (margin.top * 2))) .attr('dy', '1em') .style('text-anchor', 'middle') .text((d) => d); }, /** * Update chart */ update(el: Element, newProps: DeepPartial<IHistogramProps>) { if (!newProps.data) { return; } this.mergeProps(newProps); if (!props.data.bins) { return; } const { margin, width, height, className, data, visible } = props; sizeSVG(svg, { margin, width, height, className }); dataSets = []; data.counts.forEach((count) => { count.data.forEach((value, i) => { if (!dataSets[i]) { dataSets[i] = []; } dataSets[i].push({ groupLabel: count.label, label: data.bins[i], value: visible[data.bins[i]] !== false && visible[count.label] !== false ? value : 0, }); }); }); this.drawAxes(); drawGrid(x, y, gridX, gridY, props, maxValueCount(data.counts)); this.updateChart(data.bins, dataSets); }, /** * Any necessary clean up */ destroy(el: Element) { svg.selectAll('svg > *').remove(); }, }; return HistogramD3; });