cl-react-graph
Version:
466 lines (419 loc) • 13.1 kB
text/typescript
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;
});