diff --git a/examples/demo.html b/examples/demo.html deleted file mode 100755 index 7ec1ac9..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 67cbe0a..9dfa768 100755 --- a/src/index.ts +++ b/src/index.ts @@ -2,17 +2,26 @@ 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, 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, RowValues } 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; - _seriesDataForRendring = []; + dataProcessor: DataProcessor; series: BarSeries; options: BarConfig; @@ -20,6 +29,10 @@ export class ChartwerkBarPod extends ChartwerkPod { 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 { @@ -27,188 +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 lastRect = _.last(container.selectAll('rect')?.nodes()); - const annotation = _.find(this.options.barOptions.annotations, a => a.key === key); - if(!lastRect || !key || !annotation) { - return; - } - - const rectSelection = d3.select(lastRect); - // render triangle - container.append('path') - .attr('d', () => { - const x = Math.ceil(_.toNumber(rectSelection.attr('x'))); - const y = Math.ceil(_.toNumber(rectSelection.attr('y'))); - const options = { max: this.options.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(); }); - }); - } - - redirectEventToOverlay(): void { - 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; - } - 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 { @@ -231,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 { @@ -264,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) @@ -403,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 new file mode 100644 index 0000000..a6d12ed --- /dev/null +++ b/src/models/bar_annotation.ts @@ -0,0 +1,69 @@ +import * as d3 from 'd3'; +import * as _ from 'lodash'; + + +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; + 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); + + 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)); + } +} 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 new file mode 100644 index 0000000..629c06c --- /dev/null +++ b/src/models/data_processor.ts @@ -0,0 +1,139 @@ +import { + Datapoint, Color, ValueX, Opacity, + BarPodType, BarSerie +} from '../types'; + +import { BarConfig } from './bar_options'; + +import * as _ from 'lodash'; + +export type DataRow = { + key: number; // x + 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[] = []; + + 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[] { + return this._formattedDataRows; + } + + protected setDataRowsForDiscreteType(): void { + 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.visibleSeries) { + const rows = _.map(serie.datapoints, (datapoint: Datapoint) => { + const values = _.tail(datapoint); + return { + key: datapoint[0], // x + items: [ + { + 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: this.getMaxSumm(values), // max y axis scale + minSubtraction: this.getMinSubtraction(values), // min y axis scale + } + }); + 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] || DEFAULT_BAR_COLOR); + } + 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: Opacity | undefined, key: ValueX, target: string): number[] { + if(opacity === undefined) { + return _.map(values, value => DEFAULT_OPACITY); + } + if(_.isNumber(opacity)) { + return _.map(values, value => opacity); + } + 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 { + 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); + } + + 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 f6903e6..54f9b5d 100755 --- a/src/types.ts +++ b/src/types.ts @@ -1,34 +1,83 @@ import { Serie, Options } from '@chartwerk/core'; +import { DataRow } from 'models/data_processor'; -export type BarSerieParams = { - matchedKey: string; - colorFormatter: (serie: BarSerie) => string; +export type ValueX = (number | string); +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?: { + // 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; + 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 BarSerie = Serie & Partial; + 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, - }[]; - eventsCallbacks?: { - contextMenu?: (data: any) => void; + type: { // BarPodType.DISCRETE or BarPodType.NON_DISCRETE. Cant be both + [BarPodType.DISCRETE]: DiscreteConfig; + [BarPodType.NON_DISCRETE]: NonDiscreteConfig; } + eventsCallbacks?: { + contextMenu?: (data: CallbackEvent) => 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 } + +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 + } +};