|
|
|
import { ChartwerkPod, VueChartwerkPodMixin, TickOrientation, TimeFormat, AxisFormat } from '@chartwerk/core';
|
|
|
|
|
|
|
|
import { BarTimeSerie, BarOptions, RowValues } from './types';
|
|
|
|
import { findClosest } from './utils';
|
|
|
|
|
|
|
|
import * as d3 from 'd3';
|
|
|
|
import * as _ from 'lodash';
|
|
|
|
|
|
|
|
|
|
|
|
const DEFAULT_BAR_OPTIONS: BarOptions = {
|
|
|
|
renderBarLabels: false,
|
|
|
|
stacked: false,
|
|
|
|
matching: false
|
|
|
|
}
|
|
|
|
|
|
|
|
export class ChartwerkBarPod extends ChartwerkPod<BarTimeSerie, BarOptions> {
|
|
|
|
barYScale: null | d3.ScaleLinear<number, number> = null;
|
|
|
|
_seriesDataForRendring = [];
|
|
|
|
|
|
|
|
constructor(el: HTMLElement, _series: BarTimeSerie[] = [], _options: BarOptions = {}) {
|
|
|
|
super(d3, el, _series, _options);
|
|
|
|
_.defaults(this.options, DEFAULT_BAR_OPTIONS);
|
|
|
|
}
|
|
|
|
|
|
|
|
protected renderMetrics(): void {
|
|
|
|
if(this.series.length === 0 || this.series[0].datapoints.length === 0) {
|
|
|
|
this.renderNoDataPointsMessage();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.setBarPodScales();
|
|
|
|
this.setSeriesDataForRendering();
|
|
|
|
this.renderSerie(this._seriesDataForRendring);
|
|
|
|
}
|
|
|
|
|
|
|
|
get isMatchingDisabled(): boolean {
|
|
|
|
return this.options.matching === false || this.seriesUniqKeys.length === 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
setSeriesDataForRendering(): void {
|
|
|
|
if(this.isMatchingDisabled) {
|
|
|
|
this._seriesDataForRendring = this.getZippedDataForRender(this.visibleSeries);
|
|
|
|
} else {
|
|
|
|
const matchedSeries = this.seriesForMatching.map(
|
|
|
|
(series: BarTimeSerie[], idx: number) => this.getZippedDataForRender(series)
|
|
|
|
);
|
|
|
|
this._seriesDataForRendring = this.mergeMacthedSeriesAndSort(matchedSeries);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
setBarPodScales(): void {
|
|
|
|
// TODO: its a hack to avoid infinite scale function recalling
|
|
|
|
// It should be fixed by implenting BarState
|
|
|
|
this.barYScale = this.getYScale();
|
|
|
|
}
|
|
|
|
|
|
|
|
renderSerie(data: any): void {
|
|
|
|
this.metricContainer.selectAll(`.rects-container`)
|
|
|
|
.data(data)
|
|
|
|
.enter().append('g')
|
|
|
|
.attr('class', 'rects-container')
|
|
|
|
.each((d: RowValues, i: number, nodes: any) => {
|
|
|
|
const container = d3.select(nodes[i]);
|
|
|
|
container.selectAll('rect')
|
|
|
|
.data(d.values)
|
|
|
|
.enter().append('rect')
|
|
|
|
.style('fill', (val, i) => d.colors[i])
|
|
|
|
.attr('opacity', () => this.getBarOpacity(d))
|
|
|
|
.attr('x', (val: number, idx: number) => {
|
|
|
|
return this.getBarPositionX(d.key, idx);
|
|
|
|
})
|
|
|
|
.attr('y', (val: number, idx: number) => {
|
|
|
|
return this.getBarPositionY(val, idx, d.values);
|
|
|
|
})
|
|
|
|
.attr('width', this.barWidth)
|
|
|
|
.attr('height', (val: number) => this.getBarHeight(val))
|
|
|
|
.on('contextmenu', this.contextMenu.bind(this));
|
|
|
|
|
|
|
|
// render bar annotations, its all hardcoded
|
|
|
|
if(_.isEmpty(this.options.annotations)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
// find all series for single matchedKey
|
|
|
|
const series = _.filter(this.series, serie => _.includes(d.serieTarget, serie.target));
|
|
|
|
const matchedKeys = _.map(series, serie => serie.matchedKey); // here matchedKeys should be equal
|
|
|
|
const key = matchedKeys[0];
|
|
|
|
|
|
|
|
const lastRect = _.last(container.selectAll('rect')?.nodes());
|
|
|
|
const annotation = _.find(this.options.annotations, a => a.key === key);
|
|
|
|
if(!lastRect || !key || !annotation) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const rectSelection = d3.select(lastRect);
|
|
|
|
// render triangle
|
|
|
|
container.append('path')
|
|
|
|
.attr('d', () => {
|
|
|
|
const x = Math.ceil(_.toNumber(rectSelection.attr('x')));
|
|
|
|
const y = Math.ceil(_.toNumber(rectSelection.attr('y')));
|
|
|
|
const options = { max: this.options.maxAnnotationSize, min: this.options.minAnnotationSize };
|
|
|
|
return this.getTrianglePath(x, y, this.barWidth, options);
|
|
|
|
})
|
|
|
|
.attr('fill', annotation.color);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
getTrianglePath(x: number, y: number, length: number, options?: { max: number, min: number }): string {
|
|
|
|
// (x, y) - top left corner of bar
|
|
|
|
const minTriangleSize = options?.min || 6;
|
|
|
|
const maxTriagleSize = options?.max || 10;
|
|
|
|
const yOffset = 4; // offset between triangle and bar
|
|
|
|
const centerX = x + length / 2;
|
|
|
|
const correctedLength = _.clamp(length, minTriangleSize, maxTriagleSize);
|
|
|
|
|
|
|
|
const topY = Math.max(y - correctedLength - yOffset, 4);
|
|
|
|
const topLeftCorner = {
|
|
|
|
x: centerX - correctedLength / 2,
|
|
|
|
y: topY,
|
|
|
|
};
|
|
|
|
const topRightCorner = {
|
|
|
|
x: centerX + correctedLength / 2,
|
|
|
|
y: topY,
|
|
|
|
};
|
|
|
|
const bottomMiddleCorner = {
|
|
|
|
x: centerX,
|
|
|
|
y: topY + correctedLength,
|
|
|
|
};
|
|
|
|
|
|
|
|
return `M ${topLeftCorner.x} ${topLeftCorner.y}
|
|
|
|
L ${topRightCorner.x} ${topRightCorner.y}
|
|
|
|
L ${bottomMiddleCorner.x} ${bottomMiddleCorner.y} z`;
|
|
|
|
}
|
|
|
|
|
|
|
|
getBarOpacity(rowValues: RowValues): number {
|
|
|
|
if(this.options.opacityFormatter === undefined) {
|
|
|
|
return 1;
|
|
|
|
}
|
|
|
|
return this.options.opacityFormatter(rowValues);
|
|
|
|
}
|
|
|
|
|
|
|
|
mergeMacthedSeriesAndSort(matchedSeries: any[]) {
|
|
|
|
// TODO: refactor
|
|
|
|
if(matchedSeries.length === 0) {
|
|
|
|
throw new Error('Cant mergeMacthedSeriesAndSort');
|
|
|
|
}
|
|
|
|
if(matchedSeries.length === 1) {
|
|
|
|
return matchedSeries[0];
|
|
|
|
}
|
|
|
|
let unionSeries = _.clone(matchedSeries[0]);
|
|
|
|
for(let i = 1; i < matchedSeries.length; i++){
|
|
|
|
unionSeries = [...unionSeries, ...matchedSeries[i]];
|
|
|
|
}
|
|
|
|
const sortedSeries = _.sortBy(unionSeries, ['key']);
|
|
|
|
return sortedSeries;
|
|
|
|
}
|
|
|
|
|
|
|
|
get seriesUniqKeys(): string[] {
|
|
|
|
if(this.visibleSeries.length === 0) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
const keys = this.visibleSeries.map(serie => serie.matchedKey);
|
|
|
|
const uniqKeys = _.uniq(keys);
|
|
|
|
const filteredKeys = _.filter(uniqKeys, key => key !== undefined);
|
|
|
|
return filteredKeys;
|
|
|
|
}
|
|
|
|
|
|
|
|
get seriesForMatching(): BarTimeSerie[][] {
|
|
|
|
if(this.seriesUniqKeys.length === 0) {
|
|
|
|
return [this.visibleSeries];
|
|
|
|
}
|
|
|
|
const seriesList = this.seriesUniqKeys.map(key => {
|
|
|
|
const seriesWithKey = _.filter(this.visibleSeries, serie => serie.matchedKey === key);
|
|
|
|
return seriesWithKey;
|
|
|
|
});
|
|
|
|
return seriesList;
|
|
|
|
}
|
|
|
|
|
|
|
|
getZippedDataForRender(series: BarTimeSerie[]): RowValues[] {
|
|
|
|
if(series.length === 0) {
|
|
|
|
throw new Error('There is no visible series');
|
|
|
|
}
|
|
|
|
const keysColumn = _.map(series[0].datapoints, row => row[0]);
|
|
|
|
const valuesColumns = _.map(series, serie => _.map(serie.datapoints, row => row[1]));
|
|
|
|
// @ts-ignore
|
|
|
|
const additionalValuesColumns = _.map(series, serie => _.map(serie.datapoints, row => row[2] !== undefined ? row[2] : null));
|
|
|
|
const zippedAdditionalValuesColumn = _.zip(...additionalValuesColumns);
|
|
|
|
const zippedValuesColumn = _.zip(...valuesColumns);
|
|
|
|
const colors = _.map(series, serie => this.getBarColor(serie));
|
|
|
|
const tagrets = _.map(series, serie => serie.target);
|
|
|
|
const zippedData = _.zip(keysColumn, zippedValuesColumn, zippedAdditionalValuesColumn, tagrets);
|
|
|
|
const data = _.map(zippedData, row => { return { key: row[0], values: row[1], additionalValues: row[2], colors, serieTarget: tagrets } });
|
|
|
|
return data.filter(v => v.key !== undefined);
|
|
|
|
}
|
|
|
|
|
|
|
|
public renderSharedCrosshair(values: { x?: number, y?: number }): void {
|
|
|
|
this.crosshair.style('display', null);
|
|
|
|
|
|
|
|
const x = this.xScale(values.x);
|
|
|
|
this.crosshair.select('#crosshair-line-x')
|
|
|
|
.attr('x1', x)
|
|
|
|
.attr('x2', x);
|
|
|
|
}
|
|
|
|
|
|
|
|
public hideSharedCrosshair(): void {
|
|
|
|
this.crosshair.style('display', 'none');
|
|
|
|
}
|
|
|
|
|
|
|
|
onMouseMove(): void {
|
|
|
|
// TODO: mouse move work bad with matching
|
|
|
|
const event = this.d3.mouse(this.chartContainer.node());
|
|
|
|
const eventX = event[0];
|
|
|
|
if(this.isOutOfChart() === true) {
|
|
|
|
this.crosshair.style('display', 'none');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.crosshair.select('#crosshair-line-x')
|
|
|
|
.attr('x1', eventX)
|
|
|
|
.attr('x2', eventX);
|
|
|
|
|
|
|
|
const series = this.getSeriesPointFromMousePosition(eventX);
|
|
|
|
|
|
|
|
if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.mouseMove !== undefined) {
|
|
|
|
this.options.eventsCallbacks.mouseMove({
|
|
|
|
x: this.d3.event.pageX,
|
|
|
|
y: this.d3.event.pageY,
|
|
|
|
time: this.xScale.invert(eventX),
|
|
|
|
series,
|
|
|
|
chartX: eventX,
|
|
|
|
chartWidth: this.width
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
console.log('mouse move, but there is no callback');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
getSeriesPointFromMousePosition(eventX: number): any[] | undefined {
|
|
|
|
if(this.series === undefined || this.series.length === 0) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
const mousePoisitionKey = Math.ceil(this.xScale.invert(eventX));
|
|
|
|
const keys = _.map(this._seriesDataForRendring, el => el.key);
|
|
|
|
const idx = findClosest(keys, mousePoisitionKey);
|
|
|
|
|
|
|
|
return this._seriesDataForRendring[idx];
|
|
|
|
}
|
|
|
|
|
|
|
|
getBarColor(serie: any) {
|
|
|
|
if(serie.color === undefined) {
|
|
|
|
return this.getSerieColor(0);
|
|
|
|
}
|
|
|
|
return serie.color;
|
|
|
|
}
|
|
|
|
|
|
|
|
onMouseOver(): void {
|
|
|
|
this.crosshair.style('display', null);
|
|
|
|
this.crosshair.raise();
|
|
|
|
}
|
|
|
|
|
|
|
|
onMouseOut(): void {
|
|
|
|
if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.mouseOut !== undefined) {
|
|
|
|
this.options.eventsCallbacks.mouseOut();
|
|
|
|
} else {
|
|
|
|
console.log('mouse out, but there is no callback');
|
|
|
|
}
|
|
|
|
this.crosshair.style('display', 'none');
|
|
|
|
}
|
|
|
|
|
|
|
|
contextMenu(): void {
|
|
|
|
// maybe it is not the best name, but i took it from d3.
|
|
|
|
this.d3.event.preventDefault(); // do not open browser's context menu.
|
|
|
|
|
|
|
|
const event = this.d3.mouse(this.chartContainer.node());
|
|
|
|
const eventX = event[0];
|
|
|
|
const series = this.getSeriesPointFromMousePosition(eventX);
|
|
|
|
|
|
|
|
if(this.options.eventsCallbacks !== undefined && this.options.eventsCallbacks.contextMenu !== undefined) {
|
|
|
|
this.options.eventsCallbacks.contextMenu({
|
|
|
|
x: this.d3.event.pageX,
|
|
|
|
y: this.d3.event.pageY,
|
|
|
|
time: this.xScale.invert(eventX),
|
|
|
|
series,
|
|
|
|
chartX: eventX
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
console.log('contextmenu, but there is no callback');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
get barWidth(): number {
|
|
|
|
// TODO: here we use first value + timeInterval as bar width. It is not a good idea
|
|
|
|
const xAxisStartValue = _.first(this.series[0].datapoints)[0];
|
|
|
|
let width = this.xScale(xAxisStartValue + this.timeInterval) / 2;
|
|
|
|
if(this.options.barWidth !== undefined) {
|
|
|
|
// barWidth now has axis-x dimension
|
|
|
|
width = this.xScale(this.state.getMinValueX() + this.options.barWidth);
|
|
|
|
}
|
|
|
|
let rectColumns = this.visibleSeries.length;
|
|
|
|
if(this.options.stacked === true) {
|
|
|
|
rectColumns = 1;
|
|
|
|
}
|
|
|
|
return this.updateBarWidthWithBorders(width / rectColumns);
|
|
|
|
}
|
|
|
|
|
|
|
|
updateBarWidthWithBorders(width: number): number {
|
|
|
|
let barWidth = width;
|
|
|
|
if(this.options.minBarWidth !== undefined) {
|
|
|
|
barWidth = Math.max(barWidth, this.options.minBarWidth);
|
|
|
|
}
|
|
|
|
if(this.options.maxBarWidth !== undefined) {
|
|
|
|
barWidth = Math.min(barWidth, this.options.maxBarWidth);
|
|
|
|
}
|
|
|
|
return barWidth;
|
|
|
|
}
|
|
|
|
|
|
|
|
getBarHeight(value: number): number {
|
|
|
|
// TODO: Property 'sign' does not exist on type 'Math'
|
|
|
|
// @ts-ignore
|
|
|
|
const height = Math.sign(value) * (this.barYScale(0) - this.barYScale(value));
|
|
|
|
return height;
|
|
|
|
}
|
|
|
|
|
|
|
|
getBarPositionX(key: number, idx: number): number {
|
|
|
|
let xPosition: number = this.xScale(key);
|
|
|
|
if(this.options.stacked === false) {
|
|
|
|
xPosition += idx * this.barWidth;
|
|
|
|
}
|
|
|
|
return xPosition;
|
|
|
|
}
|
|
|
|
|
|
|
|
getBarPositionY(val: number, idx: number, values: number[]): number {
|
|
|
|
let yPosition: number = this.barYScale(Math.max(val, 0));
|
|
|
|
if(this.options.stacked === true) {
|
|
|
|
const previousBarsHeight = _.sum(
|
|
|
|
_.map(_.range(idx), i => this.getBarHeight(values[i]))
|
|
|
|
);
|
|
|
|
yPosition -= previousBarsHeight;
|
|
|
|
}
|
|
|
|
return yPosition;
|
|
|
|
}
|
|
|
|
|
|
|
|
getYScale(): d3.ScaleLinear<number, number> {
|
|
|
|
if(
|
|
|
|
this.state.getMinValueY() === undefined ||
|
|
|
|
this.state.getMaxValueY() === undefined
|
|
|
|
) {
|
|
|
|
return this.d3.scaleLinear()
|
|
|
|
.domain([1, 0])
|
|
|
|
.range([0, this.height]);
|
|
|
|
}
|
|
|
|
const yMaxValue = this.getYMaxValue();
|
|
|
|
return this.d3.scaleLinear()
|
|
|
|
.domain([yMaxValue, Math.min(this.state.getMinValueY(), 0)])
|
|
|
|
.range([0, this.height]);
|
|
|
|
}
|
|
|
|
|
|
|
|
getYMaxValue(): number | undefined {
|
|
|
|
if(this.series === undefined || this.series.length === 0 || this.series[0].datapoints.length === 0) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
if(this.options.axis.y !== undefined && this.options.axis.y.range !== undefined) {
|
|
|
|
return _.max(this.options.axis.y.range);
|
|
|
|
}
|
|
|
|
let maxValue: number;
|
|
|
|
if(this.options.stacked === true) {
|
|
|
|
if(this.options.matching === true && this.seriesUniqKeys.length > 0) {
|
|
|
|
const maxValues = this.seriesForMatching.map(series => {
|
|
|
|
const valuesColumns = _.map(series, serie => _.map(serie.datapoints, row => row[1]));
|
|
|
|
const zippedValuesColumn = _.zip(...valuesColumns);
|
|
|
|
return maxValue = _.max(_.map(zippedValuesColumn, row => _.sum(row)));
|
|
|
|
});
|
|
|
|
return _.max(maxValues);
|
|
|
|
} else {
|
|
|
|
const valuesColumns = _.map(this.visibleSeries, serie => _.map(serie.datapoints, row => row[1]));
|
|
|
|
const zippedValuesColumn = _.zip(...valuesColumns);
|
|
|
|
maxValue = _.max(_.map(zippedValuesColumn, row => _.sum(row)));
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
console.log('else')
|
|
|
|
maxValue = _.max(
|
|
|
|
this.visibleSeries.map(
|
|
|
|
serie => _.maxBy<number[]>(serie.datapoints, dp => dp[1])[0]
|
|
|
|
)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return Math.max(maxValue, 0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// it is used with Vue.component, e.g.: Vue.component('chartwerk-bar-chart', VueChartwerkBarChartObject)
|
|
|
|
export const VueChartwerkBarChartObject = {
|
|
|
|
// alternative to `template: '<div class="chartwerk-bar-chart" :id="id" />'`
|
|
|
|
render(createElement) {
|
|
|
|
return createElement(
|
|
|
|
'div',
|
|
|
|
{
|
|
|
|
class: { 'chartwerk-bar-chart': true },
|
|
|
|
attrs: { id: this.id }
|
|
|
|
}
|
|
|
|
)
|
|
|
|
},
|
|
|
|
mixins: [VueChartwerkPodMixin],
|
|
|
|
methods: {
|
|
|
|
render() {
|
|
|
|
console.time('bar-render');
|
|
|
|
const pod = new ChartwerkBarPod(document.getElementById(this.id), this.series, this.options);
|
|
|
|
pod.render();
|
|
|
|
console.timeEnd('bar-render');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
export { BarTimeSerie, BarOptions, TickOrientation, TimeFormat, AxisFormat };
|