blob: 3c13ae4bbd360e1fd58a88dcf083ef700634444e [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AfterViewInit, OnChanges, SimpleChanges, ViewChild, ElementRef, Input, Output, EventEmitter, OnInit, OnDestroy
} from '@angular/core';
import * as d3 from 'd3';
import * as d3sc from 'd3-scale-chromatic';
import {Observable} from 'rxjs/Observable';
import 'rxjs/add/observable/fromEvent';
import 'rxjs/add/operator/debounceTime';
import {
GraphPositionOptions, GraphMarginOptions, GraphTooltipInfo, LegendItem, GraphEventData, GraphEmittedEvent
} from '@app/classes/graph';
import {HomogeneousObject} from '@app/classes/object';
import {ServiceInjector} from '@app/classes/service-injector';
import {UtilsService} from '@app/services/utils.service';
import {Subscription} from 'rxjs/Subscription';
export const graphColors = [
'#41bfae',
'#79e3d1',
'#63c2e5',
'#c4aeff',
'#b991d9',
'#ffb9bf',
'#ffae65',
'#f6d151',
'#a7cf82',
'#abdfd5'
];
export class GraphComponent implements AfterViewInit, OnChanges, OnInit, OnDestroy {
@Input()
data: HomogeneousObject<HomogeneousObject<number>> = {};
@Input()
svgId = 'graph-svg';
@Input()
margin: GraphMarginOptions = {
top: 5,
right: 50,
bottom: 30,
left: 50
};
@Input()
width: number;
@Input()
height = 150;
@Input()
tickPadding = 10;
@Input()
colors: HomogeneousObject<string> = {};
@Input()
labels: HomogeneousObject<string> = {};
@Input()
chartLabel: string;
/**
* Indicates whether the graph represents dependency on time
* @type {boolean}
*/
@Input()
isTimeGraph = false;
/**
* Indicates whether X axis direction is right to left
* @type {boolean}
*/
@Input()
reverseXRange = false;
/**
* Indicates whether Y axis direction is top to bottom
* @type {boolean}
*/
@Input()
reverseYRange = false;
/**
* Indicates whether X axis ticks with fractional values should be displayed on chart (if any)
* @type {boolean}
*/
@Input()
allowFractionalXTicks = true;
/**
* Indicates whether Y axis ticks with fractional values should be displayed on chart (if any)
* @type {boolean}
*/
@Input()
allowFractionalYTicks = true;
/**
* Indicated whether Y values equal to 0 should be skipped in tooltip
* @type {boolean}
*/
@Input()
skipZeroValuesInTooltip = true;
/**
* Indicates whether X axis event should be emitted with formatted string values that are displayed
* (instead of raw values)
* @type {boolean}
*/
@Input()
emitFormattedXTick = false;
/**
* Indicates whether Y axis event should be emitted with formatted string values that are displayed
* (instead of raw values)
* @type {boolean}
*/
@Input()
emitFormattedYTick = false;
@Output()
xTickContextMenu: EventEmitter<GraphEmittedEvent<MouseEvent>> = new EventEmitter();
@Output()
yTickContextMenu: EventEmitter<GraphEmittedEvent<MouseEvent>> = new EventEmitter();
@ViewChild('graphContainer')
graphContainerRef: ElementRef;
@ViewChild('tooltip', {
read: ElementRef
})
tooltipRef: ElementRef;
private readonly xAxisClassName = 'axis-x';
private readonly yAxisClassName = 'axis-y';
protected utils: UtilsService;
protected graphContainer: HTMLElement;
private tooltip: HTMLElement;
protected host;
protected svg;
protected xScale;
protected yScale;
protected xAxis;
protected yAxis;
/**
* Ordered array of color strings for data representation
* @type {string[]}
*/
protected orderedColors: string[] = graphColors;
/**
* This property is to hold the data of the bar where the mouse is over.
*/
protected tooltipInfo: GraphTooltipInfo | {} = {};
/**
* This is the computed position of the tooltip relative to the @graphContainer which is the container of the graph.
* It is set when the mousemoving over the figures in the @handleRectMouseMove method.
*/
private tooltipPosition: GraphPositionOptions;
/**
* This property indicates if the tooltip should be positioned on the left side of the cursor or not.
* It should be true when the tooltip is out from the window.
* @type {boolean}
*/
private tooltipOnTheLeft = false;
protected subscriptions: Subscription[] = [];
/**
* This will return the information about the used levels and the connected colors and labels.
* The goal is to provide an easy property to the template to display the legend of the levels.
* @returns {LegendItem[]}
*/
legendItems: LegendItem[];
constructor() {
this.utils = ServiceInjector.injector.get(UtilsService);
}
ngOnInit() {
this.subscriptions.push(
Observable.fromEvent(window, 'resize').debounceTime(100).subscribe(this.onWindowResize)
);
this.setLegendItems();
}
ngOnDestroy() {
this.subscriptions.forEach((subscription: Subscription) => subscription.unsubscribe());
}
ngAfterViewInit() {
this.graphContainer = this.graphContainerRef.nativeElement;
this.tooltip = this.tooltipRef.nativeElement;
this.host = d3.select(this.graphContainer);
this.createGraph();
}
ngOnChanges(changes: SimpleChanges) {
const dataChange = changes.data;
if (dataChange && dataChange.currentValue && !this.utils.isEmptyObject(dataChange.currentValue)
&& (!dataChange.previousValue || this.utils.isEmptyObject(dataChange.previousValue))
&& this.utils.isEmptyObject(this.labels)) {
this.setDefaultLabels();
}
if (changes.labels || changes.colors) {
this.setLegendItems();
}
this.createGraph();
}
onWindowResize = () => {
this.createGraph();
}
protected createGraph(): void {
if (this.host && !this.utils.isEmptyObject(this.labels)) {
this.setup();
this.buildSVG();
this.populate();
}
}
/**
* Method that sets default labels map object based on data if no custom one is specified
*/
protected setDefaultLabels() {
const data = this.data;
const keys = Object.keys(data);
const labels = keys.reduce((keysReduced: HomogeneousObject<string>, dataKey: string): HomogeneousObject<string> => {
const newKeys = Object.keys(data[dataKey]);
const newKeysObj = newKeys.reduce((subKeys: HomogeneousObject<string>, key: string): HomogeneousObject<string> => {
return Object.assign(subKeys, {
[key]: key
});
}, {});
return Object.assign(keysReduced, newKeysObj);
}, {});
this.labels = labels;
this.setLegendItems();
}
protected setLegendItems(): void {
this.setColors();
if (this.colors && this.labels) {
this.legendItems = Object.keys(this.labels).map((key: string) => Object.assign({}, {
label: this.labels[key],
color: this.colors[key]
}));
}
}
protected getOrderedColorsByColors(colors: {[key: string]: string}): string[] {
const keys = Object.keys(colors);
return keys.reduce((orderedColors: string[], key: string): string[] => [...orderedColors, colors[key]], []);
}
protected setColors(): void {
if (this.utils.isEmptyObject(this.colors) && this.orderedColors && this.orderedColors.length) {
const keys = Object.keys(this.labels);
this.colors = keys.reduce((currentObject: HomogeneousObject<string>, currentKey: string, index: number) => {
return Object.assign(currentObject, {
[currentKey]: this.orderedColors[index]
});
}, {});
} else if (!this.utils.isEmptyObject(this.colors)) {
this.orderedColors = this.getOrderedColorsByColors(this.colors);
}
}
protected setup(): void {
const margin = this.margin;
this.setColors();
this.width = this.graphContainer.clientWidth - margin.left - margin.right;
const xScale = this.isTimeGraph ? d3.scaleTime() : d3.scaleLinear();
const yScale = d3.scaleLinear();
const xScaleWithRange = this.reverseXRange ? xScale.range([this.width, 0]) : xScale.range([0, this.width]);
const yScaleWithRange = this.reverseYRange ? yScale.range([0, this.height]) : yScale.range([this.height, 0]);
this.xScale = xScaleWithRange;
this.yScale = yScaleWithRange;
}
protected buildSVG(): void {
const margin = this.margin;
this.host.html('');
this.svg = this.host.append('svg').attr('id', this.svgId).attr('width', this.graphContainer.clientWidth)
.attr('height', this.height + margin.top + margin.bottom).append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
}
protected populate(): void {}
/**
* Set the domain values for the x scale regarding the given data.
* @param formattedData
*/
protected setXScaleDomain(formattedData?: any): void {}
/**
* Set the domain for the y scale regarding the given data.
* @param formattedData
*/
protected setYScaleDomain(formattedData?: any): void {}
/**
* It draws the svg representation of the x axis. The goal is to set the ticks here, add the axis to the svg element
* and set the position of the axis.
* @param {number} ticksCount - optional parameter which sets number of ticks explicitly
* @param {number} leftOffset
*/
protected drawXAxis(ticksCount?: number, leftOffset?: number): void {
const axis = d3.axisBottom(this.xScale).tickFormat(this.xAxisTickFormatter).tickPadding(this.tickPadding);
if (ticksCount) {
axis.ticks(ticksCount);
}
this.xAxis = axis;
this.svg.append('g').attr('class', `axis ${this.xAxisClassName}`)
.attr('transform', `translate(${leftOffset || 0}, ${this.height})`)
.call(this.xAxis);
if (this.xTickContextMenu.observers.length) {
this.svg.selectAll(`.${this.xAxisClassName} .tick`).on('contextmenu', (tickValue: any, index: number): void => {
const tick = this.emitFormattedXTick ? this.xAxisTickFormatter(tickValue, index) : tickValue,
nativeEvent = d3.event;
this.xTickContextMenu.emit({tick, nativeEvent});
event.preventDefault();
});
}
}
/**
* It draws the svg representation of the y axis. The goal is to set the ticks here, add the axis to the svg element
* and set the position of the axis.
* @param {number} ticksCount - optional parameter which sets number of ticks explicitly
*/
protected drawYAxis(ticksCount?: number): void {
const axis = d3.axisLeft(this.yScale).tickFormat(this.yAxisTickFormatter).tickPadding(this.tickPadding);
if (ticksCount) {
axis.ticks(ticksCount);
}
this.yAxis = axis;
this.svg.append('g').attr('class', `axis ${this.yAxisClassName}`).call(this.yAxis);
if (this.yTickContextMenu.observers.length) {
this.svg.selectAll(`.${this.yAxisClassName} .tick`).on('contextmenu', (tickValue: any, index: number): void => {
const tick = this.emitFormattedYTick ? this.yAxisTickFormatter(tickValue, index) : tickValue,
nativeEvent = d3.event;
this.yTickContextMenu.emit({tick, nativeEvent});
event.preventDefault();
});
}
};
/**
* Function that formats the labels for X axis ticks.
* Returns simple toString() conversion as default, can be overridden in ancestors.
* undefined value is returned for ticks to be skipped.
* @param tick
* @param {number} index
* @returns {string|undefined}
*/
protected xAxisTickFormatter = (tick: any, index: number): string | undefined => {
if (this.allowFractionalXTicks) {
return tick.toString();
} else {
return Number.isInteger(tick) ? tick.toFixed(0) : undefined;
}
}
/**
* Function that formats the labels for Y axis ticks.
* Returns simple toString() conversion as default, can be overridden in ancestors.
* undefined value is returned for ticks to be skipped.
* @param tick
* @param {number} index
* @returns {string|undefined}
*/
protected yAxisTickFormatter = (tick: any, index: number): string | undefined => {
if (this.allowFractionalYTicks) {
return tick.toString();
} else {
return Number.isInteger(tick) ? tick.toFixed(0) : undefined;
}
}
/**
* The goal is to handle the mouse over event on the svg elements so that we can populate the tooltip info object
* and set the initial position of the tooltip. So we call the corresponding methods.
* @param {GraphEventData} d The data for the currently "selected" figure
* @param {number} index The index of the current element in the selection
* @param elements The selection of the elements
*/
protected handleMouseOver = (d: GraphEventData, index: number, elements: HTMLElement[]): void => {
this.setTooltipDataFromChartData(d);
this.setTooltipPosition();
}
/**
* The goal is to handle the movement of the mouse over the svg elements, so that we can set the position of
* the tooltip by calling the @setTooltipPosition method.
*/
protected handleMouseMove = (): void => {
this.setTooltipPosition();
}
/**
* The goal is to reset the tooltipInfo object so that the tooltip will be hidden.
*/
protected handleMouseOut = (): void => {
this.tooltipInfo = {};
}
/**
* The goal is set the tooltip
* @param {GraphEventData} d
*/
protected setTooltipDataFromChartData(d: GraphEventData): void {
const {tick, ...data} = d.data,
levelColors = this.colors;
let tooltipKeys = Object.keys(levelColors);
if (this.skipZeroValuesInTooltip) {
tooltipKeys = tooltipKeys.filter((key: string): boolean => data[key] > 0)
}
this.tooltipInfo = {
data: tooltipKeys.map((key: string): object => Object.assign({}, {
color: this.colors[key],
label: this.labels[key],
value: data[key]
})),
title: tick
};
}
/**
* The goal of this function is to set the tooltip position regarding the d3.mouse event relative to the @graphContainer.
* Only if we have @tooltipInfo
*/
protected setTooltipPosition(): void {
if (this.tooltipInfo.hasOwnProperty('data')) {
const tooltip = this.tooltip,
relativeMousePosition = d3.mouse(this.graphContainer),
absoluteMousePosition = d3.mouse(document.body),
absoluteMouseLeft = absoluteMousePosition[0],
top = relativeMousePosition[1] - (tooltip.offsetHeight / 2),
tooltipWidth = tooltip.offsetWidth,
windowSize = window.innerWidth;
let left = relativeMousePosition[0];
if (absoluteMouseLeft + tooltipWidth > windowSize) {
left = relativeMousePosition[0] - (tooltipWidth + 25);
}
this.tooltipOnTheLeft = left < relativeMousePosition[0];
this.tooltipPosition = {left, top};
} else {
this.tooltipPosition = undefined;
}
};
}