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
+ }
+};