From 91d5fe35a153f5846eb47b42a72df41c950b65ee Mon Sep 17 00:00:00 2001 From: vargburz Date: Tue, 18 Oct 2022 20:21:23 +0300 Subject: [PATCH] Bar Pod: Discrete and Non-Discrete types --- examples/demo.html | 61 ------ examples/demo_annotation.html | 55 ++++++ examples/demo_discrete.html | 57 ++++++ examples/demo_formatters.html | 57 ++++++ examples/demo_non_discrete.html | 56 ++++++ src/index.ts | 326 ++++++-------------------------- src/models/bar_annotation.ts | 6 +- src/models/bar_group.ts | 183 ++++++++++++++++++ src/models/bar_options.ts | 99 +++++++--- src/models/bar_series.ts | 24 ++- src/models/bar_state.ts | 36 ++++ src/models/data_processor.ts | 109 ++++++++--- src/types.ts | 65 +++++-- 13 files changed, 722 insertions(+), 412 deletions(-) delete mode 100755 examples/demo.html create mode 100644 examples/demo_annotation.html create mode 100755 examples/demo_discrete.html create mode 100644 examples/demo_formatters.html create mode 100644 examples/demo_non_discrete.html create mode 100644 src/models/bar_group.ts create mode 100644 src/models/bar_state.ts diff --git a/examples/demo.html b/examples/demo.html deleted file mode 100755 index 9938c2a..0000000 --- a/examples/demo.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - -
- - - - - - diff --git a/examples/demo_annotation.html b/examples/demo_annotation.html new file mode 100644 index 0000000..394c5db --- /dev/null +++ b/examples/demo_annotation.html @@ -0,0 +1,55 @@ + + + + + + + + + +
+ + + + + + diff --git a/examples/demo_discrete.html b/examples/demo_discrete.html new file mode 100755 index 0000000..0f4588c --- /dev/null +++ b/examples/demo_discrete.html @@ -0,0 +1,57 @@ + + + + + + + + + +
+ + + + + + diff --git a/examples/demo_formatters.html b/examples/demo_formatters.html new file mode 100644 index 0000000..83623a5 --- /dev/null +++ b/examples/demo_formatters.html @@ -0,0 +1,57 @@ + + + + + + + + + +
+ + + + + + diff --git a/examples/demo_non_discrete.html b/examples/demo_non_discrete.html new file mode 100644 index 0000000..08d3ecc --- /dev/null +++ b/examples/demo_non_discrete.html @@ -0,0 +1,56 @@ + + + + + + + + + +
+ + + + + + diff --git a/src/index.ts b/src/index.ts index 4926ab5..9dfa768 100755 --- a/src/index.ts +++ b/src/index.ts @@ -3,27 +3,36 @@ import { ChartwerkPod, VueChartwerkPodMixin, TimeFormat, AxisFormat } from '@cha import { BarConfig } from './models/bar_options'; import { BarSeries } from './models/bar_series'; import { BarAnnotation } from './models/bar_annotation'; -import { DataProcessor } from './models/data_processor'; +import { DataProcessor, DataRow, DataItem } from './models/data_processor'; +import { BarGroup } from './models/bar_group'; +import { setBarScaleY, setBarScaleX } from './models/bar_state'; + +import { + BarSerie, BarOptions, DiscreteConfig, NonDiscreteConfig, + BarPodType, SizeType, CallbackEvent, Color, Opacity, + ColorFormatter, OpacityFormatter, + Datapoint, ValueX, ValueY, +} from './types'; -import { BarSerie, BarOptions } from './types'; import { findClosest } from './utils'; import * as d3 from 'd3'; import * as _ from 'lodash'; - export class ChartwerkBarPod extends ChartwerkPod { barYScale: null | d3.ScaleLinear = null; dataProcessor: DataProcessor; series: BarSeries; options: BarConfig; - constructor(el: HTMLElement, _series: BarSerie[] = [], _options: BarOptions = {}) { super(el, _series, _options); this.series = new BarSeries(_series); this.options = new BarConfig(_options); this.dataProcessor = new DataProcessor(this.series.visibleSeries, this.options); + + setBarScaleY(this.state, this.dataProcessor.dataRows, this.options); + setBarScaleX(this.state, this.dataProcessor.dataRows, this.options); } protected renderMetrics(): void { @@ -31,151 +40,33 @@ export class ChartwerkBarPod extends ChartwerkPod { this.renderNoDataPointsMessage(); return; } - - this.setBarPodScales(); - this.setSeriesDataForRendering(); - this.renderSerie(this._seriesDataForRendring); - } - - get isMatchingDisabled(): boolean { - return this.options.barOptions.matching === false || this.seriesUniqKeys.length === 0; - } - - setSeriesDataForRendering(): void { - if(this.isMatchingDisabled) { - this._seriesDataForRendring = this.getZippedDataForRender(this.series.visibleSeries); - } else { - const matchedSeries = this.seriesForMatching.map( - (series: BarSerie[], idx: number) => this.getZippedDataForRender(series) + this.dataProcessor.dataRows.forEach((dataRow: DataRow) => { + const barGroup = new BarGroup( + this.overlay, this.metricContainer, + dataRow, this.options, this.state, + { width: this.width, height: this.height, zeroHorizon: this.state.yScale(0) }, + this.dataProcessor.dataRows.length ); - 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, rowIndex: number, nodes: any) => { - const container = d3.select(nodes[rowIndex]); - container.selectAll('rect') - .data(d.values) - .enter().append('rect') - .style('fill', (val, idx) => this.getBarColor(d, val, idx, rowIndex)) - .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)) - .on('mouseover', this.redirectEventToOverlay.bind(this)) - .on('mousemove', this.redirectEventToOverlay.bind(this)) - .on('mouseout', this.redirectEventToOverlay.bind(this)) - .on('mousedown', () => { d3.event.stopPropagation(); }); - - // render bar annotations, its all hardcoded - if(_.isEmpty(this.options.barOptions.annotations)) { - return; - } - // find all series for single matchedKey - const series = _.filter(this.series.visibleSeries, serie => _.includes(d.serieTarget, serie.target)); - const matchedKeys = _.map(series, serie => serie.matchedKey); // here matchedKeys should be equal - const key = matchedKeys[0]; - const annotation = _.find(this.options.barOptions.annotations, a => a.key === key); - if(!key || !annotation) { - return; - } - const annotationOptions = { - size: this.barWidth, - max: this.options.barOptions.maxAnnotationSize, - min: this.options.barOptions.minAnnotationSize, - color: annotation.color, - }; - new BarAnnotation(this.overlay, container, annotationOptions); - }); - } - - redirectEventToOverlay(): void { - this.overlay?.node().dispatchEvent(new MouseEvent(d3.event.type, d3.event)); - } - - getBarOpacity(rowValues: RowValues): number { - if(this.options.barOptions.opacityFormatter === undefined) { - return 1; - } - return this.options.barOptions.opacityFormatter(rowValues); - } - - getBarColor(rowValues: RowValues, val: number, i: number, rowIndex: number): string { - if(_.isFunction(rowValues.colors[i])) { - return (rowValues.colors[i] as Function)({ rowData: rowValues, val, stackedIndex: i, rowIndex }); - } - return (rowValues.colors[i] as string); - } - - 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.series.isSeriesAvailable) { - return []; - } - const keys = this.series.visibleSeries.map(serie => serie.matchedKey); - const uniqKeys = _.uniq(keys); - const filteredKeys = _.filter(uniqKeys, key => key !== undefined); - return filteredKeys; - } - - get seriesForMatching(): BarSerie[][] { - if(this.seriesUniqKeys.length === 0) { - return [this.series.visibleSeries]; - } - const seriesList = this.seriesUniqKeys.map(key => { - const seriesWithKey = _.filter(this.series.visibleSeries, serie => serie.matchedKey === key); - return seriesWithKey; + this.renderAnnotationForGroup(barGroup, dataRow); }); - return seriesList; } - getZippedDataForRender(series: BarSerie[]): RowValues[] { - if(series.length === 0) { - throw new Error('There is no visible series'); + renderAnnotationForGroup(barGroup: BarGroup, dataRow: DataRow): void { + if(this.options.barType === BarPodType.DISCRETE) { + return; + } + const target = _.first(dataRow.items).target; + const serie = this.series.getSerieByTarget(target); + if(!serie.annotation?.enable) { + return; } - 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 => serie.colorFormatter || serie.color); - 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); + const annotationOptions = { + size: barGroup.getGroupWidth(), + max: serie.annotation.size.max, + min: serie.annotation.size.min, + color: serie.annotation.color, + }; + new BarAnnotation(this.overlay, barGroup.getGroupContainer(), annotationOptions); } public renderSharedCrosshair(values: { x?: number, y?: number }): void { @@ -198,28 +89,29 @@ export class ChartwerkBarPod extends ChartwerkPod { this.crosshair.select('#crosshair-line-x') .attr('x1', eventX) .attr('x2', eventX); - - const series = this.getSeriesPointFromMousePosition(eventX); + const dataRow = this.getDataRowFromMousePosition(eventX); this.options.callbackMouseMove({ - x: d3.event.pageX, - y: d3.event.pageY, - time: this.state.xScale.invert(eventX), - series, - chartX: eventX, - chartWidth: this.width + position: { + eventX: event[0], + eventY: event[1], + pageX: d3.event.pageX, + pageY: d3.event.pageY, + valueX: this.state.xScale.invert(event[0]), + valueY: this.state.yScale.invert(event[1]), + }, + data: dataRow, }); } - getSeriesPointFromMousePosition(eventX: number): any[] | undefined { + getDataRowFromMousePosition(eventX: number): DataRow | undefined { if(!this.series.isSeriesAvailable) { return undefined; } const mousePoisitionKey = Math.ceil(this.state.xScale.invert(eventX)); - const keys = _.map(this._seriesDataForRendring, el => el.key); + const keys = _.map(this.dataProcessor.dataRows, (dataRow: DataRow) => dataRow.key); const idx = findClosest(keys, mousePoisitionKey); - - return this._seriesDataForRendring[idx]; + return this.dataProcessor.dataRows[idx]; } onMouseOver(): void { @@ -231,120 +123,6 @@ export class ChartwerkBarPod extends ChartwerkPod { this.options.callbackMouseOut(); 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); - this.options.callbackContextMenu({ - pageX: d3.event.pageX, - pageY: d3.event.pageY, - xVal: this.state.xScale.invert(eventX), - series, - chartX: eventX - }); - } - - get barWidth(): number { - // TODO: here we use first value + timeInterval as bar width. It is not a good idea - const xAxisStartValue = _.first(this.series.visibleSeries[0].datapoints)[0]; - let width = this.state.xScale(xAxisStartValue + this.timeInterval) / 2; - if(this.options.barOptions.barWidth !== undefined) { - // barWidth now has axis-x dimension - width = this.state.xScale(this.state.getMinValueX() + this.options.barOptions.barWidth); - } - let rectColumns = this.series.visibleSeries.length; - if(this.options.barOptions.stacked === true) { - rectColumns = 1; - } - return this.updateBarWidthWithBorders(width / rectColumns); - } - - updateBarWidthWithBorders(width: number): number { - let barWidth = width; - if(this.options.barOptions.minBarWidth !== undefined) { - barWidth = Math.max(barWidth, this.options.barOptions.minBarWidth); - } - if(this.options.barOptions.maxBarWidth !== undefined) { - barWidth = Math.min(barWidth, this.options.barOptions.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.state.xScale(key); - if(this.options.barOptions.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.barOptions.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.isSeriesAvailable) { - return undefined; - } - if(this.options.axis.y.range) { - return _.max(this.options.axis.y.range); - } - let maxValue: number; - if(this.options.barOptions.stacked === true) { - if(this.options.barOptions.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.series.visibleSeries, serie => _.map(serie.datapoints, row => row[1])); - const zippedValuesColumn = _.zip(...valuesColumns); - maxValue = _.max(_.map(zippedValuesColumn, row => _.sum(row))); - } - } else { - maxValue = _.max( - this.series.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) @@ -370,4 +148,10 @@ export const VueChartwerkBarChartObject = { } }; -export { BarSerie, BarOptions, TimeFormat, AxisFormat }; +export { + BarSerie, BarOptions, TimeFormat, AxisFormat, + DiscreteConfig, NonDiscreteConfig, + BarPodType, SizeType, CallbackEvent, Color, Opacity, + ColorFormatter, OpacityFormatter, + Datapoint, ValueX, ValueY, DataItem, DataRow, +}; diff --git a/src/models/bar_annotation.ts b/src/models/bar_annotation.ts index c33d317..a6d12ed 100644 --- a/src/models/bar_annotation.ts +++ b/src/models/bar_annotation.ts @@ -2,8 +2,6 @@ import * as d3 from 'd3'; import * as _ from 'lodash'; -const DEFAULT_ANNOTATION_MIN_SIZE = 6; //px -const DEFAULT_ANNOTATION_MAX_SIZE = 10; //px const DEFAULT_ANNOTATION_OFFSET_Y = 4; //offset between triangle and bar in px const DEFAULT_ANNOTATION_COLOR = 'red'; @@ -40,8 +38,8 @@ export class BarAnnotation { protected getTrianglePath(): string { // (x, y) - top left corner of bar - const minTriangleSize = this.annotationOptions?.min || DEFAULT_ANNOTATION_MIN_SIZE; - const maxTriagleSize = this.annotationOptions?.max || DEFAULT_ANNOTATION_MAX_SIZE; + const minTriangleSize = this.annotationOptions?.min; + const maxTriagleSize = this.annotationOptions?.max; const yOffset = this.annotationOptions?.offset || DEFAULT_ANNOTATION_OFFSET_Y; const centerX = this.position.x + this.annotationOptions.size / 2; const correctedLength = _.clamp(this.annotationOptions.size, minTriangleSize, maxTriagleSize); diff --git a/src/models/bar_group.ts b/src/models/bar_group.ts new file mode 100644 index 0000000..5e6e8f4 --- /dev/null +++ b/src/models/bar_group.ts @@ -0,0 +1,183 @@ +import { DataRow, DataItem } from './data_processor'; +import { BarConfig } from './bar_options'; + +import { PodState } from '@chartwerk/core'; + +import { BarOptions, BarPodType, BarSerie, SizeType } from '../types'; + +import * as d3 from 'd3'; +import * as _ from 'lodash'; + + +export class BarGroup { + constructor( + protected overlay: d3.Selection, // overlay from core. It should be global + protected container: d3.Selection, + protected dataRow: DataRow, + protected options: BarConfig, + protected state: PodState, + protected boxParams: { width: number, height: number, zeroHorizon: number }, + protected allDataLength: number, + ) { + this.setGroupWidth(); + this.setBarWidth(); + + this.renderBarGroup(); + } + + protected _groupContainer: d3.Selection; + protected _groupWidth: number; + protected _barWidth: number; + + public getGroupContainer(): d3.Selection { + return this._groupContainer; + } + + public getGroupWidth(): number { + return this._groupWidth; + } + + public getBarWidth(): number { + return this._barWidth; + } + + protected renderBarGroup(): void { + this._groupContainer = this.container.append('g').attr('class', `bar-group group-${this.dataRow.key}`); + _.forEach(this.dataRow.items, (data: DataItem, serieIdx: number) => { + this.renderStackedBars(data, serieIdx); + }); + } + + protected renderStackedBars(data: DataItem, serieIdx: number): void { + const barsHeightPositiveList = []; + const barsHeightNegativeList = []; + data.values.forEach((value: number, valueIdx: number) => { + const barHeight = this.getBarHeight(value); + const config = { + position: { + x: this.getBarPositionX(serieIdx), + y: this.getBarPositionY(value, barsHeightPositiveList, barsHeightNegativeList, barHeight), + }, + width: this._barWidth, + height: barHeight, + color: data.colors[valueIdx], + opacity: data.opacity[valueIdx], + value, + }; + if(value >= 0) { + barsHeightPositiveList.push(barHeight); + } else { + barsHeightNegativeList.push(barHeight); + } + + this.renderBar(config); + }); + } + + protected getBarPositionX(idx: number): number { + return this.state.xScale(this.dataRow.key) + this._barWidth * idx; + } + + protected getBarPositionY( + value: number, barsHeightPositiveList: number[], barsHeightNegativeList: number[], barHeight: number + ): number { + if(value >= 0) { + const previousBarsHeight = _.sum(barsHeightPositiveList); + return this.boxParams.zeroHorizon - previousBarsHeight - barHeight; + } + const previousBarsHeight = _.sum(barsHeightNegativeList); + return this.boxParams.zeroHorizon + previousBarsHeight; + } + + protected getBarHeight(value): number { + return Math.abs(this.boxParams.zeroHorizon - this.state.yScale(value)); + } + + protected setGroupWidth(): void { + switch(this.options.barType) { + case BarPodType.DISCRETE: + this.setDiscreteGroupWidth(); + return; + case BarPodType.NON_DISCRETE: + this.setNonDiscreteGroupWidth(); + return; + } + } + + protected setDiscreteGroupWidth(): void { + const groupSizeConfig = this.options.dicreteConfig.groupSize.width; + let width; + if(groupSizeConfig.type === SizeType.PX) { + width = groupSizeConfig.value; + } + if(groupSizeConfig.type === SizeType.PERCENT) { + const factor = groupSizeConfig.value / 100; + width = (this.boxParams.width / this.allDataLength) * factor; + } + this._groupWidth = _.clamp(width, this.options.dicreteConfig.groupSize.min || width, this.options.dicreteConfig.groupSize.max || width); + } + + protected setNonDiscreteGroupWidth(): void { + const widthConfig = this.options.nonDicreteConfig.barWidth; + if(widthConfig.estimated.value === undefined) { + const groupWidth = this.boxParams.width / (this.allDataLength * this.dataRow.items.length); + this._groupWidth = groupWidth * 0.5; + return; + } + let width; + if(widthConfig.estimated.type === SizeType.PX) { + width = widthConfig.estimated.value; + } + if(widthConfig.estimated.type === SizeType.UNIT) { + width = this.state.absXScale(widthConfig.estimated.value); + } + this._groupWidth = _.clamp(width, widthConfig.min || width, widthConfig.max || width); + } + + protected setBarWidth(): void { + switch(this.options.barType) { + case BarPodType.DISCRETE: + this._barWidth = this._groupWidth / this.dataRow.items.length; + return; + case BarPodType.NON_DISCRETE: + this._barWidth = this._groupWidth; + return; + } + } + + protected renderBar(config: any): void { + this._groupContainer.append('rect') + .attr('x', config.position.x) + .attr('y', config.position.y) + .attr('width', config.width) + .attr('height', config.height) + .style('fill', config.color) + .attr('opacity', config.opacity) + .on('contextmenu', this.contextMenu.bind(this)) + .on('mouseover', this.redirectEventToOverlay.bind(this)) + .on('mousemove', this.redirectEventToOverlay.bind(this)) + .on('mouseout', this.redirectEventToOverlay.bind(this)) + .on('mousedown', () => { d3.event.stopPropagation(); }); + } + + contextMenu(): void { + d3.event.preventDefault(); // do not open browser's context menu. + const event = d3.mouse(this.container.node()); + + this.options.callbackContextMenu({ + position: { + eventX: event[0], + eventY: event[1], + pageX: d3.event.pageX, + pageY: d3.event.pageY, + valueX: this.state.xScale.invert(event[0]), + valueY: this.state.yScale.invert(event[1]), + }, + data: this.dataRow, + }); + } + + redirectEventToOverlay(): void { + this.overlay?.node().dispatchEvent(new MouseEvent(d3.event.type, d3.event)); + } +} diff --git a/src/models/bar_options.ts b/src/models/bar_options.ts index 32e4606..59fe0ad 100644 --- a/src/models/bar_options.ts +++ b/src/models/bar_options.ts @@ -1,40 +1,66 @@ import { CoreOptions } from '@chartwerk/core'; -import { BarOptions, BarAdditionalOptions } from '../types'; +import { BarOptions, BarPodType, DiscreteConfig, NonDiscreteConfig, SizeType } from '../types'; import * as _ from 'lodash'; +const DEFAULT_MIN_BAR_WIDTH = 4; //px + const BAR_SERIE_DEFAULTS = { - renderBarLabels: false, - stacked: false, - barWidth: undefined, - maxBarWidth: undefined, - minBarWidth: undefined, - maxAnnotationSize: undefined, - minAnnotationSize: undefined, - matching: false, - opacityFormatter: undefined, - annotations: [], + type: { + [BarPodType.DISCRETE]: { + enable: false, + step: undefined, + innerSize: undefined, + groupSize: { + width: { value: 50, type: (SizeType.PERCENT as SizeType.PERCENT) }, + max: undefined, + min: DEFAULT_MIN_BAR_WIDTH, + } + }, + [BarPodType.NON_DISCRETE]: { + enable: false, + barWidth: { + estimated: { value: undefined, type: (SizeType.UNIT as SizeType.UNIT) }, + max: undefined, + min: DEFAULT_MIN_BAR_WIDTH, + } + }, + }, }; export class BarConfig extends CoreOptions { constructor(options: BarOptions) { super(options, BAR_SERIE_DEFAULTS); + this.validateOptions(); + } + + get barOptions(): any { + return {}; } - get barOptions(): BarAdditionalOptions { - return { - renderBarLabels: this._options.renderBarLabels, - stacked: this._options.stacked, - barWidth: this._options.barWidth, - maxBarWidth: this._options.maxBarWidth, - minBarWidth: this._options.minBarWidth, - maxAnnotationSize: this._options.maxAnnotationSize, - minAnnotationSize: this._options.minAnnotationSize, - matching: this._options.matching, - opacityFormatter: this._options.opacityFormatter, - annotations: this._options.annotations, + get dicreteConfig(): DiscreteConfig { + if(!this._options.type[BarPodType.DISCRETE]?.enable) { + throw new Error(`Can't get discrete config for non_dicreste type`); } + return this._options.type[BarPodType.DISCRETE]; + } + + get nonDicreteConfig(): NonDiscreteConfig { + if(!this._options.type[BarPodType.NON_DISCRETE]?.enable) { + throw new Error(`Can't get non_discrete config for dicreste type`); + } + return this._options.type[BarPodType.NON_DISCRETE]; + } + + get barType(): BarPodType { + if(this._options.type[BarPodType.DISCRETE]?.enable === true) { + return BarPodType.DISCRETE; + } + if(this._options.type[BarPodType.NON_DISCRETE]?.enable === true) { + return BarPodType.NON_DISCRETE; + } + throw new Error(`Unknown Ber Pod Type: ${this._options.type}`); } // event callbacks @@ -47,4 +73,31 @@ export class BarConfig extends CoreOptions { get contextMenu(): (evt: any) => void { return this._options.eventsCallbacks.contextMenu; } + + validateOptions(): void { + if( + this._options.type[BarPodType.DISCRETE]?.enable === true && + this._options.type[BarPodType.NON_DISCRETE]?.enable === true + ) { + throw new Error(`Bar Options is not valid: only one BarPodType should be enabled`); + } + if( + this._options.type[BarPodType.DISCRETE]?.enable === false && + this._options.type[BarPodType.NON_DISCRETE]?.enable === false + ) { + // @ts-ignore + this._options.type[BarPodType.DISCRETE]?.enable = true; + console.warn(`Bar Options: no type has been provided -> BarPodType.DISCRETE type will be used af default`); + } + } + + validateDiscreteOptions(): void { + const groupType = this._options.type[BarPodType.DISCRETE].groupSize?.width?.type; + if( + groupType !== undefined && + groupType !== SizeType.PERCENT && groupType !== SizeType.PX + ) { + throw new Error(`Bar Options is not valid: groupSize.width.type should be "px" on "percent": ${groupType}`); + } + } } diff --git a/src/models/bar_series.ts b/src/models/bar_series.ts index e9624af..b09ec72 100644 --- a/src/models/bar_series.ts +++ b/src/models/bar_series.ts @@ -1,10 +1,23 @@ import { CoreSeries } from '@chartwerk/core'; -import { BarSerie } from '../types'; +import { BarSerie, SizeType } from '../types'; +import * as _ from 'lodash'; + +const DEFAULT_MIN_ANNOTATION_SIZE = 6; //px +const DEFAULT_MAX_ANNOTATION_SIZE = 10; //px const BAR_SERIE_DEFAULTS = { - matchedKey: undefined, - colorFormatter: undefined + annotation: { + enable: false, + color: undefined, + size: { + esimated: { value: undefined, type: (SizeType.PERCENT as SizeType.PERCENT) }, + max: DEFAULT_MAX_ANNOTATION_SIZE, + min: DEFAULT_MIN_ANNOTATION_SIZE, + } + }, + color: undefined, + opacity: 1, }; export class BarSeries extends CoreSeries { @@ -12,4 +25,9 @@ export class BarSeries extends CoreSeries { constructor(series: BarSerie[]) { super(series, BAR_SERIE_DEFAULTS); } + + // move to parent + public getSerieByTarget(target: string): BarSerie | undefined { + return _.find(this.visibleSeries, serie => serie.target === target); + } } diff --git a/src/models/bar_state.ts b/src/models/bar_state.ts new file mode 100644 index 0000000..bd6e954 --- /dev/null +++ b/src/models/bar_state.ts @@ -0,0 +1,36 @@ +import { PodState } from '@chartwerk/core'; +import { DataRow } from './data_processor'; +import { BarConfig } from './bar_options'; + +import { BarOptions, BarSerie, BarPodType, DiscreteConfig } from '../types'; + +import * as _ from 'lodash'; + +// It is not a real bar state. Just rewrite some core params. like xScale/yScale +// TODO: import core scale and extend it, same to bar_series and bar_options + +export function setBarScaleX(state: PodState, dataRows: DataRow[], options: BarConfig): void { + switch(options.barType) { + case BarPodType.DISCRETE: + const config: DiscreteConfig = options.dicreteConfig; + const discreteStep = _.isNumber(config.step) ? config.step : (_.last(dataRows).key - _.first(dataRows).key) / dataRows.length; + state.xValueRange = [state.xValueRange[0], state.xValueRange[1] + discreteStep]; + return; + case BarPodType.NON_DISCRETE: + if(!_.isEmpty(options.axis.x.range)) { + return; + } + const nonDiscreteStep = (_.last(dataRows).key - _.first(dataRows).key) / dataRows.length; + state.xValueRange = [state.xValueRange[0], state.xValueRange[1] + nonDiscreteStep]; + return; + } +} + +export function setBarScaleY(state: PodState, dataRows: DataRow[], options: BarOptions): void { + if(!_.isEmpty(options.axis.y.range)) { + return; + } + const maxValue = _.max(_.map(dataRows, (dataRow: DataRow) => dataRow.maxSumm)); + const minValue = _.min(_.map(dataRows, (dataRow: DataRow) => dataRow.minSubtraction)); + state.yValueRange = [minValue, maxValue]; +} diff --git a/src/models/data_processor.ts b/src/models/data_processor.ts index 9651930..629c06c 100644 --- a/src/models/data_processor.ts +++ b/src/models/data_processor.ts @@ -1,25 +1,42 @@ -import { BarSerie, BarOptions, Datapoint, Color, ValueX, OpacityFormatter, BarPodType } from '../types'; +import { + Datapoint, Color, ValueX, Opacity, + BarPodType, BarSerie +} from '../types'; + +import { BarConfig } from './bar_options'; import * as _ from 'lodash'; export type DataRow = { key: number; // x - data: { - [serieTarget: string]: { - values: number[]; // y list - colors: string[]; - opacity: number[]; - } - }; + items: DataItem[]; maxSumm: number; minSubtraction: number; -} +}; +export type DataItem = { + target: string; // serie target + values: number[]; // y list + colors: string[]; + opacity: number[]; +}; +const DEFAULT_BAR_COLOR = 'green'; +const DEFAULT_OPACITY = 1; export class DataProcessor { - _formattedDataRows: DataRow[]; + _formattedDataRows: DataRow[] = []; - constructor(protected series: BarSerie[], protected options: BarOptions) { - this.setDataRowsForNonDiscreteType(); + constructor(protected visibleSeries: BarSerie[], protected options: BarConfig) { + this.validateData(); + switch(this.options.barType) { + case BarPodType.DISCRETE: + this.setDataRowsForDiscreteType(); + break; + case BarPodType.NON_DISCRETE: + this.setDataRowsForNonDiscreteType(); + break; + default: + throw new Error(`Bar DataProcessor: Unknown BarPodType ${this.options.barType}`); + } } public get dataRows(): DataRow[] { @@ -27,32 +44,40 @@ export class DataProcessor { } protected setDataRowsForDiscreteType(): void { - const groupByList = ( - this.options.type[BarPodType.DISCRETE].groupBy.x || - _.map(_.first(this.series).datapoints, datapoint => datapoint[0]) - ); - let rows = []; - // TODO: not effective - for(let groupItem of groupByList) { - for(let serie of this.series) { - const datapoint = _.find(serie.datapoints, ) + const zippedData = _.zip(..._.map(this.visibleSeries, serie => serie.datapoints)); + this._formattedDataRows = _.map(zippedData, (dataRow: Datapoint[], rowIdx: number) => { + return { + key: _.first(_.first(dataRow)), // x + items: _.map(dataRow, (datapoint: Datapoint, pointIdx: number) => { + const serie = this.visibleSeries[pointIdx]; + const values = _.tail(datapoint); + return { + target: serie.target, + values, // y list + colors: this.getColorsForEachValue(values, serie.color, datapoint[0], serie.target), + opacity: this.getOpacityForEachValue(values, serie.opacity, datapoint[0], serie.target), + } + }), + maxSumm: _.max(_.map(dataRow, (datapoint: Datapoint) => this.getMaxSumm(_.tail(datapoint)))), // max y axis scale + minSubtraction: _.min(_.map(dataRow, (datapoint: Datapoint) => this.getMinSubtraction(_.tail(datapoint)))), // min y axis scale } - } + }); } protected setDataRowsForNonDiscreteType(): void { - for(let serie of this.series) { + for(let serie of this.visibleSeries) { const rows = _.map(serie.datapoints, (datapoint: Datapoint) => { const values = _.tail(datapoint); return { key: datapoint[0], // x - data: { - [serie.target]: { + items: [ + { + target: serie.target, values, // y list colors: this.getColorsForEachValue(values, serie.color, datapoint[0], serie.target), - opacity: this.getOpacityForEachValue(values, serie.opacityFormatter, datapoint[0], serie.target), + opacity: this.getOpacityForEachValue(values, serie.opacity, datapoint[0], serie.target), } - }, + ], maxSumm: this.getMaxSumm(values), // max y axis scale minSubtraction: this.getMinSubtraction(values), // min y axis scale } @@ -67,7 +92,7 @@ export class DataProcessor { return _.map(values, value => color); } if(_.isArray(color)) { - return _.map(values, (value, i) => color[i] || 'red'); + return _.map(values, (value, i) => color[i] || DEFAULT_BAR_COLOR); } if(_.isFunction(color)) { return _.map(values, (value, i) => (color as Function)({ value, barIndex: i, key, target })); @@ -75,11 +100,20 @@ export class DataProcessor { throw new Error(`Unknown type of serie color: ${target} ${color}`); } - getOpacityForEachValue(values: number[], opacity: OpacityFormatter | undefined, key: ValueX, target: string): number[] { + getOpacityForEachValue(values: number[], opacity: Opacity | undefined, key: ValueX, target: string): number[] { if(opacity === undefined) { - return _.map(values, value => 1); + return _.map(values, value => DEFAULT_OPACITY); + } + if(_.isNumber(opacity)) { + return _.map(values, value => opacity); } - return _.map(values, (value, i) => opacity({ value, barIndex: i, key, target })); + if(_.isArray(opacity)) { + return _.map(values, (value, i) => !_.isNil(opacity[i]) ? opacity[i] : DEFAULT_OPACITY); + } + if(_.isFunction(opacity)) { + return _.map(values, (value, i) => (opacity as Function)({ value, barIndex: i, key, target })); + } + throw new Error(`Unknown type of serie opacity: ${target} ${opacity}`); } getMaxSumm(values: number[]): number { @@ -89,4 +123,17 @@ export class DataProcessor { getMinSubtraction(values: number[]): number { return values.reduce((prev, curr) => curr < 0 ? prev + curr : prev, 0); } + + validateData(): void { + if(this.options.barType === BarPodType.NON_DISCRETE) { + return; + } + const xValuesList = _.map(_.first(this.visibleSeries).datapoints, dp => dp[0]); + _.forEach(this.visibleSeries, serie => { + const serieXList = _.map(serie.datapoints, dp => dp[0]); + if(!_.isEqual(xValuesList, serieXList)) { + throw new Error(`Bar DataProcessor: All series should have equal X values lists`); + } + }); + } } diff --git a/src/types.ts b/src/types.ts index 9da78b8..54f9b5d 100755 --- a/src/types.ts +++ b/src/types.ts @@ -1,45 +1,51 @@ import { Serie, Options } from '@chartwerk/core'; +import { DataRow } from 'models/data_processor'; export type ValueX = (number | string); -type ValueY = number; +export type ValueY = number; export type Datapoint = [ValueX, ...ValueY[]]; // [x, y, y, y, ..., y], multiple y values as stacked bars export type ColorFormatter = (data: { value: ValueY, key: ValueX, barIndex: number, target: string }) => string; export type OpacityFormatter = (data: { value: ValueY, key: ValueX, barIndex: number, target: string }) => number; export type Color = string | string[] | ColorFormatter; +export type Opacity = number | number[] | OpacityFormatter; export type BarSerieAdditionalParams = { datapoints: Datapoint[]; + // TODO add absolute/stacked type for y values. datapoint can be [x: 0, y1: 10, y2: 20] (absolute) === [x: 0, y1: 10, y2: 10] (stacked) annotation?: { - color: string; - size: { + // only for non_discrete type for now + enable: boolean; + color?: string; + size?: { + esimated: { value: number, type?: SizeType.PERCENT | SizeType.PX }; max?: number; // type always SizeType.PX min?: number; // type always SizeType.PX } }; - color: Color; - opacityFormatter?: OpacityFormatter; + color?: Color; + opacity?: Opacity; } export type BarSerie = Serie & BarSerieAdditionalParams; +export type CallbackEvent = { + position: { + eventX: number, + eventY: number, + pageX: number, + pageY: number, + valueX: number, + valueY: number, + }, + data: DataRow, +} export type BarAdditionalOptions = { type: { // BarPodType.DISCRETE or BarPodType.NON_DISCRETE. Cant be both - [BarPodType.DISCRETE]: { - // union bars as one group, see examples/demo-group-by.html - groupBy: { x: ValueX[] }; // use list of X values from first Serie by default - innerSize: { value: number, type?: SizeType.PERCENT | SizeType.PX }; // size of bars inside one group - groupSize: { value: number, type?: SizeType.PERCENT | SizeType.PX }; - }; - [BarPodType.NON_DISCRETE]: { - barWidth: { - estimated?: { value: number, type?: SizeType.UNIT | SizeType.PX }; - max?: number; // type always SizeType.PX - min?: number; // type always SizeType.PX - } - } + [BarPodType.DISCRETE]: DiscreteConfig; + [BarPodType.NON_DISCRETE]: NonDiscreteConfig; } eventsCallbacks?: { - contextMenu?: (data: any) => void; + contextMenu?: (data: CallbackEvent) => void; }; } export type BarOptions = Options & Partial; @@ -54,3 +60,24 @@ export enum BarPodType { DISCRETE = 'discrete', // render bars as groups NON_DISCRETE = 'non-discrete', // render bars as time chart } + +export type DiscreteConfig = { + // union bars as one group, see examples/demo-group-by.html + enable: boolean; + step?: number; // X axis interval between two bars, type always SizeType.UNIT + groupSize?: { + width: { value: number, type?: SizeType.PERCENT | SizeType.PX, }, + max?: number, // type always SizeType.PX + min?: number, // type always SizeType.PX + // TODO: margin between bars + }; +}; + +export type NonDiscreteConfig = { + enable: boolean; + barWidth?: { + estimated?: { value: number, type?: SizeType.UNIT | SizeType.PX }; + max?: number; // type always SizeType.PX + min?: number; // type always SizeType.PX + } +};