Browse Source
Bar pod: discrete and non-discrete types See merge request chartwerk/bar-pod!11pull/1/head
Alexander Velikiy
2 years ago
13 changed files with 882 additions and 420 deletions
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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)); |
||||
} |
||||
} |
@ -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)); |
||||
} |
||||
} |
@ -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]; |
||||
} |
@ -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`); |
||||
} |
||||
}); |
||||
} |
||||
} |
@ -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…
Reference in new issue