diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.html b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.html
new file mode 100644
index 000000000..8e86acd33
--- /dev/null
+++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.html
@@ -0,0 +1,12 @@
+
+
diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.scss b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.scss
new file mode 100644
index 000000000..5d4e87f30
--- /dev/null
+++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.scss
@@ -0,0 +1,3 @@
+:host {
+ display: block;
+}
diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.stories.ts b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.stories.ts
new file mode 100644
index 000000000..3ed928745
--- /dev/null
+++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.stories.ts
@@ -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;
+
+const Template: Story = (
+ args: PortfolioProportionChartComponent
+) => ({
+ props: args
+});
+
+export const Loading = Template.bind({});
+Loading.args = {};
diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
new file mode 100644
index 000000000..261c97d08
--- /dev/null
+++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
@@ -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 & { 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 * context.raw;
+ return `${label} (${value.toFixed(2)}%)`;
+ } else {
+ const value = 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
+ ];
+ }
+}
diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.module.ts b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.module.ts
new file mode 100644
index 000000000..9472b1cfa
--- /dev/null
+++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.module.ts
@@ -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 {}