UNPKG

cl-react-graph

Version:
466 lines (419 loc) 13.1 kB
import { extent } from 'd3-array'; import { axisBottom, axisLeft, } from 'd3-axis'; import { format } from 'd3-format'; import { scaleBand, scaleLinear, ScaleLinear, scaleOrdinal, } from 'd3-scale'; import { select, Selection, } from 'd3-selection'; import { timeFormat } from 'd3-time-format'; import get from 'lodash.get'; import merge from 'lodash.merge'; import colorScheme from './colors'; import attrs from './d3/attrs'; import { drawGrid, gridHeight, gridWidth, xAxisHeight, yAxisWidth, } from './grid'; import { IAxis, IChartAdaptor, IHistogramDataSet, IHistogramProps, } from './Histogram'; import tips, { makeTip } from './tip'; import { axis as defaultAxis, grid, } from './utils/defaults'; import { DeepPartial } from './utils/types'; export const shouldFormatTick = (axis: IAxis): boolean => { return (axis.scale === 'TIME' && axis.hasOwnProperty('dateFormat')) || axis.hasOwnProperty('numberFormat'); } export const formatTick = (axis: IAxis) => (v: string | number) => { if (axis.scale === 'TIME') { return timeFormat(axis.dateFormat)(new Date(v)); } return format(axis.numberFormat)(Number(v)) }; interface IGroupDataItem { label: string; groupLabel?: string; value: number; } 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 innerScaleBand = scaleBand(); let container: Selection<SVGElement, any, any, any>; let dataSets: any[]; let gridX: any; let gridY: any; let yAxisContainer: Selection<any, any, any, any>; let xAxisContainer: Selection<any, any, any, any>; let xAxisLabel: any; let yAxisLabel: any; const props: IHistogramProps = { axis: defaultAxis, bar: { groupMargin: 0.1, margin: 0, width: 50, }, className: 'histogram-d3', colorScheme, data: { bins: [], counts: [], }, delay: 0, domain: { max: null, min: null, }, duration: 400, grid, height: 200, margin: { bottom: 0, left: 5, right: 0, top: 5, }, stacked: false, 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); this._makeSvg(el); this.makeGrid(); this.makeScales(); 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; } }, /** * Make the SVG container element * Recreate if it previously existed */ _makeSvg(el: Element) { if (svg) { svg.selectAll('svg > *').remove(); svg.remove(); const childNodes = el.getElementsByTagName('svg'); if (childNodes.length > 0) { el.removeChild(childNodes[0]); } } // Reference to svg element containing chart svg = select(el).append('svg'); this.sizeSVG(); const r = makeTip(props.tipContainer, tipContainer); tipContent = r.tipContent; tipContainer = r.tipContainer; }, sizeSVG() { const { margin, width, height, className } = props; const scale = { x: 1 - (margin.left / Number(width)), y: 1 - (margin.top / height), }; 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}) scale(${scale.x},${scale.y})`); }, valuesCount(counts: IHistogramDataSet[]): number { return counts.reduce((a: number, b: IHistogramDataSet): number => { return b.data.length > a ? b.data.length : a; }, 0); }, /** * Update a linear scale with range and domain values taken either from the compiled * group data. If the chart is stacked then sum all bin values first. */ appendDomainRange(scale: ScaleLinear<number, number>, data: IGroupData): void { const yDomain: number[] = []; const { domain, margin, height, stacked } = props; const allCounts: number[] = data.reduce((prev: number[], next): number[] => { return stacked ? [...prev, next.reduce((p: number, n): number => p + n.value, 0)] : [...prev, ...next.map((n) => n.value)]; }, [0]); const thisExtent = extent<any>(allCounts, (d) => d); yDomain[1] = domain && domain.hasOwnProperty('max') && domain.max !== null ? domain.max : Number(thisExtent[1]); yDomain[0] = domain && domain.hasOwnProperty('min') && domain.min !== null ? domain.min : Number(thisExtent[0]); const yRange = [height - (margin.top * 2) - xAxisHeight(props.axis), 0]; scale.range(yRange) .domain(yDomain); }, makeScales() { xAxisContainer = svg.append('g').attr('class', 'x-axis'); yAxisContainer = svg.append('g').attr('class', 'y-axis'); xAxisLabel = svg.append('g'); yAxisLabel = svg.append('g'); }, /** * Draw scales */ _drawScales() { const { axis, data, margin, height, stacked } = props; const valuesCount = this.valuesCount(data.counts); const w = gridWidth(props); const dataLabels = data.counts.map((c) => c.label); x .domain(data.bins) .rangeRound([0, w]) .paddingInner(this.groupedMargin()); innerScaleBand .domain(stacked ? ['main'] : dataLabels) .rangeRound([0, x.bandwidth()]) .paddingInner(this.barMargin()); const xAxis = axisBottom<string>(x); const tickSize = get(axis, 'x.tickSize', undefined); if (tickSize !== undefined) { xAxis.tickSize(tickSize); } else { if (w / valuesCount < 10) { // Show one in 10 x axis labels xAxis.tickValues(x.domain().filter((d, i) => !(i % 10))); } } if (shouldFormatTick(axis.x)) { xAxis.tickFormat(formatTick(axis.x)); } xAxisContainer .attr('transform', 'translate(' + (yAxisWidth(axis) + axis.y.style['stroke-width']) + ',' + (height - xAxisHeight(props.axis) - (margin.left * 2)) + ')') .call(xAxis); this.appendDomainRange(y, dataSets); const yAxis = axisLeft<number>(y).ticks(axis.y.ticks); const yTickSize = get(axis, 'y.tickSize', undefined); if (yTickSize !== undefined) { yAxis.tickSize(yTickSize); } if (shouldFormatTick(axis.y)) { yAxis.tickFormat(formatTick(axis.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); }, /** * Returns the margin between similar bars in different data sets */ groupedMargin(): number { const m = get(props.bar, 'groupMargin', 0.1); return m >= 0 && m <= 1 ? m : 0.1; }, barMargin(): number { const m = get(props.bar, 'margin', 0); return m >= 0 && m <= 1 ? m : 0.1; }, barWidth() { return innerScaleBand.bandwidth(); }, /** * Draw a single data set into the chart */ updateChart( bins: string[], groupData: IGroupData, ) { const { axis, height, width, margin, delay, duration, tip, stacked } = props; const barWidth = this.barWidth(); const barY = (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 = stacked && stackIndex > 0 ? oSet : 0; return y(d.value + offset); } // const borderColors = set.borderColors ? d3.scaleOrdinal(set.borderColors) : null; 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); // Don't ask why but we must reference tipContentFn as props.tipContentFn otherwise // it doesn't update with props changes const onMouseOver = (d: IGroupDataItem, i: number) => { const ix = bins.findIndex((b) => b === d.label); tipContent.html(() => props.tipContentFn(bins, ix, d.value)); tip.fx.in(tipContainer); }; bars .enter() .append<SVGElement>('rect') .attr('height', 0) .attr('y', barY) .attr('class', 'bar') .on('mouseover', onMouseOver) .on('mousemove', () => tip.fx.move(tipContainer)) .on('mouseout', () => tip.fx.out(tipContainer)) .merge(bars) .attr('x', (d: IGroupDataItem) => { return Number(innerScaleBand(String(d.groupLabel))); }) .attr('width', (d) => barWidth) .attr('fill', (d, i) => colors(String(i))) .transition() .duration(duration) .delay(delay) .attr('y', barY) // Hide bar's bottom border .attr('stroke-dasharray', (d: IGroupDataItem): string => { const currentHeight = gHeight - (y(d.value)); return `${barWidth} 0 ${currentHeight} ${barWidth}`; }) .attr('height', (d: IGroupDataItem): number => gHeight - (y(d.value))); 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); }, makeGrid() { gridX = svg.append('g') .attr('class', 'grid gridX'); gridY = svg.append('g') .attr('class', 'grid gridY'); }, /** * Update chart */ update(el: Element, newProps: DeepPartial<IHistogramProps>) { if (!newProps.data) { return; } this.mergeProps(newProps); if (!props.data.bins) { return; } this.sizeSVG(); const { data, visible } = props; dataSets = [] as IGroupData; 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, } as IGroupDataItem); }); }); this._drawScales(); drawGrid(x, y, gridX, gridY, props, this.valuesCount(data.counts)); this.updateChart(data.bins, dataSets); }, /** * Any necessary clean up */ destroy(el: Element) { svg.selectAll('svg > *').remove(); }, }; return HistogramD3; });