diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0ad2fca..989c82104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added a chart to the holdings tab of the home page (experimental) + ## 2.94.0 - 2024-07-09 ### Changed diff --git a/apps/client/src/app/components/home-holdings/home-holdings.component.ts b/apps/client/src/app/components/home-holdings/home-holdings.component.ts index 5141bf9fa..86fd0ce0c 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.component.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.component.ts @@ -1,11 +1,21 @@ import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { PortfolioPosition, User } from '@ghostfolio/common/interfaces'; +import { + PortfolioPosition, + UniqueAsset, + User +} from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { HoldingType, ToggleOption } from '@ghostfolio/common/types'; +import { + HoldingType, + HoldingViewMode, + ToggleOption +} from '@ghostfolio/common/types'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Router } from '@angular/router'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -26,6 +36,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { { label: $localize`Closed`, value: 'CLOSED' } ]; public user: User; + public viewModeFormControl = new FormControl('TABLE'); private unsubscribeSubject = new Subject(); @@ -34,6 +45,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { private dataService: DataService, private deviceService: DeviceDetectorService, private impersonationStorageService: ImpersonationStorageService, + private router: Router, private userService: UserService ) {} @@ -76,6 +88,13 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { public onChangeHoldingType(aHoldingType: HoldingType) { this.holdingType = aHoldingType; + if (this.holdingType === 'ACTIVE') { + this.viewModeFormControl.enable(); + } else if (this.holdingType === 'CLOSED') { + this.viewModeFormControl.disable(); + this.viewModeFormControl.setValue('TABLE'); + } + this.holdings = undefined; this.fetchHoldings() @@ -87,6 +106,14 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { }); } + public onSymbolClicked({ dataSource, symbol }: UniqueAsset) { + if (dataSource && symbol) { + this.router.navigate([], { + queryParams: { dataSource, symbol, holdingDetailDialog: true } + }); + } + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); diff --git a/apps/client/src/app/components/home-holdings/home-holdings.html b/apps/client/src/app/components/home-holdings/home-holdings.html index a2bd43636..be053415f 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.html +++ b/apps/client/src/app/components/home-holdings/home-holdings.html @@ -6,32 +6,60 @@
-
- -
- - @if (hasPermissionToCreateOrder && holdings?.length > 0) { -
- Manage Activities +
+ @if (user?.settings?.isExperimentalFeatures) { +
+
+ + + + + + + + +
+
+ } +
+
+
+ @if (viewModeFormControl.value === 'CHART') { + + } @else if (viewModeFormControl.value === 'TABLE') { + + @if (hasPermissionToCreateOrder && holdings?.length > 0) { + + } }
diff --git a/apps/client/src/app/components/home-holdings/home-holdings.module.ts b/apps/client/src/app/components/home-holdings/home-holdings.module.ts index f10adeab2..df951c1a8 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.module.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.module.ts @@ -1,9 +1,12 @@ import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; +import { GfTreemapChartComponent } from '@ghostfolio/ui/treemap-chart'; import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { RouterModule } from '@angular/router'; import { HomeHoldingsComponent } from './home-holdings.component'; @@ -12,9 +15,13 @@ import { HomeHoldingsComponent } from './home-holdings.component'; declarations: [HomeHoldingsComponent], imports: [ CommonModule, + FormsModule, GfHoldingsTableComponent, GfToggleModule, + GfTreemapChartComponent, MatButtonModule, + MatButtonToggleModule, + ReactiveFormsModule, RouterModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA] diff --git a/libs/common/src/lib/types/holding-view-mode.type.ts b/libs/common/src/lib/types/holding-view-mode.type.ts new file mode 100644 index 000000000..50a4e2b29 --- /dev/null +++ b/libs/common/src/lib/types/holding-view-mode.type.ts @@ -0,0 +1 @@ +export type HoldingViewMode = 'CHART' | 'TABLE'; diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index fc4ddc4bf..65fdfe5f0 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -8,6 +8,7 @@ import type { DateRange } from './date-range.type'; import type { Granularity } from './granularity.type'; import type { GroupBy } from './group-by.type'; import type { HoldingType } from './holding-type.type'; +import type { HoldingViewMode } from './holding-view-mode.type'; import type { MarketAdvanced } from './market-advanced.type'; import type { MarketDataPreset } from './market-data-preset.type'; import type { MarketState } from './market-state.type'; @@ -30,6 +31,7 @@ export type { Granularity, GroupBy, HoldingType, + HoldingViewMode, Market, MarketAdvanced, MarketDataPreset, 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 index aa0a6cacd..c60ed3443 100644 --- 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 @@ -354,7 +354,7 @@ export class GfPortfolioProportionChartComponent * Color palette, inspired by https://yeun.github.io/open-color */ private getColorPalette() { - // + // TODO: Reuse require('open-color') return [ '#329af0', // blue 5 '#20c997', // teal 5 diff --git a/libs/ui/src/lib/treemap-chart/index.ts b/libs/ui/src/lib/treemap-chart/index.ts new file mode 100644 index 000000000..62f54ac11 --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/index.ts @@ -0,0 +1 @@ +export * from './treemap-chart.component'; diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.html b/libs/ui/src/lib/treemap-chart/treemap-chart.component.html new file mode 100644 index 000000000..c7de5ef4d --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.html @@ -0,0 +1,13 @@ +@if (isLoading) { + +} + 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..557bdc2ae --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -0,0 +1,168 @@ +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 { orderBy } from 'lodash'; +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() cursor: string; + @Input() holdings: PortfolioPosition[]; + + @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: 14 }, { size: 11 }, { lineHeight: 2, size: 14 }], + 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 datasetIndex = activeElements[0].datasetIndex; + + const dataset = orderBy( + event.chart.data.datasets[datasetIndex].tree, + ['allocationInPercentage'], + ['desc'] + ); + + const dataSource: DataSource = dataset[dataIndex].dataSource; + const symbol: string = dataset[dataIndex].symbol; + + this.treemapChartClicked.emit({ dataSource, symbol }); + } catch {} + }, + onHover: (event, chartElement) => { + if (this.cursor) { + event.native.target.style.cursor = chartElement[0] + ? this.cursor + : 'default'; + } + }, + plugins: { + tooltip: { + enabled: false + } + } + }, + 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"