Feature/add story for portfolio proportion chart component (#384)
* Add story * Use new component * Update changelog
This commit is contained in:
@@ -1,12 +0,0 @@
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
[theme]="{
|
||||
height: '15rem',
|
||||
width: '100%'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
<canvas
|
||||
#chartCanvas
|
||||
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
|
||||
></canvas>
|
@@ -1,3 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@@ -1,322 +0,0 @@
|
||||
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, OnInit
|
||||
{
|
||||
@Input() baseCurrency: Currency;
|
||||
@Input() isInPercent: boolean;
|
||||
@Input() keys: string[];
|
||||
@Input() locale: string;
|
||||
@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 ngOnInit() {}
|
||||
|
||||
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
|
||||
];
|
||||
}
|
||||
}
|
@@ -1,13 +0,0 @@
|
||||
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 {}
|
@@ -1,10 +1,10 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/client/components/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
|
||||
import { AllocationsPageRoutingModule } from './allocations-page-routing.module';
|
||||
import { AllocationsPageComponent } from './allocations-page.component';
|
||||
|
Reference in New Issue
Block a user