Feature/setup treemap chart for holdings (#3560)
* Setup treemap chart * Update changelog
This commit is contained in:
parent
890c5b986c
commit
4063c62a17
@ -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
|
||||
|
@ -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<HoldingViewMode>('TABLE');
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -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();
|
||||
|
@ -6,32 +6,60 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-toggle
|
||||
class="d-none d-lg-block"
|
||||
[defaultValue]="holdingType"
|
||||
[isLoading]="false"
|
||||
[options]="holdingTypeOptions"
|
||||
(change)="onChangeHoldingType($event.value)"
|
||||
/>
|
||||
</div>
|
||||
<gf-holdings-table
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
|
||||
[holdings]="holdings"
|
||||
[locale]="user?.settings?.locale"
|
||||
/>
|
||||
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
|
||||
<div class="text-center">
|
||||
<a
|
||||
class="mt-3"
|
||||
i18n
|
||||
mat-stroked-button
|
||||
[routerLink]="['/portfolio', 'activities']"
|
||||
>Manage Activities</a
|
||||
>
|
||||
<div class="d-flex">
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<div class="d-flex">
|
||||
<div class="d-none d-lg-block">
|
||||
<mat-button-toggle-group
|
||||
[formControl]="viewModeFormControl"
|
||||
[hideSingleSelectionIndicator]="true"
|
||||
>
|
||||
<mat-button-toggle value="TABLE">
|
||||
<ion-icon name="reorder-four-outline" />
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle value="CHART">
|
||||
<ion-icon name="grid-outline" />
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="align-items-center d-flex flex-grow-1 justify-content-end">
|
||||
<gf-toggle
|
||||
class="d-none d-lg-block"
|
||||
[defaultValue]="holdingType"
|
||||
[isLoading]="false"
|
||||
[options]="holdingTypeOptions"
|
||||
(change)="onChangeHoldingType($event.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@if (viewModeFormControl.value === 'CHART') {
|
||||
<gf-treemap-chart
|
||||
class="mt-3"
|
||||
cursor="pointer"
|
||||
[holdings]="holdings"
|
||||
(treemapChartClicked)="onSymbolClicked($event)"
|
||||
/>
|
||||
} @else if (viewModeFormControl.value === 'TABLE') {
|
||||
<gf-holdings-table
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
|
||||
[holdings]="holdings"
|
||||
[locale]="user?.settings?.locale"
|
||||
/>
|
||||
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
|
||||
<div class="text-center">
|
||||
<a
|
||||
class="mt-3"
|
||||
i18n
|
||||
mat-stroked-button
|
||||
[routerLink]="['/portfolio', 'activities']"
|
||||
>Manage Activities</a
|
||||
>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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]
|
||||
|
1
libs/common/src/lib/types/holding-view-mode.type.ts
Normal file
1
libs/common/src/lib/types/holding-view-mode.type.ts
Normal file
@ -0,0 +1 @@
|
||||
export type HoldingViewMode = 'CHART' | 'TABLE';
|
@ -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,
|
||||
|
@ -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
|
||||
|
1
libs/ui/src/lib/treemap-chart/index.ts
Normal file
1
libs/ui/src/lib/treemap-chart/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './treemap-chart.component';
|
13
libs/ui/src/lib/treemap-chart/treemap-chart.component.html
Normal file
13
libs/ui/src/lib/treemap-chart/treemap-chart.component.html
Normal file
@ -0,0 +1,13 @@
|
||||
@if (isLoading) {
|
||||
<ngx-skeleton-loader
|
||||
animation="pulse"
|
||||
class="h-100"
|
||||
[theme]="{
|
||||
height: '100%'
|
||||
}"
|
||||
/>
|
||||
}
|
||||
<canvas
|
||||
#chartCanvas
|
||||
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
|
||||
></canvas>
|
@ -0,0 +1,4 @@
|
||||
:host {
|
||||
aspect-ratio: 16 / 9;
|
||||
display: block;
|
||||
}
|
168
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
Normal file
168
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
Normal file
@ -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<UniqueAsset>();
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
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'] = <any>{
|
||||
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: <unknown>{
|
||||
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;
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
10
yarn.lock
10
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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user