From 135a286bf302e839f9046ad21fc39a28e664a157 Mon Sep 17 00:00:00 2001 From: vargburz Date: Fri, 16 Sep 2022 17:25:38 +0300 Subject: [PATCH 1/5] move bar annotations to a separate model --- src/index.ts | 54 +++++---------------------- src/models/bar_annotation.ts | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 45 deletions(-) create mode 100644 src/models/bar_annotation.ts diff --git a/src/index.ts b/src/index.ts index 67cbe0a..7a75018 100755 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ 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 { BarSerie, BarOptions, RowValues } from './types'; import { findClosest } from './utils'; @@ -88,27 +89,17 @@ export class ChartwerkBarPod extends ChartwerkPod { 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 lastRect = _.last(container.selectAll('rect')?.nodes()); const annotation = _.find(this.options.barOptions.annotations, a => a.key === key); - if(!lastRect || !key || !annotation) { + if(!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.barOptions.maxAnnotationSize, min: this.options.barOptions.minAnnotationSize }; - return this.getTrianglePath(x, y, this.barWidth, options); - }) - .attr('fill', annotation.color) - .on('mouseover', this.redirectEventToOverlay.bind(this)) - .on('mousemove', this.redirectEventToOverlay.bind(this)) - .on('mouseout', this.redirectEventToOverlay.bind(this)) - .on('mousedown', () => { d3.event.stopPropagation(); }); + const annotationOptions = { + size: this.barWidth, + max: this.options.barOptions.maxAnnotationSize, + min: this.options.barOptions.minAnnotationSize, + color: annotation.color, + }; + new BarAnnotation(this.overlay, container, annotationOptions); }); } @@ -116,33 +107,6 @@ export class ChartwerkBarPod extends ChartwerkPod { this.overlay?.node().dispatchEvent(new MouseEvent(d3.event.type, d3.event)); } - 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.barOptions.opacityFormatter === undefined) { return 1; diff --git a/src/models/bar_annotation.ts b/src/models/bar_annotation.ts new file mode 100644 index 0000000..c33d317 --- /dev/null +++ b/src/models/bar_annotation.ts @@ -0,0 +1,71 @@ +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'; + +export class BarAnnotation { + position: { x: number, y: number }; + + constructor( + protected overlay: d3.Selection, // overlay from core. It should be global + protected groupContainer: d3.Selection, // group - bars as one item + protected annotationOptions: { size: number, max?: number, min?: number, offset?: number, color?: string }, + ) { + this.position = this.getGroupLastRectPosition(); + this.renderAnnotation(); + } + + protected renderAnnotation(): void { + this.groupContainer.append('path') + .attr('d', () => this.getTrianglePath()) + .attr('fill', this.annotationOptions.color || DEFAULT_ANNOTATION_COLOR) + .on('mouseover', this.redirectEventToOverlay.bind(this)) + .on('mousemove', this.redirectEventToOverlay.bind(this)) + .on('mouseout', this.redirectEventToOverlay.bind(this)) + .on('mousedown', () => { d3.event.stopPropagation(); }); + } + + getGroupLastRectPosition(): { x: number, y: number } { + const lastRect = _.last(this.groupContainer.selectAll('rect')?.nodes()); + const barSelection = d3.select(lastRect); + return { + x: Math.ceil(_.toNumber(barSelection.attr('x'))), + y: Math.ceil(_.toNumber(barSelection.attr('y'))), + } + } + + 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 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); + + const topY = Math.max(this.position.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`; + } + + redirectEventToOverlay(): void { + this.overlay?.node().dispatchEvent(new MouseEvent(d3.event.type, d3.event)); + } +} From 032d80188e078155f2203a63a93c9db96bde7fbb Mon Sep 17 00:00:00 2001 From: vargburz Date: Fri, 16 Sep 2022 20:10:10 +0300 Subject: [PATCH 2/5] new data and options formats --- examples/demo.html | 10 +++---- src/models/data_processor.ts | 0 src/types.ts | 55 +++++++++++++++++++++++------------- 3 files changed, 41 insertions(+), 24 deletions(-) create mode 100644 src/models/data_processor.ts diff --git a/examples/demo.html b/examples/demo.html index 7ec1ac9..9938c2a 100755 --- a/examples/demo.html +++ b/examples/demo.html @@ -14,9 +14,9 @@ document.getElementById('chart'), [ { target: 'test11', datapoints: getData(), matchedKey: 'm-1', color: 'red', colorFormatter: (data) => ['green', 'yellow'][data.rowIndex] }, - // { target: 'test12', datapoints: [[100, 10], [200, 20], [300, 10]], matchedKey: 'm-1', color: 'green' }, - // { target: 'test21', datapoints: [[130, 10], [230, 26], [330, 15]], matchedKey: 'm-2', color: 'yellow'}, - // { target: 'test22', datapoints: [[130, 10], [230, 27], [330, 10]], matchedKey: 'm-2', color: 'blue' }, + { target: 'test12', datapoints: [[100, 10], [200, 20], [300, 10]], matchedKey: 'm-1', color: 'green' }, + { target: 'test21', datapoints: [[130, 10], [230, 26], [330, 15]], matchedKey: 'm-2', color: 'yellow'}, + { target: 'test22', datapoints: [[130, 10], [230, 27], [330, 10]], matchedKey: 'm-2', color: 'blue' }, ], { usePanning: false, @@ -24,8 +24,8 @@ x: { format: 'custom', invert: false, valueFormatter: (value) => { return 'L' + value; } }, y: { format: 'custom', invert: false, range: [0, 30], valueFormatter: (value) => { return value + '%'; } } }, - stacked: false, - matching: false, + stacked: true, + matching: true, maxBarWidth: 20, minBarWidth: 4, zoomEvents: { diff --git a/src/models/data_processor.ts b/src/models/data_processor.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/types.ts b/src/types.ts index f6903e6..7aff2de 100755 --- a/src/types.ts +++ b/src/types.ts @@ -1,28 +1,39 @@ import { Serie, Options } from '@chartwerk/core'; -export type BarSerieParams = { - matchedKey: string; - colorFormatter: (serie: BarSerie) => string; +type ValueX = (number | string); +type ValueY = number; + +export type BarSerieAdditionalParams = { + datapoints: [ValueX, ...ValueY[]]; // [x, y, y, y, ..., y], multiple y values as stacked bars + annotation?: { + color: string; + size: { + max?: number; // type always SizeType.PX + min?: number; // type always SizeType.PX + } + } + opacityFormatter?: (data: RowValues) => number; + colorFormatter?: (data: any) => string; } -export type BarSerie = Serie & Partial; +export type BarSerie = Serie & BarSerieAdditionalParams; + + export type BarAdditionalOptions = { - renderBarLabels?: boolean; - stacked?: boolean; - barWidth?: number; // width in x axis unit - maxBarWidth?: number; // in px - minBarWidth?: number; // in px - maxAnnotationSize?: number; // in px TODO: move to annotaions - minAnnotationSize?: number; // in px - matching?: boolean; - opacityFormatter?: (data: RowValues) => number; - annotations?: { - key: string, // matchedKey from series - // TODO: add enum with "triangle" option - color: string, - }[]; + group: { // for more than 1 serie and discrete.enable == true + by: { x: ValueX[] }; // union bars as one group, see examples/demo-group-by.html + size: number; // type always SizeType.PERCENT + }; + discrete: { + enable: boolean; // if false -> render bars as time chart | group will not work, see examples/demo-non-discrete.html + barWidth: { + estimated?: { value: number, type?: SizeType }; + max?: number; // type always SizeType.PX + min?: number; // type always SizeType.PX + } + }; eventsCallbacks?: { contextMenu?: (data: any) => void; - } + }; } export type BarOptions = Options & Partial; export type RowValues = { @@ -32,3 +43,9 @@ export type RowValues = { colors: (string | ((data: any) => string))[], serieTarget: string[], } + +export enum SizeType { + UNIT = 'unit', // in units of X or Y values + PX = 'px', + PERCENT = 'percent', // from 0 to 100 +} From cdd03b410ca02196031f8cce2178bf960b920e86 Mon Sep 17 00:00:00 2001 From: vargburz Date: Sat, 17 Sep 2022 00:44:00 +0300 Subject: [PATCH 3/5] init data processor with non discrete type --- src/models/data_processor.ts | 60 ++++++++++++++++++++++++++++++++++++ src/types.ts | 50 ++++++++++++++++-------------- 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/src/models/data_processor.ts b/src/models/data_processor.ts index e69de29..6cedea2 100644 --- a/src/models/data_processor.ts +++ b/src/models/data_processor.ts @@ -0,0 +1,60 @@ +import { BarSerie, BarOptions, Datapoint, Color, ValueX, OpacityFormatter } from '../types'; + +import * as _ from 'lodash'; + +export type DataRow = { + key: number, // x + values: number[], // y list + colors: string[], + opacity: number[], + serieTarget: string, +} + +export class DataProcessor { + _formattedDataRows: DataRow[]; + + constructor(protected series: BarSerie[], protected options: BarOptions) { + + } + + public get dataRows(): DataRow[] { + return this._formattedDataRows; + } + + protected setDataRowsForNonDiscreteType(): void { + for(let serie of this.series) { + const rows = _.map(serie.datapoints, (datapoint: Datapoint) => { + const values = _.tail(datapoint); + return { + key: datapoint[0], // x + values, // y list + colors: this.getColorsForEachValue(values, serie.color, datapoint[0], serie.target), + opacity: this.getOpacityForEachValue(values, serie.opacityFormatter, datapoint[0], serie.target), + target: serie.target, + } + }); + this._formattedDataRows = _.concat(this._formattedDataRows, rows); + } + this._formattedDataRows = _.sortBy(this._formattedDataRows, 'key'); + } + + getColorsForEachValue(values: number[], color: Color, key: ValueX, target: string): string[] { + if(_.isString(color)) { + return _.map(values, value => color); + } + if(_.isArray(color)) { + return _.map(values, (value, i) => color[i] || 'red'); + } + if(_.isFunction(color)) { + return _.map(values, (value, i) => (color as Function)({ value, barIndex: i, key, target })); + } + throw new Error(`Unknown type of serie color: ${target} ${color}`); + } + + getOpacityForEachValue(values: number[], opacity: OpacityFormatter | undefined, key: ValueX, target: string): number[] { + if(opacity === undefined) { + return _.map(values, value => 1); + } + return _.map(values, (value, i) => opacity({ value, barIndex: i, key, target })); + } +} diff --git a/src/types.ts b/src/types.ts index 7aff2de..1bf9934 100755 --- a/src/types.ts +++ b/src/types.ts @@ -1,51 +1,55 @@ import { Serie, Options } from '@chartwerk/core'; -type ValueX = (number | string); +export type ValueX = (number | string); 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 BarSerieAdditionalParams = { - datapoints: [ValueX, ...ValueY[]]; // [x, y, y, y, ..., y], multiple y values as stacked bars + datapoints: Datapoint[]; annotation?: { color: string; size: { max?: number; // type always SizeType.PX min?: number; // type always SizeType.PX } - } - opacityFormatter?: (data: RowValues) => number; - colorFormatter?: (data: any) => string; + }; + color: Color; + opacityFormatter?: OpacityFormatter; } export type BarSerie = Serie & BarSerieAdditionalParams; export type BarAdditionalOptions = { - group: { // for more than 1 serie and discrete.enable == true - by: { x: ValueX[] }; // union bars as one group, see examples/demo-group-by.html - size: number; // type always SizeType.PERCENT - }; - discrete: { - enable: boolean; // if false -> render bars as time chart | group will not work, see examples/demo-non-discrete.html - barWidth: { - estimated?: { value: number, type?: SizeType }; - max?: number; // type always SizeType.PX - min?: number; // type always SizeType.PX + type: { // BarPodType.DISCRETE or BarPodType.NON_DISCRETE. Cant be both + [BarPodType.DISCRETE]: { + groupBy: { x: ValueX[] }; // union bars as one group, see examples/demo-group-by.html + 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 + } } - }; + } eventsCallbacks?: { contextMenu?: (data: any) => void; }; } export type BarOptions = Options & Partial; -export type RowValues = { - key: number, - values: number[], - additionalValues: (null | number)[], // values in datapoints third column - colors: (string | ((data: any) => string))[], - serieTarget: string[], -} export enum SizeType { UNIT = 'unit', // in units of X or Y values PX = 'px', PERCENT = 'percent', // from 0 to 100 } + +export enum BarPodType { + DISCRETE = 'discrete', // render bars as groups + NON_DISCRETE = 'non-discrete', // render bars as time chart +} From 3777ba470c5a4e19121c208fe483f4524a4dc36f Mon Sep 17 00:00:00 2001 From: vargburz Date: Sat, 17 Sep 2022 01:44:19 +0300 Subject: [PATCH 4/5] try to adddiscrete type --- src/index.ts | 7 +++-- src/models/data_processor.ts | 54 ++++++++++++++++++++++++++++-------- src/types.ts | 3 +- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7a75018..4926ab5 100755 --- a/src/index.ts +++ b/src/index.ts @@ -3,8 +3,9 @@ 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 { BarSerie, BarOptions, RowValues } from './types'; +import { BarSerie, BarOptions } from './types'; import { findClosest } from './utils'; import * as d3 from 'd3'; @@ -13,14 +14,16 @@ import * as _ from 'lodash'; export class ChartwerkBarPod extends ChartwerkPod { barYScale: null | d3.ScaleLinear = null; - _seriesDataForRendring = []; + 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); } protected renderMetrics(): void { diff --git a/src/models/data_processor.ts b/src/models/data_processor.ts index 6cedea2..9651930 100644 --- a/src/models/data_processor.ts +++ b/src/models/data_processor.ts @@ -1,36 +1,60 @@ -import { BarSerie, BarOptions, Datapoint, Color, ValueX, OpacityFormatter } from '../types'; +import { BarSerie, BarOptions, Datapoint, Color, ValueX, OpacityFormatter, BarPodType } from '../types'; import * as _ from 'lodash'; export type DataRow = { - key: number, // x - values: number[], // y list - colors: string[], - opacity: number[], - serieTarget: string, + key: number; // x + data: { + [serieTarget: string]: { + values: number[]; // y list + colors: string[]; + opacity: number[]; + } + }; + maxSumm: number; + minSubtraction: number; } export class DataProcessor { _formattedDataRows: DataRow[]; constructor(protected series: BarSerie[], protected options: BarOptions) { - + this.setDataRowsForNonDiscreteType(); } public get dataRows(): DataRow[] { return this._formattedDataRows; } + 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, ) + } + } + } + protected setDataRowsForNonDiscreteType(): void { for(let serie of this.series) { const rows = _.map(serie.datapoints, (datapoint: Datapoint) => { const values = _.tail(datapoint); return { key: datapoint[0], // x - values, // y list - colors: this.getColorsForEachValue(values, serie.color, datapoint[0], serie.target), - opacity: this.getOpacityForEachValue(values, serie.opacityFormatter, datapoint[0], serie.target), - target: serie.target, + data: { + [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), + } + }, + maxSumm: this.getMaxSumm(values), // max y axis scale + minSubtraction: this.getMinSubtraction(values), // min y axis scale } }); this._formattedDataRows = _.concat(this._formattedDataRows, rows); @@ -57,4 +81,12 @@ export class DataProcessor { } return _.map(values, (value, i) => opacity({ value, barIndex: i, key, target })); } + + getMaxSumm(values: number[]): number { + return values.reduce((prev, curr) => curr > 0 ? prev + curr : prev, 0); + } + + getMinSubtraction(values: number[]): number { + return values.reduce((prev, curr) => curr < 0 ? prev + curr : prev, 0); + } } diff --git a/src/types.ts b/src/types.ts index 1bf9934..9da78b8 100755 --- a/src/types.ts +++ b/src/types.ts @@ -25,7 +25,8 @@ export type BarSerie = Serie & BarSerieAdditionalParams; export type BarAdditionalOptions = { type: { // BarPodType.DISCRETE or BarPodType.NON_DISCRETE. Cant be both [BarPodType.DISCRETE]: { - groupBy: { x: ValueX[] }; // union bars as one group, see examples/demo-group-by.html + // 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 }; }; From 91d5fe35a153f5846eb47b42a72df41c950b65ee Mon Sep 17 00:00:00 2001 From: vargburz Date: Tue, 18 Oct 2022 20:21:23 +0300 Subject: [PATCH 5/5] 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 + } +};