UNPKG

angular-d3-graph

Version:

Component for rendering a line graph or bar graph.

531 lines (493 loc) 18.5 kB
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 { line } from 'd3-shape'; import * as _merge from 'lodash.merge'; @Injectable() export class GraphService { el; svg; data: any = []; settings = { props: { x: 'x', y: 'y' }, margin: { left: 48, right: 10, top: 10, bottom: 48 }, axis: { x: { position: 'bottom', label: 'x', ticks: 5, tickSize: 5, tickFormat: ',.0f', invert: false }, y: { position: 'left', label: 'y', ticks: 5, tickSize: 5, tickFormat: ',.0f', invert: false } }, transition: { ease: easePoly, duration: 1000 }, zoom: { enabled: false, min: 1, max: 10 } }; barHover = new EventEmitter(); barClick = new EventEmitter(); private type; private zoomBehaviour; private width; private height; private d3el; private container; private dataContainer; private clip; private scales; private transform = zoomIdentity; private created = false; private bisectX = bisector((d) => d[this.settings.props.x]).left; /** * 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.transform = zoomTransform(this.svg.node()) || zoomIdentity; this.scales = this.getScales(); 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)); } 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(); if (this.type === 'bar') { // update bars with new data bars.attr('class', (d, i) => 'bar bar-' + i) .attr('height', (d) => 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])) .attr('x', (d) => this.scales.x(d.data[0][this.settings.props.x])) .attr('width', this.scales.x.bandwidth()); // add bars for new data bars.enter().append('rect') .attr('class', (d, i) => 'bar bar-enter bar-' + i) .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) .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 }); }) .transition().ease(this.settings.transition.ease) .duration(this.settings.transition.duration) .attr('height', (d) => 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.getExtent(); const lines = this.dataContainer.selectAll('.line').data(lineData, (d) => d.id); const flatLine = line() .defined((d: any) => !isNaN(d[this.settings.props.y])) .x((d: any, index: any, da: any) => this.scales.x(d.x)) .y(this.scales.y(extent.y[0])); const valueLine = line().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])); const update = () => { lines .attr('class', (d, i) => 'line line-' + i) .attr('transform', 'translate(' + transform.x + ',0)scale(' + transform.k + ',1)') .attr('vector-effect', 'non-scaling-stroke') .transition().ease(this.settings.transition.ease) .duration(this.settings.transition.duration) .attr('d', (d) => valueLine(d.data)); }; // 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) .attr('d', (d) => flatLine(d.data)) .remove(); if (this.type === 'line') { // update lines with new data update(); // add lines for new data lines.enter().append('path') .attr('class', (d, i) => 'line line-enter line-' + i) .attr('transform', 'translate(' + transform.x + ',0)scale(' + transform.k + ',1)') .attr('vector-effect', 'non-scaling-stroke') .attr('d', (d) => flatLine(d.data)) .transition() .ease(this.settings.transition.ease) .duration(this.settings.transition.duration) .attr('d', (d) => valueLine(d.data)); } 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); } /** * 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; 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.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('dy', -10) .text(this.settings.axis.x.label); 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', 10) .text(this.settings.axis.y.label); 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('.line').each(function (d: any) { const i = self.bisectX(d.data, currentX, 1); 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)); values.push(self.getLineEventValue(d, boundedIndex, this)); }); 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; } private getLineEventValue(dataItem, pointIndex, el) { 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(dataItem.data[pointIndex][this.settings.props.x])), el: el }; } private getBarEventValue(data) { } 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')) }; } private createSvg() { this.svg = this.d3el.append('svg'); // clip area this.clip = this.svg.append('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'); } /** * 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; } if (this.type === 'line') { return axisGen(scale).ticks(settings.ticks) .tickSize(settings.tickSize) .tickFormat(format(settings.tickFormat)); } else if (this.type === 'bar') { if (settings.position === 'top' || settings.position === 'bottom') { return axisGen(scale); } else if (settings.position === 'left' || settings.position === 'right') { return axisGen(scale).ticks(settings.ticks); } } } /** * 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 x and y extent of the data * @param data */ private getExtent(): { x: Array<number>, y: Array<number> } { if (!this.data.length) { return { x: [0, 1], y: [0, 1] }; } const extents: any = {}; for (const dp of this.data) { const setExtent = { x: extent(dp.data, (d) => parseFloat(d[this.settings.props.x])), y: extent(dp.data, (d) => parseFloat(d[this.settings.props.y])) }; extents.x = extents.x ? extent([...extents.x, ...setExtent.x]) : setExtent.x; extents.y = extents.y ? extent([...extents.y, ...setExtent.y]) : setExtent.y; } return extents; } /** * Returns the scales based on the graph type */ private getScales() { if (this.type === 'line') { const ranges = this.getRange(); const extents = this.getExtent(); 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]) }; scales.x.domain(this.data.map((d) => d.data[0][this.settings.props.x])); scales.y.domain([0, max(this.data, (d: any) => parseFloat(d.data[0][this.settings.props.y]))]); return scales; } } /** * 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'; } }