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 { Serie, Options } from '@chartwerk/core'; |
||||||
|
import { DataRow } from 'models/data_processor'; |
||||||
|
|
||||||
export type BarSerieParams = { |
export type ValueX = (number | string); |
||||||
matchedKey: string; |
export type ValueY = number; |
||||||
colorFormatter: (serie: BarSerie) => string; |
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 = { |
export type BarAdditionalOptions = { |
||||||
renderBarLabels?: boolean; |
type: { // BarPodType.DISCRETE or BarPodType.NON_DISCRETE. Cant be both
|
||||||
stacked?: boolean; |
[BarPodType.DISCRETE]: DiscreteConfig; |
||||||
barWidth?: number; // width in x axis unit
|
[BarPodType.NON_DISCRETE]: NonDiscreteConfig; |
||||||
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; |
|
||||||
} |
} |
||||||
|
eventsCallbacks?: { |
||||||
|
contextMenu?: (data: CallbackEvent) => void; |
||||||
|
}; |
||||||
} |
} |
||||||
export type BarOptions = Options & Partial<BarAdditionalOptions>; |
export type BarOptions = Options & Partial<BarAdditionalOptions>; |
||||||
export type RowValues = { |
|
||||||
key: number, |
export enum SizeType { |
||||||
values: number[], |
UNIT = 'unit', // in units of X or Y values
|
||||||
additionalValues: (null | number)[], // values in datapoints third column
|
PX = 'px', |
||||||
colors: (string | ((data: any) => string))[], |
PERCENT = 'percent', // from 0 to 100
|
||||||
serieTarget: string[], |
} |
||||||
|
|
||||||
|
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