angular-d3-graph
Version:
Component for rendering a line graph or bar graph.
684 lines (637 loc) • 24.3 kB
text/typescript
import { Injectable, EventEmitter } from '@angular/core';
import { select, selectAll } from 'd3-selection';
import { transition } from 'd3-transition';
import { scaleLinear, scaleBand } from 'd3-scale';
import { easePoly } from 'd3-ease';
import { axisRight, axisBottom, axisLeft, axisTop } from 'd3-axis';
import { extent, max, bisector, scan } from 'd3-array';
import { format } from 'd3-format';
import { zoom, zoomIdentity, zoomTransform } from 'd3-zoom';
import { lineChunked } from 'd3-line-chunked';
import * as _merge from 'lodash.merge';
export class GraphService {
el;
svg;
data: any = [];
settings = {
id: 'd3graph',
props: { x: 'x', y: 'y' },
margin: { left: 48, right: 10, top: 10, bottom: 48 },
axis: {
x: { position: 'bottom', label: 'x', invert: false, extent: [], minVal: null, maxVal: null },
y: { position: 'left', label: 'y', invert: false, extent: [], minVal: null, maxVal: null }
},
transition: { ease: easePoly, duration: 1000 },
zoom: { enabled: false, min: 1, max: 10 },
debug: false
};
barHover = new EventEmitter();
barClick = new EventEmitter();
private type;
private zoomBehaviour;
private width;
private height;
private d3el;
private container;
private dataContainer;
private dataRect; // rectangle around the bounds of the graph data
private clip;
private defs;
private scales;
private transform = zoomIdentity;
private created = false;
private bisectX = bisector((d) => d[this.settings.props.x]).left;
private title;
private desc;
/**
* initializes the SVG element for the graph
* @param settings graph settings
*/
create(el, data, settings = {}): GraphService {
this.el = el;
this.d3el = select(this.el);
this.updateSettings(settings);
// build the SVG if it doesn't exist yet
if (!this.svg && this.d3el) {
this.createSvg();
}
this.setDimensions();
this.addZoomBehaviour();
this.created = true;
if (data) { this.update(data); }
return this;
}
isCreated() { return this.created; }
isLineGraph() { return this.type === 'line'; }
/**
* Sets the data for the graph and updates the view
* @param data new data for the graph
* @param type override the type of graph to render
*/
update(data, type?) {
this.setType(type ? type : this.detectTypeFromData(data));
this.svg.attr('class', this.type === 'line' ? 'line-graph' : 'bar-graph');
this.data = data;
this.updateView();
}
/**
* Adds axis and lines
* If any arguments are passed the rendered elements will not transition into place
*/
updateView(...args) {
this.setDimensions();
this.transform = zoomTransform(this.svg.node()) || zoomIdentity;
this.scales = this.getScales();
this.updateTitleDesc();
this.renderAxis(this.settings.axis.x, this.transform, args.length > 0)
.renderAxis(this.settings.axis.y, this.transform, args.length > 0);
this.type === 'line' ? this.renderLines() : this.renderBars();
}
/**
* Transitions the graph to the range provided by x1 and x2
*/
setVisibleRange(x1, x2): GraphService {
const pxWidth = Math.abs(this.scales.x(x1) - this.scales.x(x2));
const spaceAvailable = this.width;
const scaleAmount = Math.min((spaceAvailable / pxWidth), this.settings.zoom.max);
const scaledWidth = pxWidth * scaleAmount;
const emptySpace = ((spaceAvailable - scaledWidth) / 2);
this.transform = zoomIdentity.scale(scaleAmount)
.translate((-1 * this.scales.x(x1)) + (emptySpace / scaleAmount), 0);
this.svg.transition()
.ease(this.settings.transition.ease)
.duration(this.settings.transition.duration)
.call(this.zoomBehaviour.transform, this.transform);
return this;
}
/**
* Sets the type of graph, 'line' or 'bar'. If switching from one type to another,
* the render functions for the old type are called to clear out any rendered data.
* @param type type of graph to switch to
*/
setType(type: string) {
if (this.type !== type) {
const oldType = this.type;
this.type = type;
if (oldType === 'line') { this.renderLines(); }
if (oldType === 'bar') { this.renderBars(); }
}
}
/**
* Creates an axis for graph element
*/
renderAxis(settings, transform = this.transform, blockTransition = false): GraphService {
const axisType =
(settings.position === 'top' || settings.position === 'bottom') ? 'x' : 'y';
const axisGenerator = this.getAxisGenerator(settings);
// if line graph, scale axis based on transform
const scale = (axisType === 'x') ?
(this.type === 'line' ? transform.rescaleX(this.scales.x) : this.scales.x) :
this.scales.y;
// if called from a mouse event (blockTransition = true), call the axis generator
// if transition is programatically triggered, transition to the new axis position
if (blockTransition) {
this.container.selectAll('g.axis-' + axisType)
.call(axisGenerator.scale(scale));
} else {
this.container.selectAll('g.axis-' + axisType)
.transition().duration(this.settings.transition.duration)
.call(axisGenerator.scale(scale));
}
// update axis label
this.container.selectAll('g.axis-' + axisType + ' .label-' + axisType)
.text(this.settings.axis[axisType]['label'] || '');
return this;
}
/**
* Render bars for the data
*/
renderBars() {
const barData = (this.type === 'bar' ? this.data : []);
const bars = this.dataContainer.selectAll('.bar').data(barData, (d) => d.id);
const self = this;
// transition out bars no longer present
bars.exit()
.attr('class', (d, i) => 'bar bar-exit bar-' + i)
.transition()
.ease(this.settings.transition.ease)
.duration(this.settings.transition.duration)
.attr('height', 0)
.attr('y', this.height)
.remove();
const update = () => {
bars.transition().ease(this.settings.transition.ease)
.duration(this.settings.transition.duration)
.attr('class', (d, i) => 'bar bar-' + i)
.attr('height', (d) => Math.max(
0, this.height - this.scales.y(this.getBarDisplayVal(d.data[0][this.settings.props.y], this.scales.y))
))
.attr('y', (d) => this.scales.y(this.getBarDisplayVal(d.data[0][this.settings.props.y], this.scales.y)))
.attr('x', (d) => this.scales.x(d.data[0][this.settings.props.x]))
.attr('width', this.scales.x.bandwidth());
};
if (this.type === 'bar') {
// update bars with new data
update();
// add bars for new data
bars.enter().append('rect')
.attr('class', (d, i) => 'bar bar-enter bar-' + i)
.on('mouseover', function(d) { self.barHover.emit({...d, ...self.getBarRect(this), el: this }); })
.on('mouseout', function(d) { self.barHover.emit(null); })
.on('click', function(d) { self.barClick.emit({...d, ...self.getBarRect(this), el: this }); })
.attr('x', (d) => this.scales.x(d.data[0][this.settings.props.x]))
.attr('y', this.height)
.attr('width', this.scales.x.bandwidth())
.attr('height', 0)
.transition().ease(this.settings.transition.ease)
.duration(this.settings.transition.duration)
.attr('height', (d) => Math.max(0, this.height - this.scales.y(d.data[0][this.settings.props.y])))
.attr('y', (d) => this.scales.y(d.data[0][this.settings.props.y]));
}
return this;
}
/**
* Renders lines for any data in the data set.
*/
renderLines(transform = this.transform) {
const lineData = (this.type === 'line' ? this.data : []);
const extent = this.getExtents();
const lines = this.dataContainer.selectAll('g.line').data(lineData, (d) => d.id);
const linesEnter = lines.enter().append('g')
.attr('class', (d, i) => 'line line-' + i);
const flatLine = lineChunked()
.accessData(d => d.data)
.defined((d: any) => !isNaN(d[this.settings.props.y]))
.x((d: any, index: any, da: any) => 0)
.y(this.scales.y(extent.y[0]))
.pointAttrs({ r: 0 });
const valueLine = lineChunked()
.accessData(d => d.data)
.defined((d: any) => !isNaN(d[this.settings.props.y]))
.x((d: any, index: any, da: any) => this.scales.x(d.x))
.y((d: any) => this.scales.y(d[this.settings.props.y]))
.lineAttrs({
class: (d, i) => 'line line-' + i,
transform: 'translate(' + transform.x + ',0)scale(' + transform.k + ',1)',
'vector-effect': 'non-scaling-stroke'
})
.gapStyles({ 'stroke-opacity': 0 })
.pointAttrs({ r: 5 });
// Transition from flat line on enter
linesEnter.call(flatLine)
.transition()
.ease(this.settings.transition.ease)
.duration(this.settings.transition.duration)
.call(valueLine);
lines.transition()
.ease(this.settings.transition.ease)
.duration(this.settings.transition.duration)
.call(valueLine);
// transition out lines no longer present
lines.exit()
.attr('class', (d, i) => 'line line-exit line-' + i)
.transition()
.ease(this.settings.transition.ease)
.duration(this.settings.transition.duration)
.call(flatLine)
.remove();
return this;
}
/**
* Transitions back to the default zoom for the graph
*/
resetZoom(): GraphService {
this.svg.transition()
.ease(this.settings.transition.ease)
.duration(this.settings.transition.duration)
.call(this.zoomBehaviour.transform, zoomIdentity);
return this;
}
/**
* Overrides any provided graph settings
* @param settings graph settings
*/
updateSettings(settings = {}) {
this.settings = _merge(this.settings, settings);
this.log('updated settings', settings);
}
/**
* Sets the width and height of the graph and updates any containers
*/
setDimensions(margin = this.settings.margin) {
this.width = this.el.clientWidth - margin.left - margin.right;
this.height =
this.el.getBoundingClientRect().height - margin.top - margin.bottom;
// only set dimensions if width and height are valid
if (this.width > 0 && this.height > 0) {
this.svg
.attr('width', this.width + margin.left + margin.right)
.attr('height', this.height + margin.top + margin.bottom);
this.container.attr('width', this.width).attr('height', this.height)
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
this.dataContainer.attr('width', this.width).attr('height', this.height)
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
this.clip.attr('width', this.width).attr('height', this.height);
this.dataRect.attr('width', this.width).attr('height', this.height)
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
this.svg.selectAll('g.axis-x')
.attr('transform', this.getAxisTransform(this.settings.axis.x.position))
.selectAll('.label-x')
.attr('transform', 'translate(' + this.width / 2 + ',' + margin.bottom + ')')
.attr('text-anchor', 'middle')
.attr('dy', -10)
;
this.svg.selectAll('g.axis-y')
.attr('transform', this.getAxisTransform(this.settings.axis.y.position))
.selectAll('.label-y')
.attr('transform', 'rotate(-90) translate(' + -this.height / 2 + ',' + -margin.left + ')')
.attr('dy', margin.left / 2)
.attr('text-anchor', 'middle')
.text(this.settings.axis.y.label);
this.log('setting dimensions', this.width, this.height, margin);
} else {
this.width = 1;
this.height = 1;
}
return this;
}
/**
* Gets the value of the data at the provided x pixel coordinate
*/
getValueAtPosition(xPos) {
if (
xPos < this.settings.margin.left || xPos > (this.settings.margin.left + this.width)
) { return null; }
const graphX = Math.max(0, Math.min((xPos - this.settings.margin.left), this.width));
const x0 = this.scales.x.invert(graphX);
return this.getLineValues(x0, 0);
}
/**
* Gets the line values for a previous or next x value
* @param currentX
* @param offset
*/
getLineValues(currentX, offset = 1) {
// use the first X value if there is no current
if (!currentX && currentX !== 0) {
currentX = this.data[0].data[0][this.settings.props.x];
offset = 0;
}
const self = this;
const values = [];
selectAll('g.line').each(function (d: any) {
const i = self.bisectX(d.data, currentX, 1);
const NULL_VAL = { id: d.id, x: null, y: null, xPos: null, yPos: null };
if (d.data.length && d.data[i]) {
const x0 = d.data[i - 1][self.settings.props.x];
const x1 = d.data[i][self.settings.props.x];
const closestIndex = (Math.abs(currentX - x0) > Math.abs(currentX - x1)) ? i : i - 1;
const boundedIndex = Math.min(d.data.length - 1, Math.max(0, closestIndex + offset));
// Only push value if less than a full stop away from the closest
const val = self.getLineEventValue(d, boundedIndex, this);
values.push(Math.abs(currentX - val.x) < 1 ? val : NULL_VAL);
} else {
values.push(NULL_VAL);
}
});
return values;
}
/**
* Gets the bar values for a previous or next x value
* @param currentX
* @param offset
*/
getBarValue(currentX, offset = 1) {
const self = this;
let newIndex = -1;
// get the new index, or start at the beginning if there is no current value
if (!currentX) {
newIndex = 0;
} else {
selectAll('.bar').each(function(d: any, i: number) {
if (d.data[0][self.settings.props.x] === currentX) {
newIndex = (i + offset) % self.data.length;
}
});
}
// get the bar dimensions for the new index and return the data / position
if (newIndex > -1) {
const el = selectAll('.bar').filter((d0: any, i) => i === newIndex).node();
return [{ id: this.data[newIndex].id, ...this.data[newIndex].data[0], ...this.getBarRect(el), el: el }];
}
return null;
}
/**
* Display a minimum line for bar graph values that is 1% of the maximum value
* @param val
* @param scale
*/
private getBarDisplayVal(val, scale) {
const scaleDomain = scale.domain();
return val >= 0.1 ? val : scaleDomain[scaleDomain.length - 1] * 0.01;
}
/**
* Builds an object to return for DOM events
* @param dataItem the line data item
* @param pointIndex the index of the point to get data at
* @param el the DOM line element
*/
private getLineEventValue(dataItem, pointIndex, el) {
let yVal = dataItem.data[pointIndex][this.settings.props.y];
yVal = this.settings.axis.y.maxVal > 0 ? Math.min(this.settings.axis.y.maxVal, yVal) : yVal;
return {
id: dataItem.id,
...dataItem.data[pointIndex],
xPos: (this.settings.margin.left + this.scales.x(dataItem.data[pointIndex][this.settings.props.x])),
yPos: (this.settings.margin.top + this.scales.y(yVal)),
el: el
};
}
/** Gets the position and size of a passed `rect` element */
private getBarRect(el) {
return {
top: parseFloat(el.getAttribute('y')) + this.settings.margin.top,
left: parseFloat(el.getAttribute('x')) + this.settings.margin.left,
width: parseFloat(el.getAttribute('width')),
height: parseFloat(el.getAttribute('height'))
};
}
/** Creates the skeleton DOM structure of the SVG */
private createSvg() {
this.svg = this.d3el.append('svg');
this.title = this.svg.append('title');
this.desc = this.svg.append('desc');
// data rect
this.dataRect = this.svg.append('rect')
.attr('class', 'graph-rect');
// defs
this.defs = this.svg.append('defs');
// gradients
for (let i = 0; i < 4; i++) {
const g = this.defs.append('linearGradient').attr('id', 'g' + i)
.attr('x1', 0).attr('x2', 0).attr('y1', 0).attr('y2', 1);
g.append('stop').attr('offset', '0%').attr('class', 'g' + i + '-start');
g.append('stop').attr('offset', '100%').attr('class', 'g' + i + '-end');
}
// clip area
this.clip = this.defs
.append('clipPath').attr('id', 'data-container')
.append('rect').attr('x', 0).attr('y', 0);
// containers for axis
this.container = this.svg.append('g').attr('class', 'graph-container');
this.container.append('g').attr('class', 'axis axis-x')
.append('text').attr('class', 'label-x');
this.container.append('g').attr('class', 'axis axis-y')
.append('text').attr('class', 'label-y');
// masked container for lines and bars
this.dataContainer = this.svg.append('g')
.attr('clip-path', 'url(#data-container)')
.attr('class', 'data-container');
this.log('created svg', this.svg);
}
/**
* Creates the zoom behaviour for the graph then sets it up based on
* dimensions and settings
*/
private addZoomBehaviour(): GraphService {
this.zoomBehaviour = zoom()
.scaleExtent([this.settings.zoom.min, this.settings.zoom.max])
.translateExtent([[0, 0], [this.width, this.height]])
.extent([[0, 0], [this.width, this.height]])
.on('zoom', this.updateView.bind(this));
if (this.settings.zoom.enabled) { this.svg.call(this.zoomBehaviour); }
return this;
}
/**
* Get the transform based on the axis position
* @param position
*/
private getAxisTransform(position: string) {
switch (position) {
case 'top':
return 'translate(0,0)';
case 'bottom':
return 'translate(0,' + this.height + ')';
case 'left':
return 'translate(0,0)';
case 'right':
return 'translate(' + this.width + ',0)';
default:
return 'translate(0,0)';
}
}
/**
* returns the axis generator based the axis settings and graph type
* @param settings settings for the axis, including position and tick formatting
*/
private getAxisGenerator(settings: any) {
let axisGen;
let scale;
switch (settings.position) {
case 'top':
axisGen = axisTop;
scale = this.scales.x;
break;
case 'bottom':
axisGen = axisBottom;
scale = this.scales.x;
break;
case 'left':
axisGen = axisLeft;
scale = this.scales.y;
break;
case 'right':
axisGen = axisRight;
scale = this.scales.y;
break;
}
return this.addTicks(axisGen(scale), settings);
}
/** Add ticks to an axis */
private addTicks(axisGen, settings) {
let axis = axisGen;
if (settings.hasOwnProperty('ticks') && settings.ticks) {
axis = axis.ticks(settings.ticks);
}
if (settings.hasOwnProperty('tickSize') && settings.tickSize) {
axis = axis.tickSize(this.getTickSize(settings.tickSize, settings.position));
}
if (settings.hasOwnProperty('tickFormat') && settings.tickFormat) {
axis = axis.tickFormat(format(settings.tickFormat));
}
if (settings.hasOwnProperty('tickPadding') && settings.tickPadding) {
axis = axis.tickPadding(settings.tickPadding);
}
this.log('created axis', axis, settings);
return axis;
}
/** Parse the tick size to see if it is a percentage */
private getTickSize(value, axisPosition: string) {
if (typeof value === 'string' && value.slice(-1) === '%') {
const axisType = axisPosition === 'left' || axisPosition === 'right' ? 'y' : 'x';
return (parseFloat(value) / 100) * (axisType === 'x' ? this.height : this.width );
}
return value;
}
/**
* Returns a range for the axis
*/
private getRange() {
return {
x: (this.settings.axis.x.invert ? [this.width, 0] : [0, this.width]),
y: (this.settings.axis.y.invert ? [0, this.height] : [this.height, 0])
};
}
/** Gets the extents for the x and y axis */
private getExtents(): { x: Array<number>, y: Array<number> } {
return {
x: this.getExtent('x'),
y: this.getExtent('y')
};
}
/**
* Gets either the x or y extent
* @param prop either 'x' or 'y'
*/
private getExtent(prop: string): Array<number> {
const axis = this.settings.axis[prop];
// return if extent is explicitly set
if (axis.hasOwnProperty('extent') && axis['extent'].length === 2) {
return axis['extent'];
}
// return if there is no data
if (!this.data.length) { return [0, 1]; }
// loop through data and create an extent representative of the entire data set
let xtnt: Array<number>;
for (const dp of this.data) {
const setExtent = extent(dp.data, (d) => parseFloat(d[this.settings.props[prop]]));
xtnt = xtnt ? extent([...xtnt, ...setExtent]) : setExtent;
}
// pad y extent by 10%
xtnt = prop === 'y' ? this.padExtent(xtnt, 0.1, {top: true, bottom: true}) : xtnt;
// Set min Y to minVal if needed
if (!isNaN(parseFloat(axis.minVal))) { xtnt[0] = Math.max(axis.minVal, xtnt[0]); }
// Cap Y extent to maxVal if present
if (!isNaN(parseFloat(axis.maxVal))) { xtnt[1] = Math.min(xtnt[1], axis.maxVal); }
return xtnt;
}
/**
* Returns the scales based on the graph type
*/
private getScales() {
if (this.type === 'line') {
const ranges = this.getRange();
const extents = this.getExtents();
return {
x: scaleLinear().range(ranges.x).domain(extents.x),
y: scaleLinear().range(ranges.y).domain(extents.y)
};
} else if (this.type === 'bar') {
const scales = {
x: scaleBand().rangeRound([0, this.width]).padding(0.25),
y: scaleLinear().rangeRound([this.height, 0])
};
// Set max Y value in scale to at least minVal, at most maxVal
let maxY = max(this.data, (d: any) => parseFloat(d.data[0][this.settings.props.y]));
if (!isNaN(this.settings.axis.y.minVal)) {
maxY = Math.max(this.settings.axis.y.minVal, maxY);
}
if (!isNaN(this.settings.axis.y.maxVal)) {
maxY = Math.min(this.settings.axis.y.maxVal, maxY);
}
// Cap Y domain to maxVal without padding if present
let yDomain;
if (this.settings.axis.y.hasOwnProperty('extent') && this.settings.axis.y.extent.length === 2) {
yDomain = this.settings.axis.y.extent;
} else {
yDomain = this.settings.axis.y.maxVal === maxY ? [0, maxY] : this.padExtent([0, maxY], 0.1, { top: true });
}
scales.x.domain(this.data.map((d) => d.data[0][this.settings.props.x]));
scales.y.domain(yDomain);
return scales;
}
}
/** Pads the min / max of an extent array by the provided amount */
private padExtent(extent: Array<number>, amount = 0.1, options: any = {}): Array<number> {
const padding = (extent[1] - extent[0]) * amount;
const min = options.bottom ? extent[0] - padding : extent[0];
const max = options.top ? extent[1] + padding : extent[1];
return [min, max];
}
/**
* Attempts to determine the type of graph based on the provided data.
* If each item in the data set only has one data point, it assumes it is a bar graph
* Anything else is a line graph.
* @param data The dataset for the graph
*/
private detectTypeFromData(data): string {
for (let i = 0; i < data.length; i++) {
if (data[i].data.length !== 1) {
return 'line';
}
}
return 'bar';
}
/**
* Set the title and description for the graph based on settings and also add
* the aria-labelledby attr as recommended by:
* https://css-tricks.com/accessible-svgs/
*/
private updateTitleDesc() {
this.title
.attr('id', this.settings.id + '_title')
.text(this.settings['title'] ? this.settings['title'] : '');
this.desc
.attr('id', this.settings.id + '_desc')
.text(this.settings['description'] ? this.settings['description'] : '');
this.svg
.attr('aria-labelledby', `${this.settings.id}_title ${this.settings.id}_desc`);
}
/** Wrapper for console logging if debug is enabled */
private log(...logItems) {
if (this.settings.debug) {
console.debug.apply(this, logItems);
}
}
}