mirror of https://github.com/ghostfolio/ghostfolio
5 changed files with 369 additions and 0 deletions
@ -0,0 +1,12 @@ |
|||
<ngx-skeleton-loader |
|||
*ngIf="isLoading" |
|||
animation="pulse" |
|||
[theme]="{ |
|||
height: '15rem', |
|||
width: '100%' |
|||
}" |
|||
></ngx-skeleton-loader> |
|||
<canvas |
|||
#chartCanvas |
|||
[ngStyle]="{ display: isLoading ? 'none' : 'block' }" |
|||
></canvas> |
@ -0,0 +1,3 @@ |
|||
:host { |
|||
display: block; |
|||
} |
@ -0,0 +1,23 @@ |
|||
import { Meta, Story, moduleMetadata } from '@storybook/angular'; |
|||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
|||
|
|||
import { PortfolioProportionChartComponent } from './portfolio-proportion-chart.component'; |
|||
|
|||
export default { |
|||
title: 'Value', |
|||
component: PortfolioProportionChartComponent, |
|||
decorators: [ |
|||
moduleMetadata({ |
|||
imports: [NgxSkeletonLoaderModule] |
|||
}) |
|||
] |
|||
} as Meta<PortfolioProportionChartComponent>; |
|||
|
|||
const Template: Story<PortfolioProportionChartComponent> = ( |
|||
args: PortfolioProportionChartComponent |
|||
) => ({ |
|||
props: args |
|||
}); |
|||
|
|||
export const Loading = Template.bind({}); |
|||
Loading.args = {}; |
@ -0,0 +1,318 @@ |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
Component, |
|||
Input, |
|||
OnChanges, |
|||
OnDestroy, |
|||
OnInit, |
|||
ViewChild |
|||
} from '@angular/core'; |
|||
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; |
|||
import { getTextColor } from '@ghostfolio/common/helper'; |
|||
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; |
|||
import { Currency } from '@prisma/client'; |
|||
import { Tooltip } from 'chart.js'; |
|||
import { LinearScale } from 'chart.js'; |
|||
import { ArcElement } from 'chart.js'; |
|||
import { DoughnutController } from 'chart.js'; |
|||
import { Chart } from 'chart.js'; |
|||
import ChartDataLabels from 'chartjs-plugin-datalabels'; |
|||
import * as Color from 'color'; |
|||
|
|||
@Component({ |
|||
selector: 'gf-portfolio-proportion-chart', |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
templateUrl: './portfolio-proportion-chart.component.html', |
|||
styleUrls: ['./portfolio-proportion-chart.component.scss'] |
|||
}) |
|||
export class PortfolioProportionChartComponent implements OnChanges, OnDestroy { |
|||
@Input() baseCurrency: Currency; |
|||
@Input() isInPercent = false; |
|||
@Input() keys: string[] = []; |
|||
@Input() locale = ''; |
|||
@Input() maxItems?: number; |
|||
@Input() showLabels = false; |
|||
@Input() positions: { |
|||
[symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number }; |
|||
} = {}; |
|||
|
|||
@ViewChild('chartCanvas') chartCanvas; |
|||
|
|||
public chart: Chart; |
|||
public isLoading = true; |
|||
|
|||
private colorMap: { |
|||
[symbol: string]: string; |
|||
} = { |
|||
[UNKNOWN_KEY]: `rgba(${getTextColor()}, 0.12)` |
|||
}; |
|||
|
|||
public constructor() { |
|||
Chart.register( |
|||
ArcElement, |
|||
ChartDataLabels, |
|||
DoughnutController, |
|||
LinearScale, |
|||
Tooltip |
|||
); |
|||
} |
|||
|
|||
public ngOnChanges() { |
|||
if (this.positions) { |
|||
this.initialize(); |
|||
} |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.chart?.destroy(); |
|||
} |
|||
|
|||
private initialize() { |
|||
this.isLoading = true; |
|||
const chartData: { |
|||
[symbol: string]: { |
|||
color?: string; |
|||
subCategory: { [symbol: string]: { value: number } }; |
|||
value: number; |
|||
}; |
|||
} = {}; |
|||
|
|||
Object.keys(this.positions).forEach((symbol) => { |
|||
if (this.positions[symbol][this.keys[0]]) { |
|||
if (chartData[this.positions[symbol][this.keys[0]]]) { |
|||
chartData[this.positions[symbol][this.keys[0]]].value += |
|||
this.positions[symbol].value; |
|||
|
|||
if ( |
|||
chartData[this.positions[symbol][this.keys[0]]].subCategory[ |
|||
this.positions[symbol][this.keys[1]] |
|||
] |
|||
) { |
|||
chartData[this.positions[symbol][this.keys[0]]].subCategory[ |
|||
this.positions[symbol][this.keys[1]] |
|||
].value += this.positions[symbol].value; |
|||
} else { |
|||
chartData[this.positions[symbol][this.keys[0]]].subCategory[ |
|||
this.positions[symbol][this.keys[1]] ?? UNKNOWN_KEY |
|||
] = { value: this.positions[symbol].value }; |
|||
} |
|||
} else { |
|||
chartData[this.positions[symbol][this.keys[0]]] = { |
|||
subCategory: {}, |
|||
value: this.positions[symbol].value |
|||
}; |
|||
|
|||
if (this.positions[symbol][this.keys[1]]) { |
|||
chartData[this.positions[symbol][this.keys[0]]].subCategory = { |
|||
[this.positions[symbol][this.keys[1]]]: { |
|||
value: this.positions[symbol].value |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
} else { |
|||
if (chartData[UNKNOWN_KEY]) { |
|||
chartData[UNKNOWN_KEY].value += this.positions[symbol].value; |
|||
} else { |
|||
chartData[UNKNOWN_KEY] = { |
|||
subCategory: this.keys[1] |
|||
? { [this.keys[1]]: { value: 0 } } |
|||
: undefined, |
|||
value: this.positions[symbol].value |
|||
}; |
|||
} |
|||
} |
|||
}); |
|||
|
|||
let chartDataSorted = Object.entries(chartData) |
|||
.sort((a, b) => { |
|||
return a[1].value - b[1].value; |
|||
}) |
|||
.reverse(); |
|||
|
|||
if (this.maxItems && chartDataSorted.length > this.maxItems) { |
|||
// Add surplus items to unknown group
|
|||
const rest = chartDataSorted.splice( |
|||
this.maxItems, |
|||
chartDataSorted.length - 1 |
|||
); |
|||
|
|||
let unknownItem = chartDataSorted.find((charDataItem) => { |
|||
return charDataItem[0] === UNKNOWN_KEY; |
|||
}); |
|||
|
|||
if (!unknownItem) { |
|||
const index = chartDataSorted.push([ |
|||
UNKNOWN_KEY, |
|||
{ subCategory: {}, value: 0 } |
|||
]); |
|||
unknownItem = chartDataSorted[index]; |
|||
} |
|||
|
|||
rest.forEach((restItem) => { |
|||
if (unknownItem?.[1]) { |
|||
unknownItem[1] = { |
|||
subCategory: {}, |
|||
value: unknownItem[1].value + restItem[1].value |
|||
}; |
|||
} |
|||
}); |
|||
|
|||
// Sort data again
|
|||
chartDataSorted = chartDataSorted |
|||
.sort((a, b) => { |
|||
return a[1].value - b[1].value; |
|||
}) |
|||
.reverse(); |
|||
} |
|||
|
|||
chartDataSorted.forEach(([symbol, item], index) => { |
|||
if (this.colorMap[symbol]) { |
|||
// Reuse color
|
|||
item.color = this.colorMap[symbol]; |
|||
} else { |
|||
const color = |
|||
this.getColorPalette()[index % this.getColorPalette().length]; |
|||
|
|||
// Store color for reuse
|
|||
this.colorMap[symbol] = color; |
|||
|
|||
item.color = color; |
|||
} |
|||
}); |
|||
|
|||
const backgroundColorSubCategory: string[] = []; |
|||
const dataSubCategory: number[] = []; |
|||
const labelSubCategory: string[] = []; |
|||
|
|||
chartDataSorted.forEach(([, item]) => { |
|||
let lightnessRatio = 0.2; |
|||
|
|||
Object.keys(item.subCategory).forEach((subCategory) => { |
|||
backgroundColorSubCategory.push( |
|||
Color(item.color).lighten(lightnessRatio).hex() |
|||
); |
|||
dataSubCategory.push(item.subCategory[subCategory].value); |
|||
labelSubCategory.push(subCategory); |
|||
|
|||
lightnessRatio += 0.1; |
|||
}); |
|||
}); |
|||
|
|||
const datasets = [ |
|||
{ |
|||
backgroundColor: chartDataSorted.map(([, item]) => { |
|||
return item.color; |
|||
}), |
|||
borderWidth: 0, |
|||
data: chartDataSorted.map(([, item]) => { |
|||
return item.value; |
|||
}) |
|||
} |
|||
]; |
|||
|
|||
let labels = chartDataSorted.map(([label]) => { |
|||
return label; |
|||
}); |
|||
|
|||
if (this.keys[1]) { |
|||
datasets.unshift({ |
|||
backgroundColor: backgroundColorSubCategory, |
|||
borderWidth: 0, |
|||
data: dataSubCategory |
|||
}); |
|||
|
|||
labels = labelSubCategory.concat(labels); |
|||
} |
|||
|
|||
const data = { |
|||
datasets, |
|||
labels |
|||
}; |
|||
|
|||
if (this.chartCanvas) { |
|||
if (this.chart) { |
|||
this.chart.data = data; |
|||
this.chart.update(); |
|||
} else { |
|||
this.chart = new Chart(this.chartCanvas.nativeElement, { |
|||
data, |
|||
options: { |
|||
cutout: '70%', |
|||
layout: { |
|||
padding: this.showLabels === true ? 100 : 0 |
|||
}, |
|||
plugins: { |
|||
datalabels: { |
|||
color: (context) => { |
|||
return this.getColorPalette()[ |
|||
context.dataIndex % this.getColorPalette().length |
|||
]; |
|||
}, |
|||
display: this.showLabels === true ? 'auto' : false, |
|||
labels: { |
|||
index: { |
|||
align: 'end', |
|||
anchor: 'end', |
|||
formatter: (value, context) => { |
|||
return value > 0 |
|||
? context.chart.data.labels?.[context.dataIndex] |
|||
: ''; |
|||
}, |
|||
offset: 8 |
|||
} |
|||
} |
|||
}, |
|||
legend: { display: false }, |
|||
tooltip: { |
|||
callbacks: { |
|||
label: (context) => { |
|||
const labelIndex = |
|||
(data.datasets[context.datasetIndex - 1]?.data?.length ?? |
|||
0) + context.dataIndex; |
|||
const label = context.chart.data.labels?.[labelIndex] ?? ''; |
|||
|
|||
if (this.isInPercent) { |
|||
const value = 100 * <number>context.raw; |
|||
return `${label} (${value.toFixed(2)}%)`; |
|||
} else { |
|||
const value = <number>context.raw; |
|||
return `${label} (${value.toLocaleString(this.locale, { |
|||
maximumFractionDigits: 2, |
|||
minimumFractionDigits: 2 |
|||
})} ${this.baseCurrency})`;
|
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
type: 'doughnut' |
|||
}); |
|||
} |
|||
|
|||
this.isLoading = false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Color palette, inspired by https://yeun.github.io/open-color
|
|||
*/ |
|||
private getColorPalette() { |
|||
//
|
|||
return [ |
|||
'#329af0', // blue 5
|
|||
'#20c997', // teal 5
|
|||
'#94d82d', // lime 5
|
|||
'#ff922b', // orange 5
|
|||
'#f06595', // pink 5
|
|||
'#845ef7', // violet 5
|
|||
'#5c7cfa', // indigo 5
|
|||
'#22b8cf', // cyan 5
|
|||
'#51cf66', // green 5
|
|||
'#fcc419', // yellow 5
|
|||
'#ff6b6b', // red 5
|
|||
'#cc5de8' // grape 5
|
|||
]; |
|||
} |
|||
} |
@ -0,0 +1,13 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { NgModule } from '@angular/core'; |
|||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
|||
|
|||
import { PortfolioProportionChartComponent } from './portfolio-proportion-chart.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [PortfolioProportionChartComponent], |
|||
exports: [PortfolioProportionChartComponent], |
|||
imports: [CommonModule, NgxSkeletonLoaderModule], |
|||
providers: [] |
|||
}) |
|||
export class GfPortfolioProportionChartModule {} |
Loading…
Reference in new issue