cl-react-graph
Version:
587 lines (522 loc) • 16.8 kB
text/typescript
import { extent } from 'd3-array';
import { axisBottom, axisLeft } from 'd3-axis';
import { scaleBand, scaleLinear, ScaleLinear, scaleOrdinal } from 'd3-scale';
import { select } from 'd3-selection';
import merge from 'deepmerge';
import * as get from 'lodash.get';
import colorScheme from './colors';
import attrs from './d3/attrs';
import { IChartAdaptor, IHistogramData, IHistogramDataSet } from './Histogram';
import { IJoyPlotProps } from './JoyPlot';
import tips, { makeTip } from './tip';
interface IGroupDataItem {
label: string;
value: number;
}
type IGroupData = IGroupDataItem[][];
export const joyPlotD3 = ((): IChartAdaptor => {
let svg;
let tipContainer;
let tipContent;
let props: IJoyPlotProps;
let dataSets: IGroupData[];
const yOuterScaleBand = scaleBand();
const y = scaleLinear();
const x = scaleBand();
const innerScaleBand = scaleBand();
// Gridlines in x axis function
function make_x_gridlines(ticks: number = 5) {
return axisBottom(x)
.ticks(ticks);
}
// Gridlines in y axis function
function make_y_gridlines(ticks: number = 5) {
return axisLeft(yOuterScaleBand)
.ticks(ticks);
}
const defaultProps: IJoyPlotProps = {
axis: {
x: {
height: 20,
label: '',
margin: 10,
style: {
'fill': 'none',
'shape-rendering': 'crispEdges',
'stroke': '#666',
'stroke-opacity': 1,
'stroke-width': 1,
},
text: {
style: {
fill: '#666',
},
},
},
y: {
label: '',
style: {
'fill': 'none',
'shape-rendering': 'crispEdges',
'stroke': '#666',
'stroke-opacity': 1,
'stroke-width': 1,
},
text: {
style: {
fill: '#666',
},
},
ticks: 10,
width: 25,
},
},
bar: {
groupMargin: 0,
margin: 0,
width: 50,
},
className: 'histogram-d3',
colorScheme,
data: [{
bins: [],
counts: [],
}],
delay: 0,
domain: {
max: null,
min: null,
},
duration: 400,
grid: {
x: {
style: {
'fill': 'none',
'stroke': '#bbb',
'stroke-opacity': 0.7,
'stroke-width': 1,
},
ticks: 5,
visible: true,
},
y: {
style: {
'fill': 'none',
'stroke': '#bbb',
'stroke-opacity': 0.7,
'stroke-width': 1,
},
ticks: 5,
visible: true,
},
},
height: 200,
margin: {
left: 5,
top: 5,
},
stroke: {
color: '#005870',
dasharray: '',
linecap: 'butt',
width: 0,
},
tip: tips,
tipContainer: 'body',
tipContentFn: (bins, i, d, joyTitle): string =>
joyTitle + ': ' +
bins[i] + '<br />' + d,
visible: {},
width: 200,
};
const JoyPlotD3 = {
/**
* Initialization
* @param {Node} el Target DOM node
* @param {Object} props Chart properties
*/
create(el: HTMLElement, newProps: Partial<IJoyPlotProps> = {}) {
this.mergeProps(newProps);
this._makeSvg(el);
this.makeGrid(props);
this.makeScales();
this.containers = props.data.map((d, i) => svg
.append('g')
.attr('class', `histogram-container-${i}`),
);
this.update(el, props);
},
mergeProps(newProps: Partial<IJoyPlotProps>) {
props = merge<IJoyPlotProps>(defaultProps, newProps);
props.data = newProps.data;
if (newProps.colorScheme) {
props.colorScheme = newProps.colorScheme;
}
},
/**
* Make the SVG container element
* Recreate if it previously existed
* @param {Dom} el Dom container node
*/
_makeSvg(el) {
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;
},
/**
* Get a max count of values in each data set
* @param {Object} counts Histogram data set values
* @return {Number} count
*/
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
*/
appendDomainRange(scale: ScaleLinear<number, number>, data: IGroupData[]): void {
const yDomain: number[] = [];
const { domain } = props;
const allCounts: number[] = data.reduce((prev, next) => {
const thisCounts: number[] = next.reduce((p, n) => {
return [...p, ...n.map((item) => item.value)];
}, [] as number[]);
return [...prev, ...thisCounts];
}, [0]);
const thisExtent = extent(allCounts, (d) => d);
yDomain[1] = domain && domain.hasOwnProperty('max') && domain.max !== null
? domain.max
: thisExtent[1];
yDomain[0] = domain && domain.hasOwnProperty('min') && domain.min !== null
? domain.min
: thisExtent[0];
const yRange = [yOuterScaleBand.bandwidth(), 0];
scale.range(yRange)
.domain(yDomain);
},
yAxisWidth() {
const { axis } = props;
return axis.y.label === ''
? axis.y.width
: axis.y.width + 30;
},
xAxisHeight() {
const { axis } = props;
return axis.x.label === ''
? axis.x.height
: axis.x.height + 30;
},
makeScales() {
const { axis, margin, height, width } = props;
this.xAxis = svg.append('g').attr('class', 'x-axis');
this.yAxis = svg.append('g').attr('class', 'y-axis');
if (axis.x.label !== '') {
svg.append('text')
.attr('class', 'x-axis-label')
.attr('transform',
'translate(' + (Number(width) / 2) + ' ,' +
((height - this.xAxisHeight() - (margin.left * 2)) + 10 + axis.x.margin) + ')')
.style('text-anchor', 'middle')
.text(axis.x.label);
}
if (axis.y.label !== '') {
svg.append('text')
.attr('class', 'y-axis-label')
.attr('transform', 'translate(0, -' + this.gridHeight() + ')rotate(-90)')
.attr('y', 0 - margin.left)
.attr('x', 0 - (height / 2 - (margin.top * 2)))
.attr('dy', '1em')
.style('text-anchor', 'middle')
.text(axis.y.label);
}
},
getBins() {
return props.data.reduce((prev, next) => {
return Array.from(new Set(prev.concat(next.bins)));
}, [] as string[]);
},
/**
* Draw scales
* @param {Object} data Chart data
*/
_drawScales(data: IHistogramData[]) {
const { bar, domain, margin, width, height, axis } = props;
const valuesCount = data.reduce((prev, next) => {
const c = this.valuesCount(next.counts);
return c > prev ? c : prev;
}, 0);
const w = this.gridWidth();
let xAxis;
const dataLabels = data[0].counts.map((c) => c.label);
const bins = this.getBins();
x
.domain(bins)
.rangeRound([0, w])
.paddingInner(this.groupedMargin());
innerScaleBand
.domain(dataLabels)
.rangeRound([0, x.bandwidth()])
.paddingInner(this.barMargin());
xAxis = axisBottom(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)));
}
}
this.xAxis
.attr('transform', 'translate(' + (this.yAxisWidth() + axis.y.style['stroke-width']) + ',' +
(height - this.xAxisHeight() - (margin.left * 2)) + ')')
.call(xAxis);
const yLabels = data.map((d) => d.title);
const yOuterBounds: [number, number] = [height - (margin.top * 2) - this.xAxisHeight(), 0];
yOuterScaleBand.domain(yLabels)
.rangeRound(yOuterBounds);
this.appendDomainRange(y, dataSets);
const yAxis = axisLeft(yOuterScaleBand).ticks(axis.y.ticks);
const yTickSize = get(axis, 'y.tickSize', undefined);
if (yTickSize !== undefined) {
yAxis.tickSize(yTickSize);
}
this.yAxis
.attr('transform', 'translate(' + this.yAxisWidth() + ', 0)')
.transition()
.call(yAxis);
const { ...xLabelStyle } = axis.x.text.style;
const { ...yLabelStyle } = axis.y.text.style;
attrs(svg.selectAll('.y-axis .domain, .y-axis .tick line'), axis.y.style);
attrs(svg.selectAll('.y-axis .tick text'), axis.y.text.style);
attrs(svg.selectAll('.x-axis .domain, .x-axis .tick line'), axis.x.style);
attrs(svg.selectAll('.x-axis .tick text'), axis.x.text.style);
},
/**
* Calculate the width of the area used to display the
* chart bars. Removes chart margins and Y axis from
* chart total width.
* @return {number} width
*/
gridWidth(): number {
const { axis, width, margin } = props;
return Number(width) - (margin.left * 2) - this.yAxisWidth();
},
/**
* Calculate the height of the area used to display the
* chart bars. Removes chart margins and X axis from
* chart total height.
* @return {number} width
*/
gridHeight(): number {
const { height, margin, axis } = props;
return height - (margin.top * 2) - this.xAxisHeight();
},
/**
* Returns the margin between similar bars in different data sets
* @return {Number} Margin
*/
groupedMargin(): number {
const m = get(props.bar, 'groupMargin', 0.1);
return m >= 0 && m <= 1
? m
: 0;
},
barMargin(): number {
const m = get(props.bar, 'margin', 0);
return m >= 0 && m <= 1
? m
: 0.1;
},
/**
* Calculate the bar width
* @return {number} bar width
*/
barWidth() {
return innerScaleBand.bandwidth();
},
/**
* Draw a single data set into the chart
*/
updateChart(
groupData: IGroupData[],
) {
const bins = this.getBins();
const { height, width, margin, bar, delay, duration,
axis, stroke, tip, tipContentFn } = props;
const barWidth = this.barWidth();
const colors = scaleOrdinal(props.colorScheme);
const borderColors = scaleOrdinal(['#FFF']);
const yAxisWidth = this.yAxisWidth();
const groupedMargin = this.groupedMargin();
const maxItems = groupData.reduce((prev, next) => {
const thisMax = next.reduce((p, n) => n.length > p ? n.length : p, 0);
return thisMax > prev ? thisMax : prev;
}, 0);
groupData.forEach((data, i) => {
const joyTitle = props.data[i].title;
const g = this.containers[i]
.selectAll('g')
.data(data);
const bars = g.enter()
.append('g')
.merge(g)
.attr('transform', (d) => {
const xdelta = yAxisWidth
+ axis.y.style['stroke-width']
+ x(d[0].label);
const ydelta = yOuterScaleBand(d[0].joyLabel);
return `translate(${xdelta}, ${ydelta})`;
})
.selectAll('rect')
.data((d) => d);
bars
.enter()
.append('rect')
.attr('height', 0)
.attr('y', (d: IGroupDataItem): number => yOuterScaleBand.bandwidth())
.attr('class', 'bar')
.attr('x', (d) => innerScaleBand(d.groupLabel))
.attr('width', (d) => barWidth)
.attr('fill', (d, ix) => colors(ix))
.on('mouseover', (d: IGroupDataItem) => {
const ix = bins.findIndex((b) => b === d.label);
tipContent.html(() => tipContentFn(bins, ix, d.value, joyTitle));
tip.fx.in(tipContainer);
})
.on('mousemove', () => tip.fx.move(tipContainer))
.on('mouseout', () => tip.fx.out(tipContainer))
.merge(bars)
.transition()
.duration(duration)
.delay(delay)
.attr('y', (d: IGroupDataItem): number => y(d.value))
.attr('stroke', (d, ix) => {
if (borderColors) {
return borderColors(ix);
}
})
.attr('shape-rendering', 'crispEdges')
.attr('stroke-width', stroke.width)
.attr('stroke-linecap', stroke.linecap)
// Hide bar's bottom border
.attr('stroke-dasharray',
(d: IGroupDataItem): string => {
const currentHeight = yOuterScaleBand.bandwidth() - y(d.value);
return `${barWidth} 0 ${currentHeight} ${barWidth}`;
})
.attr('height', (d: IGroupDataItem): number =>
yOuterScaleBand.bandwidth() - y(d.value),
);
g.exit().remove();
});
},
makeGrid(props: IJoyPlotProps) {
const { grid } = props;
this.gridX = svg.append('g')
.attr('class', 'grid gridX');
this.gridY = svg.append('g')
.attr('class', 'grid gridY');
},
/**
* Draw a grid onto the chart background
* @param {Object} props Props
*/
_drawGrid() {
const { data, height, width, axis, grid, margin, bar } = props;
const ticks = data.reduce((prev, next) => {
const c = this.valuesCount(next.counts);
return c > prev ? prev : c;
}, 0);
const axisWidth = axis.y.style['stroke-width'];
const offset = {
x: this.yAxisWidth() + axisWidth,
y: this.gridHeight(),
};
if (grid.x.visible) {
// Add the X gridlines
this.gridX.attr('transform', `translate(${offset.x}, ${offset.y})`);
this.gridX.call(make_x_gridlines(get(grid, 'x.ticks', ticks))
.tickSize(-height + this.xAxisHeight() + (margin.top * 2))
.tickFormat(() => ''));
attrs(this.gridX.selectAll('.tick line'), grid.x.style);
attrs(this.gridX.selectAll('.domain'), { stroke: 'transparent' });
}
if (grid.y.visible) {
// add the Y gridlines
this.gridY.attr('transform', 'translate(' + (this.yAxisWidth() + axisWidth) + ', 0)')
.transition()
.call(make_y_gridlines(get(grid, 'y.ticks', ticks))
.tickSize(-width + (margin.left * 2) + this.yAxisWidth())
.tickFormat(() => ''),
);
attrs(this.gridY.selectAll('.tick line'), grid.y.style);
// Hide the first horizontal grid line to show axis
this.gridY.selectAll('.gridY .tick line').filter((d, i) => i === 0)
.attr('display', 'none');
attrs(this.gridY.selectAll('.domain'), { stroke: 'transparent' });
}
},
/**
* Update chart
* @param {HTMLElement} el Chart element
* @param {Object} props Chart props
*/
update(el: HTMLElement, newProps: IJoyPlotProps) {
if (!props.data) {
return;
}
this.mergeProps(newProps);
const { data, visible } = props;
dataSets = data.map((d) => {
const lineData = [] as IGroupData;
d.counts.forEach((count) => {
count.data.forEach((value, i) => {
if (!lineData[i]) {
lineData[i] = [];
}
lineData[i].push({
groupLabel: count.label,
joyLabel: d.title,
label: d.bins[i],
value: visible[d.bins[i]] !== false && visible[count.label] !== false ? value : 0,
} as IGroupDataItem);
});
});
return lineData;
});
this._drawScales(props.data);
this._drawGrid();
this.updateChart(dataSets);
},
/**
* Any necessary clean up
* @param {Element} el To remove
*/
destroy(el: HTMLElement) {
svg.selectAll('svg > *').remove();
},
};
return JoyPlotD3;
});