+
+
+}
+
diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.scss b/libs/ui/src/lib/treemap-chart/treemap-chart.component.scss
new file mode 100644
index 000000000..d041372c8
--- /dev/null
+++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.scss
@@ -0,0 +1,4 @@
+:host {
+ aspect-ratio: 16 / 9;
+ display: block;
+}
diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
new file mode 100644
index 000000000..d94967c44
--- /dev/null
+++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
@@ -0,0 +1,160 @@
+import { getLocale } from '@ghostfolio/common/helper';
+import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
+
+import { CommonModule } from '@angular/common';
+import {
+ AfterViewInit,
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ EventEmitter,
+ Input,
+ OnChanges,
+ OnDestroy,
+ Output,
+ ViewChild
+} from '@angular/core';
+import { DataSource } from '@prisma/client';
+import { ChartConfiguration } from 'chart.js';
+import { LinearScale } from 'chart.js';
+import { Chart } from 'chart.js';
+import { TreemapController, TreemapElement } from 'chartjs-chart-treemap';
+import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
+
+const { gray, green, red } = require('open-color');
+
+@Component({
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [CommonModule, NgxSkeletonLoaderModule],
+ selector: 'gf-treemap-chart',
+ standalone: true,
+ styleUrls: ['./treemap-chart.component.scss'],
+ templateUrl: './treemap-chart.component.html'
+})
+export class GfTreemapChartComponent
+ implements AfterViewInit, OnChanges, OnDestroy
+{
+ @Input() baseCurrency: string;
+ @Input() cursor: string;
+ @Input() holdings: PortfolioPosition[];
+ @Input() isInPercent = false;
+ @Input() locale = getLocale();
+
+ @Output() treemapChartClicked = new EventEmitter
();
+
+ @ViewChild('chartCanvas') chartCanvas: ElementRef;
+
+ public chart: Chart<'treemap'>;
+ public isLoading = true;
+
+ public constructor() {
+ Chart.register(LinearScale, TreemapController, TreemapElement);
+ }
+
+ public ngAfterViewInit() {
+ if (this.holdings) {
+ this.initialize();
+ }
+ }
+
+ public ngOnChanges() {
+ if (this.holdings) {
+ this.initialize();
+ }
+ }
+
+ public ngOnDestroy() {
+ this.chart?.destroy();
+ }
+
+ private initialize() {
+ this.isLoading = true;
+
+ const data: ChartConfiguration['data'] = {
+ datasets: [
+ {
+ backgroundColor(ctx) {
+ const netPerformancePercentWithCurrencyEffect =
+ ctx.raw._data.netPerformancePercentWithCurrencyEffect;
+
+ if (netPerformancePercentWithCurrencyEffect > 0.03) {
+ return green[9];
+ } else if (netPerformancePercentWithCurrencyEffect > 0.02) {
+ return green[7];
+ } else if (netPerformancePercentWithCurrencyEffect > 0.01) {
+ return green[5];
+ } else if (netPerformancePercentWithCurrencyEffect > 0) {
+ return green[3];
+ } else if (netPerformancePercentWithCurrencyEffect === 0) {
+ return gray[3];
+ } else if (netPerformancePercentWithCurrencyEffect > -0.01) {
+ return red[3];
+ } else if (netPerformancePercentWithCurrencyEffect > -0.02) {
+ return red[5];
+ } else if (netPerformancePercentWithCurrencyEffect > -0.03) {
+ return red[7];
+ } else {
+ return red[9];
+ }
+ },
+ key: 'allocationInPercentage',
+ labels: {
+ align: 'left',
+ color: ['white'],
+ display: true,
+ font: [{ size: 16, weight: 'bold' }, { size: 12 }],
+ formatter(ctx) {
+ const netPerformancePercentWithCurrencyEffect =
+ ctx.raw._data.netPerformancePercentWithCurrencyEffect;
+
+ return [
+ ctx.raw._data.name,
+ ctx.raw._data.symbol,
+ `${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(ctx.raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`
+ ];
+ },
+ position: 'top'
+ },
+ spacing: 1,
+ tree: this.holdings
+ }
+ ]
+ };
+
+ if (this.chartCanvas) {
+ if (this.chart) {
+ this.chart.data = data;
+ this.chart.update();
+ } else {
+ this.chart = new Chart(this.chartCanvas.nativeElement, {
+ data,
+ options: {
+ animation: false,
+ onClick: (event, activeElements) => {
+ try {
+ const dataIndex = activeElements[0].index;
+
+ const dataSource: DataSource =
+ event.chart.data.datasets[0].tree[dataIndex].dataSource;
+ const symbol: string =
+ event.chart.data.datasets[0].tree[dataIndex].symbol;
+
+ this.treemapChartClicked.emit({ dataSource, symbol });
+ } catch {}
+ },
+ onHover: (event, chartElement) => {
+ if (this.cursor) {
+ event.native.target.style.cursor = chartElement[0]
+ ? this.cursor
+ : 'default';
+ }
+ }
+ },
+ type: 'treemap'
+ });
+ }
+ }
+
+ this.isLoading = false;
+ }
+}
diff --git a/package.json b/package.json
index fca0a9a0f..49b43b303 100644
--- a/package.json
+++ b/package.json
@@ -97,6 +97,7 @@
"cache-manager-redis-store": "2.0.0",
"chart.js": "4.2.0",
"chartjs-adapter-date-fns": "3.0.0",
+ "chartjs-chart-treemap": "2.3.1",
"chartjs-plugin-annotation": "2.1.2",
"chartjs-plugin-datalabels": "2.2.0",
"cheerio": "1.0.0-rc.12",
@@ -122,6 +123,7 @@
"ngx-markdown": "18.0.0",
"ngx-skeleton-loader": "7.0.0",
"ngx-stripe": "18.0.0",
+ "open-color": "1.9.1",
"papaparse": "5.3.1",
"passport": "0.7.0",
"passport-google-oauth20": "2.0.0",
diff --git a/yarn.lock b/yarn.lock
index 8d6caaef2..e807877b5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10071,6 +10071,11 @@ chartjs-adapter-date-fns@3.0.0:
resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz#c25f63c7f317c1f96f9a7c44bd45eeedb8a478e5"
integrity sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==
+chartjs-chart-treemap@2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/chartjs-chart-treemap/-/chartjs-chart-treemap-2.3.1.tgz#b0d27309ee373cb7706cabb262c48c53ffacf710"
+ integrity sha512-GW+iODLICIJhNZtHbTtaOjCwRIxmXcquXRKDFMsrkXyqyDeSN1aiVfzNNj6Xjy55soopqRA+YfHqjT2S2zF7lQ==
+
chartjs-plugin-annotation@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/chartjs-plugin-annotation/-/chartjs-plugin-annotation-2.1.2.tgz#8c307c931fda735a1acf1b606ad0e3fd7d96299b"
@@ -16757,6 +16762,11 @@ onetime@^5.1.0, onetime@^5.1.2:
dependencies:
mimic-fn "^2.1.0"
+open-color@1.9.1:
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/open-color/-/open-color-1.9.1.tgz#a6e6328f60eff7aa60e3e8fcfa50f53ff3eece35"
+ integrity sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==
+
open@8.4.2, open@^8.0.4, open@^8.0.9, open@^8.4.0:
version "8.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"