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 { barYScale: null | d3.ScaleLinear = null; _seriesDataForRendring = []; constructor(el: HTMLElement, _series: BarTimeSerie[] = [], _options: BarOptions = {}) { super(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 = 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: d3.event.pageX, y: 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. d3.event.preventDefault(); // do not open browser's context menu. const event = 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: d3.event.pageX, y: 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 { if( this.state.getMinValueY() === undefined || this.state.getMaxValueY() === undefined ) { return d3.scaleLinear() .domain([1, 0]) .range([0, this.height]); } const yMaxValue = this.getYMaxValue(); return 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(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: '
'` 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 };