UNPKG

cl-react-graph

Version:
525 lines (465 loc) 14.9 kB
import { axisBottom, axisLeft, } from 'd3-axis'; import { format } from 'd3-format'; import { scaleBand, scaleLinear, scaleOrdinal, scalePoint, } from 'd3-scale'; import { Selection } from 'd3-selection'; import { timeFormat } from 'd3-time-format'; import cloneDeep from 'lodash/cloneDeep'; import mergeWith from 'lodash/mergeWith'; import { IGroupData, IGroupDataItem, } from './BaseHistogramD3'; import colorScheme from './colors'; import attrs from './d3/attrs'; import { gridHeight, gridWidth, xAxisHeight, yAxisWidth, } from './grid'; import { EGroupedBarLayout, IChartAdaptor, } from './Histogram'; import tips, { makeTip } from './tip'; import { ITornadoDataSet, ITornadoProps, } from './Tornado'; import { getBarWidth, groupedBarsUseSameXAxisValue, groupedPaddingInner, paddingInner, } from './utils/bars'; import { axis as defaultAxis, grid as defaultGrid, } from './utils/defaults'; import { applyDomainAffordance, shouldFormatTick, ticks, tickSize, } 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 const maxValueCount = (counts: ITornadoDataSet[]): number => { return counts.reduce((a: number, b: ITornadoDataSet): number => { return b.data.length > a ? b.data.length : a; }, 0); }; // The height for the x axis labels showing the left/right labels. const SPLIT_AXIS_HEIGHT = 20; const calculatePercents = (groupData: IGroupData) => { const totals = groupData.reduce((prev, next) => { next.forEach((datum, i) => { const side = datum.side!; const groupLabel = datum.groupLabel!; if (!prev[groupLabel]) { prev[groupLabel] = { left: 0, right: 0 }; } prev[groupLabel][side] = prev[groupLabel][side] + datum.value; }) return prev; }, {} as Record<string, { left: number, right: number }>); return groupData.map((data, i) => { return data.map((datum) => { const side = datum.side!; const groupLabel = datum.groupLabel!; const total = totals[groupLabel][side]; return { ...datum, percent: total === 0 ? 0 : Math.round(datum.value / total * 100), } }) }); } export class TornadoD3 { svg: undefined | Selection<any, any, any, any>;; tipContainer; tipContent; x = scaleLinear(); y = scaleBand(); innerScaleBand = scaleBand(); container: undefined | Selection<SVGElement, any, any, any>; dataSets: IGroupData = [[]]; gridX: undefined | TSelection; gridY: undefined | TSelection; yAxisContainer: undefined | TSelection; xAxisContainer: undefined | TSelection; xAxisContainer2: undefined | TSelection; yAxisLabel: undefined | TSelection; xAxisLabel: undefined | TSelection; domain: [number, number] = [0, 0]; props: ITornadoProps = { axis: cloneDeep(defaultAxis), bar: { grouped: { paddingInner: 0.1, paddingOuter: 0, }, paddingInner: 0.1, paddingOuter: 0, overlayMargin: 5, }, className: 'torando-d3', colorScheme, center: true, data: { bins: [], colorScheme: [], counts: [], }, delay: 0, domain: { max: null, min: null, }, duration: 400, grid: defaultGrid, groupLayout: EGroupedBarLayout.GROUPED, height: 200, margin: { bottom: 0, left: 5, right: 0, top: 5, }, showBinPercentages: false, splitBins: ['Left', 'Right'], 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, }; /** * Initialization */ create(el: Element, newProps: DeepPartial<ITornadoProps> = {}) { const { props } = this; this.mergeProps(newProps); this.svg = makeSvg(el, undefined); const { margin, width, height, className } = props; sizeSVG(this.svg, { margin, width, height, className }); const r = makeTip(props.tipContainer, this.tipContainer); this.tipContent = r.tipContent; this.tipContainer = r.tipContainer; [this.gridX, this.gridY] = makeGrid(this.svg); // Used to display the 2 split bin labels this.xAxisContainer2 = this.svg.append('g').attr('class', 'xAxisContainer2'); this.container = this.svg .append<SVGElement>('g') .attr('class', 'histogram-container'); // Render Axis above bars so that we can see the y axis overlaid [this.xAxisContainer, this.yAxisContainer, this.xAxisLabel, this.yAxisLabel] = makeScales(this.svg); this.update(props); } /** * Draw Axes */ drawAxes() { const { props, svg, x, y, innerScaleBand, yAxisContainer, domain, xAxisContainer, xAxisContainer2 } = this; const { bar, data, groupLayout, margin, width, height, axis } = props; const valuesCount = maxValueCount(data.counts); const w = gridWidth(props); const h = gridHeight(props) - SPLIT_AXIS_HEIGHT; const dataLabels = data.counts.map((c) => c.label); y.domain(data.bins) .rangeRound([h, 0]) .paddingInner(groupedPaddingInner(bar)); innerScaleBand .domain(groupedBarsUseSameXAxisValue({ groupLayout }) ? ['main'] : dataLabels) .rangeRound([0, y.bandwidth()]) .paddingInner(paddingInner(props.bar)); const xAxis = axisBottom<number>(x) .tickFormat((v) => { const n = v.toString().replace('-', ''); if (shouldFormatTick(axis.x)) { if (axis.x.scale === 'TIME') { return timeFormat(axis.x.dateFormat)(new Date(n)); } return isNaN(Number(v)) ? n : format(axis.x.numberFormat)(Number(n)) } return n; }); tickSize({ axis: xAxis, axisLength: w, scaleBand: x, axisConfig: axis.x, limitByValues: false, valuesCount: 10, }); this.calculateDomain(); const x2 = scalePoint<any>(); const xGroupAxis = axisBottom(x2).tickPadding(SPLIT_AXIS_HEIGHT) .tickSize(0) x2.range([Number(width) / 4, Number(width) * (3 / 4) - (margin.top * 2) - axis.y.width]) .domain(props.splitBins); /** Y-Axis (label axis) set up */ const yAxis = axisLeft<string>(y); ticks({ axis: yAxis, valuesCount, axisLength: h, axisConfig: axis.y, scaleBand: y, limitByValues: true, }); // @TODO - Stacked? (was using appendDomainRange()) x.range([0, Number(width) - (margin.top * 2) - axis.y.width]) .domain(this.domain) .nice(); const xAxisY = height - xAxisHeight(props.axis) - margin.top - SPLIT_AXIS_HEIGHT; xAxisContainer ?.attr('transform', 'translate(' + yAxisWidth(axis) + ',' + xAxisY + ')') .call(xAxis); xAxisContainer2 ?.attr('transform', 'translate(' + yAxisWidth(axis) + ',' + (xAxisY) + ')') .call(xGroupAxis); // Move the y axis ticks to the left of the chart (need to go after the x axis range set up) yAxis.tickPadding(x(0) + 10) yAxisContainer // Place the y axis in the middle of the chart ?.attr('transform', 'translate(' + (yAxisWidth(axis) + x(0)) + ', ' + margin.top + ' )') .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); } calculateDomain() { const { props } = this; const { data, center } = props; const leftValues = data.counts.reduce((prev, next) => prev.concat(next.data[0]), [] as number[]); const rightValues = data.counts.reduce((prev, next) => prev.concat(next.data[1]), [] as number[]); // Use applyDomainAffordance to allow space for percentage labels this.domain = [ applyDomainAffordance(-Math.max(...leftValues)), applyDomainAffordance(Math.max(...rightValues)), ]; // Center the 0 axis value in the middle of the chart if (center) { const max = Math.max(Math.max(...leftValues), this.domain[1]); this.domain = [ applyDomainAffordance(-max), applyDomainAffordance(max)]; } return this.domain; } /** * Draw a single data set into the chart */ updateChart( bins: string[], groupData: IGroupData, ) { const { props, container, tipContainer, yAxisLabel, x, innerScaleBand, tipContent, y } = this; const { axis, height, margin, delay, duration, tip, showBinPercentages } = props; const percentData = calculatePercents(groupData); const stackedOffset = (d: IGroupDataItem, stackIndex: number) => { const w = d.side === 'left' ? -d.value : d.value; return x(Math.min(0, w)); } const colors = scaleOrdinal(props.colorScheme); const gWidth = gridWidth(props); const g = container ?.selectAll<SVGElement, {}>('g') .data(percentData); const bars = g?.enter() .append<SVGElement>('g') .merge(g) .attr('transform', (d: any[]) => { let yd = y(d[0].label); if (yd === undefined) { yd = 0; } const xX = yAxisWidth(axis) + axis.x.style['stroke-width']; return `translate(${xX}, ${margin.top + yd})`; }) .selectAll<SVGElement, {}>('rect') .data((d) => d); bars ?.enter() .append<SVGElement>('rect') .attr('width', 0) .attr('x', (d) => x(0)) .attr('class', (d) => `bar ${d.side}`) .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('y', (d: IGroupDataItem, i: number) => { const overlay = (props.groupLayout === EGroupedBarLayout.OVERLAID) ? Math.floor(i / 2) * props.bar.overlayMargin : Number(innerScaleBand(String(d.groupLabel))); return overlay; }) .attr('height', (d, i) => getBarWidth(Math.floor(i / 2), props.groupLayout, props.bar, innerScaleBand)) .attr('fill', (d, i) => colors(String(d.groupLabel))) .transition() .duration(duration) .delay(delay) .attr('x', stackedOffset) .attr('width', (d: IGroupDataItem): number => { const w = d.side === 'left' ? -d.value : d.value; return Math.abs(x(w) - x(0)); }); const percents = g?.enter() .append<SVGElement>('g') .merge(g) .attr('transform', (d: any[]) => { let yd = y(d[0].label); if (yd === undefined) { yd = 0; } const xX = yAxisWidth(axis) + axis.x.style['stroke-width']; return `translate(${xX}, ${margin.top + yd})`; }) .selectAll<SVGElement, {}>('text') .data((d) => d); percents ?.enter() .append<SVGElement>('text') .attr('width', 0) .attr('x', (d) => { const w = d.side === 'left' ? -40 : 40; return x(0) + w; }) .attr('class', 'percentage-label') .style('text-anchor', 'middle') .style('font-size', '0.675rem') .merge(percents) .text((d, i) => { return showBinPercentages ? `${d.percent}%` : ''; }) .attr('y', (d: IGroupDataItem, i: number) => { const h = getBarWidth(0, props.groupLayout, props.bar, innerScaleBand); const offset = h / 2; // Ensure that percentage labels don't overlap const verticalOffset = i < 2 ? 0 : 14; return offset + verticalOffset; }) .attr('fill', (d, i) => colors(String(d.groupLabel))) .transition() .duration(duration) .delay(delay) .attr('x', (d) => { const w = d.side === 'left' ? - 20 : 20; const v = d.side === 'left' ? -d.value : d.value; return x(v) + w; }) .attr('width', (d: IGroupDataItem): number => { const w = d.side === 'left' ? -d.value : d.value; return Math.abs(x(w) - x(0)); }); percents?.exit().remove(); bars?.exit().remove(); g?.exit().remove(); const yText = yAxisLabel ?.selectAll<any, any>('text') .data([axis.y.label]); yText?.enter().append('text') .attr('class', 'y-axis-label') .merge(yText) .attr('transform', 'translate(' + 0 + ' ,' + ((height - yAxisWidth(props.axis) - (margin.left * 2)) + axis.x.margin) + ')') .style('text-anchor', 'middle') .text((d) => d); const xText = yAxisLabel ?.selectAll<any, any>('text') .data([axis.x.label]); xText?.enter().append('text') .attr('class', 'x-axis-label') .merge(xText) .attr('transform', 'rotate(-90)') .attr('y', 0) .attr('x', 0 - (gWidth / 2 - (margin.top * 2))) .attr('dy', '1em') .style('text-anchor', 'middle') .text((d) => d); } mergeProps(newProps: DeepPartial<ITornadoProps>) { const { props } = this; const customerizer = (objValue, srcValue, key, object, source, stack) => { if (['data'].includes(key)) { return srcValue; } } mergeWith(props, newProps, customerizer); } /** * Update chart */ update(newProps: DeepPartial<ITornadoProps>) { const { props } = this; if (!props.data) { return; } if (!props.data.bins) { return; } this.mergeProps(newProps); const { margin, width, height, className, data, visible } = props; sizeSVG(this.svg, { margin, width, height, className }); this.dataSets = []; data.counts.forEach((count) => { count.data.forEach((value, genderIndex) => { value.forEach((aValue, rowIndex) => { if (!this.dataSets[rowIndex]) { this.dataSets[rowIndex] = []; } this.dataSets[rowIndex].push({ side: genderIndex === 0 ? 'left' : 'right', groupLabel: count.label, colorRef: count.label, label: data.bins[rowIndex], value: visible[data.bins[rowIndex]] !== false && visible[count.label] !== false ? aValue : 0, }); }) }); }); this.drawAxes(); // @TODO add back in, // drawHorizontalGrid<any>({ x, y, gridX, gridY, props, ticks: maxValueCount(data.counts) }); this.updateChart(data.bins, this.dataSets); } /** * Any necessary clean up */ destroy() { this.svg?.selectAll('svg > *').remove(); } }