Browse Source

Merge branch 'bar-pod-refactoring' into 'main'

Bar pod: discrete and non-discrete types

See merge request chartwerk/bar-pod!11
pull/1/head
Alexander Velikiy 2 years ago
parent
commit
22809f9a01
  1. 61
      examples/demo.html
  2. 55
      examples/demo_annotation.html
  3. 57
      examples/demo_discrete.html
  4. 57
      examples/demo_formatters.html
  5. 56
      examples/demo_non_discrete.html
  6. 365
      src/index.ts
  7. 69
      src/models/bar_annotation.ts
  8. 183
      src/models/bar_group.ts
  9. 99
      src/models/bar_options.ts
  10. 24
      src/models/bar_series.ts
  11. 36
      src/models/bar_state.ts
  12. 139
      src/models/data_processor.ts
  13. 101
      src/types.ts

61
examples/demo.html

@ -1,61 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
<script src="../dist/index.dev.js" type="text/javascript"></script>
</head>
<body>
<div id="chart" style="width: 50%; height: 500px;"></div>
<script type="text/javascript">
var pod = new ChartwerkBarPod(
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' },
],
{
usePanning: false,
axis: {
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,
maxBarWidth: 20,
minBarWidth: 4,
zoomEvents: {
scroll: { zoom: { isActive: false }, pan: { isActive: false } },
},
annotations: [
{ key: 'm-1', color: 'red' },
{ key: 'm-2', color: 'green' }
],
eventsCallbacks: {
zoomIn: (range) => { console.log('range', range) }
},
renderLegend: false,
}
);
console.time('render');
pod.render();
console.timeEnd('render');
function getData() {
return [
[100, 15], [200, 20], [300, 10],
];
}
</script>
</body>
</html>
<style>
.overlay {
fill: black;
}
</style>

55
examples/demo_annotation.html

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
<script src="../dist/index.dev.js" type="text/javascript"></script>
</head>
<body>
<div id="chart" style="width: 50%; height: 500px;"></div>
<script type="text/javascript">
var pod = new ChartwerkBarPod(
document.getElementById('chart'),
[
{ target: 'serie1', datapoints: [[100, 10, 20, 5], [180, 15, 30, 5], [300, 20, 35, 15]], color: ['red', 'pink', 'blue'], annotation: { enable: true, color: 'green'} },
{ target: 'serie2', datapoints: [[150, 10, 10], [220, 20, 15], [330, 30, 5]], color: ['green', 'yellow'], annotation: { enable: true, color: 'black' } },
],
{
usePanning: false,
axis: {
x: { format: 'custom', invert: false, valueFormatter: (value) => { return 'L' + value; }, ticksCount: 6, range: [50, 380] },
y: { format: 'custom', invert: false, valueFormatter: (value) => { return value + '%'; }, ticksCount: 8 }
},
zoomEvents: {
scroll: { zoom: { isActive: false }, pan: { isActive: false } },
},
eventsCallbacks: {
zoomIn: (range) => { console.log('range', range) }
},
renderLegend: false,
type: {
['non-discrete']: {
enable: true,
barWidth: {
estimated: { value: 35, type: 'unit' },
max: 30,
min: 10,
}
}
}
}
);
console.time('render');
pod.render();
console.timeEnd('render');
</script>
</body>
</html>
<style>
.overlay {
fill: none;
}
</style>

57
examples/demo_discrete.html

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
<script src="../dist/index.dev.js" type="text/javascript"></script>
</head>
<body>
<div id="chart" style="width: 50%; height: 500px;"></div>
<script type="text/javascript">
var pod = new ChartwerkBarPod(
document.getElementById('chart'),
[
{ target: 'serie1', datapoints: [[100, 10, 20, 5], [200, 15, 30, 5], [300, 20, 35, 15]], color: ['red', 'pink', 'blue'] },
{ target: 'serie2', datapoints: [[100, 10, 10], [200, 20, 15], [300, 30, 5]], color: ['green', 'yellow'] },
{ target: 'serie3', datapoints: [[100, -10, 5], [200, -5, -5], [300, -20, 15]], color: ['red', 'pink', 'blue'] },
{ target: 'serie4', datapoints: [[100, 10, 10], [200, 20, 15], [300, 30, 5]], color: ['green', 'yellow'] },
],
{
usePanning: false,
axis: {
x: { format: 'custom', invert: false, valueFormatter: (value) => { return 'L' + value; }, ticksCount: 6 },
y: { format: 'custom', invert: false, valueFormatter: (value) => { return value + '%'; }, ticksCount: 8 }
},
zoomEvents: {
scroll: { zoom: { isActive: false }, pan: { isActive: false } },
},
eventsCallbacks: {
zoomIn: (range) => { console.log('range', range) }
},
renderLegend: false,
// discrete type config
type: {
discrete: {
enable: true,
step: 100,
groupSize: {
width: { value: 60, type: 'percent', }
}
}
}
}
);
console.time('render');
pod.render();
console.timeEnd('render');
</script>
</body>
</html>
<style>
.overlay {
fill: none;
}
</style>

57
examples/demo_formatters.html

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
<script src="../dist/index.dev.js" type="text/javascript"></script>
</head>
<body>
<div id="chart" style="width: 50%; height: 500px;"></div>
<script type="text/javascript">
// use formatters for color and opacity
const colorFormatter = (event) => (event.value > 50 ? 'red' : 'green');
const opacityFormatter = (event) => (event.key >= 200 ? 0.5 : 1);
var pod = new ChartwerkBarPod(
document.getElementById('chart'),
[
{ target: 'serie1', datapoints: [[0, 50], [100, 70], [200, 40], [300, 90]], color: colorFormatter, opacity: opacityFormatter },
],
{
usePanning: false,
axis: {
x: { format: 'custom', invert: false, valueFormatter: (value) => { return 'L' + value; }, ticksCount: 6 },
y: { format: 'custom', invert: false, valueFormatter: (value) => { return value + '%'; }, ticksCount: 8 }
},
zoomEvents: {
scroll: { zoom: { isActive: false }, pan: { isActive: false } },
},
eventsCallbacks: {
zoomIn: (range) => { console.log('range', range) }
},
renderLegend: false,
type: {
discrete: {
enable: true,
step: 100,
groupSize: {
width: { value: 60, type: 'percent', }
}
}
}
}
);
console.time('render');
pod.render();
console.timeEnd('render');
</script>
</body>
</html>
<style>
.overlay {
fill: none;
}
</style>

56
examples/demo_non_discrete.html

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
<script src="../dist/index.dev.js" type="text/javascript"></script>
</head>
<body>
<div id="chart" style="width: 50%; height: 500px;"></div>
<script type="text/javascript">
var pod = new ChartwerkBarPod(
document.getElementById('chart'),
[
{ target: 'serie1', datapoints: [[100, 10, 20, 5], [180, 15, 30, 5], [300, 20, 35, 15]], color: ['red', 'pink', 'blue'] },
{ target: 'serie2', datapoints: [[150, 10, 10], [220, 20, 15], [330, 30, 5]], color: ['green', 'yellow'] },
],
{
usePanning: false,
axis: {
x: { format: 'custom', invert: false, valueFormatter: (value) => { return 'L' + value; }, ticksCount: 6, range: [50, 380] },
y: { format: 'custom', invert: false, valueFormatter: (value) => { return value + '%'; }, ticksCount: 8 }
},
zoomEvents: {
scroll: { zoom: { isActive: false }, pan: { isActive: false } },
},
eventsCallbacks: {
zoomIn: (range) => { console.log('range', range) }
},
renderLegend: false,
// non_discrete type config
type: {
['non-discrete']: {
enable: true,
barWidth: {
estimated: { value: 35, type: 'unit' },
max: 30,
min: 10,
}
}
}
}
);
console.time('render');
pod.render();
console.timeEnd('render');
</script>
</body>
</html>
<style>
.overlay {
fill: none;
}
</style>

365
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<BarSerie, BarOptions> {
barYScale: null | d3.ScaleLinear<number, number> = null;
_seriesDataForRendring = [];
dataProcessor: DataProcessor;
series: BarSeries;
options: BarConfig;
@ -20,6 +29,10 @@ export class ChartwerkBarPod extends ChartwerkPod<BarSerie, 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 {
@ -27,188 +40,33 @@ export class ChartwerkBarPod extends ChartwerkPod<BarSerie, BarOptions> {
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<BarSerie, BarOptions> {
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<BarSerie, BarOptions> {
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<number, number> {
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<number[]>(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,
};

69
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<SVGRectElement, unknown, null, undefined>, // overlay from core. It should be global
protected groupContainer: d3.Selection<SVGGElement, unknown, null, undefined>, // 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));
}
}

183
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<SVGRectElement, unknown, null, undefined>, // overlay from core. It should be global
protected container: d3.Selection<SVGGElement, unknown, null, undefined>,
protected dataRow: DataRow,
protected options: BarConfig,
protected state: PodState<BarSerie, BarOptions>,
protected boxParams: { width: number, height: number, zeroHorizon: number },
protected allDataLength: number,
) {
this.setGroupWidth();
this.setBarWidth();
this.renderBarGroup();
}
protected _groupContainer: d3.Selection<SVGGElement, unknown, null, undefined>;
protected _groupWidth: number;
protected _barWidth: number;
public getGroupContainer(): d3.Selection<SVGGElement, unknown, null, undefined> {
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));
}
}

99
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<BarOptions> {
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<BarOptions> {
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}`);
}
}
}

24
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<BarSerie> {
@ -12,4 +25,9 @@ export class BarSeries extends CoreSeries<BarSerie> {
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);
}
}

36
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<BarSerie, BarOptions>, 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<BarSerie, BarOptions>, 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];
}

139
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`);
}
});
}
}

101
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<BarSerieParams>;
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<BarAdditionalOptions>;
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
}
};

Loading…
Cancel
Save