Browse Source

Merge branch 'main' into pr/3393

pull/3393/head
Thomas Kaul 1 year ago
parent
commit
dd136b2fb3
  1. 19
      CHANGELOG.md
  2. 4
      apps/api/src/app/account/account.service.ts
  3. 32
      apps/api/src/app/order/order.service.ts
  4. 11
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  5. 1
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  6. 1
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts
  7. 1
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts
  8. 1
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  9. 1
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts
  10. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts
  11. 1
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts
  12. 1
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  13. 1
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts
  14. 4
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  15. 1
      apps/api/src/app/user/user.service.ts
  16. 69
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  17. 30
      apps/client/src/app/components/home-holdings/home-holdings.html
  18. 7
      apps/client/src/app/components/home-holdings/home-holdings.module.ts
  19. 6
      apps/client/src/app/components/home-holdings/home-holdings.scss
  20. 2
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  21. 12
      apps/client/src/assets/site.webmanifest
  22. 4
      libs/common/src/lib/models/timeline-position.ts
  23. 9
      libs/common/src/lib/permissions.ts
  24. 12
      libs/common/src/lib/personal-finance-tools.ts
  25. 1
      libs/common/src/lib/types/holding-view-mode.type.ts
  26. 2
      libs/common/src/lib/types/index.ts
  27. 2
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  28. 1
      libs/ui/src/lib/treemap-chart/index.ts
  29. 13
      libs/ui/src/lib/treemap-chart/treemap-chart.component.html
  30. 4
      libs/ui/src/lib/treemap-chart/treemap-chart.component.scss
  31. 168
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
  32. 4
      package.json
  33. 10
      yarn.lock

19
CHANGELOG.md

@ -5,6 +5,25 @@ 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).
## 2.96.0 - 2024-07-13
### Changed
- Improved the chart of the holdings tab on the home page (experimental)
- Separated the icon purposes in the `site.webmanifest`
### Fixed
- Fixed an issue in the portfolio summary with the currency conversion of fees
- Fixed an issue in the the search for a holding
- Removed the show condition of the experimental features setting in the user settings
## 2.95.0 - 2024-07-12
### Added
- Added a chart to the holdings tab of the home page (experimental)
## 2.94.0 - 2024-07-09
### Changed

4
apps/api/src/app/account/account.service.ts

@ -174,8 +174,8 @@ export class AccountService {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
} = groupBy(filters, ({ type }) => {
return type;
});
if (filtersByAccount?.length > 0) {

32
apps/api/src/app/order/order.service.ts

@ -313,10 +313,14 @@ export class OrderService {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
} = groupBy(filters, ({ type }) => {
return type;
});
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
@ -358,6 +362,30 @@ export class OrderService {
};
}
if (searchQuery) {
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
if (where.SymbolProfile) {
where.SymbolProfile = {
AND: [
where.SymbolProfile,
{
OR: searchQueryWhereInput
}
]
};
} else {
where.SymbolProfile = {
OR: searchQueryWhereInput
};
}
}
if (filtersByTag?.length > 0) {
where.tags = {
some: {

11
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -368,6 +368,12 @@ export abstract class PortfolioCalculator {
} = {};
for (const item of lastTransactionPoint.items) {
const feeInBaseCurrency = item.fee.mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
lastTransactionPoint.date
]
);
const marketPriceInBaseCurrency = (
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
).mul(
@ -431,10 +437,11 @@ export abstract class PortfolioCalculator {
};
positions.push({
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
feeInBaseCurrency,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
averagePrice: item.averagePrice,
currency: item.currency,
dataSource: item.dataSource,

1
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -168,6 +168,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),

1
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),

1
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts

@ -138,6 +138,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'),
feeInBaseCurrency: new Big('1.55'),
firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),

1
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -166,6 +166,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'),

1
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts

@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('49'),
feeInBaseCurrency: new Big('49'),
firstBuyDate: '2021-09-01',
grossPerformance: null,
grossPerformancePercentage: null,

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts

@ -151,6 +151,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33'),
grossPerformancePercentage: new Big('0.3066651705565529623'),
@ -177,7 +178,7 @@ describe('PortfolioCalculator', () => {
valueInBaseCurrency: new Big('103.10483')
}
],
totalFeesWithCurrencyEffect: new Big('1'),
totalFeesWithCurrencyEffect: new Big('0.9238'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12'),
totalInvestmentWithCurrencyEffect: new Big('82.329056'),

1
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts

@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: null,
grossPerformancePercentage: null,

1
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'),
feeInBaseCurrency: new Big('4.25'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'),

1
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -183,6 +183,7 @@ describe('PortfolioCalculator', () => {
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),

4
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -34,9 +34,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.fee) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.fee
currentPosition.feeInBaseCurrency
);
}

1
apps/api/src/app/user/user.service.ts

@ -237,6 +237,7 @@ export class UserService {
currentPermissions = without(
currentPermissions,
permissions.accessHoldingsChart,
permissions.createAccess
);

69
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';
@ -18,6 +28,7 @@ import { takeUntil } from 'rxjs/operators';
export class HomeHoldingsComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToAccessHoldingsChart: boolean;
public hasPermissionToCreateOrder: boolean;
public holdings: PortfolioPosition[];
public holdingType: HoldingType = 'ACTIVE';
@ -26,6 +37,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 +46,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private router: Router,
private userService: UserService
) {}
@ -53,20 +66,17 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToAccessHoldingsChart = hasPermission(
this.user.permissions,
permissions.accessHoldingsChart
);
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
this.initialize();
this.changeDetectorRef.markForCheck();
}
@ -76,16 +86,16 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
public onChangeHoldingType(aHoldingType: HoldingType) {
this.holdingType = aHoldingType;
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.initialize();
}
this.changeDetectorRef.markForCheck();
public onSymbolClicked({ dataSource, symbol }: UniqueAsset) {
if (dataSource && symbol) {
this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true }
});
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
@ -104,4 +114,27 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
range: this.user?.settings?.dateRange
});
}
private initialize() {
this.viewModeFormControl.disable();
if (
this.hasPermissionToAccessHoldingsChart &&
this.holdingType === 'ACTIVE'
) {
this.viewModeFormControl.enable();
} else if (this.holdingType === 'CLOSED') {
this.viewModeFormControl.setValue('TABLE');
}
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
}
}

30
apps/client/src/app/components/home-holdings/home-holdings.html

@ -6,7 +6,25 @@
</div>
<div class="row">
<div class="col-lg">
<div class="d-flex justify-content-end">
<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 i18n-title title="Table" value="TABLE">
<ion-icon name="reorder-four-outline" />
</mat-button-toggle>
<mat-button-toggle i18n-title title="Chart" 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"
@ -15,6 +33,15 @@
(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"
@ -33,6 +60,7 @@
>
</div>
}
}
</div>
</div>
</div>

7
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]

6
apps/client/src/app/components/home-holdings/home-holdings.scss

@ -1,3 +1,9 @@
:host {
display: block;
.mat-button-toggle-group {
.mat-button-toggle-appearance-standard {
--mat-standard-button-toggle-height: 1.5rem;
}
}
}

2
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -196,7 +196,6 @@
/>
</div>
</div>
@if (hasPermissionToUpdateUserSettings) {
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>Experimental Features</div>
@ -214,7 +213,6 @@
/>
</div>
</div>
}
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
Ghostfolio <ng-container i18n>User ID</ng-container>

12
apps/client/src/assets/site.webmanifest

@ -7,10 +7,16 @@
{
"sizes": "192x192",
"src": "/assets/android-chrome-192x192.png",
"type": "image/png",
"purpose": "any maskable"
"type": "image/png"
},
{
"purpose": "any",
"sizes": "512x512",
"src": "/assets/android-chrome-512x512.png",
"type": "image/png"
},
{
"purpose": "maskable",
"sizes": "512x512",
"src": "/assets/android-chrome-512x512.png",
"type": "image/png"
@ -21,5 +27,5 @@
"short_name": "Ghostfolio",
"start_url": "/en/",
"theme_color": "#FFFFFF",
"url": "https://www.ghostfol.io"
"url": "https://ghostfol.io"
}

4
libs/common/src/lib/models/timeline-position.ts

@ -24,6 +24,10 @@ export class TimelinePosition {
@Type(() => Big)
fee: Big;
@Transform(transformToBig, { toClassOnly: true })
@Type(() => Big)
feeInBaseCurrency: Big;
firstBuyDate: string;
@Transform(transformToBig, { toClassOnly: true })

9
libs/common/src/lib/permissions.ts

@ -5,6 +5,7 @@ import { Role } from '@prisma/client';
export const permissions = {
accessAdminControl: 'accessAdminControl',
accessAssistant: 'accessAssistant',
accessHoldingsChart: 'accessHoldingsChart',
createAccess: 'createAccess',
createAccount: 'createAccount',
createAccountBalance: 'createAccountBalance',
@ -47,6 +48,7 @@ export function getPermissions(aRole: Role): string[] {
return [
permissions.accessAdminControl,
permissions.accessAssistant,
permissions.accessHoldingsChart,
permissions.createAccess,
permissions.createAccount,
permissions.createAccountBalance,
@ -72,11 +74,16 @@ export function getPermissions(aRole: Role): string[] {
];
case 'DEMO':
return [permissions.accessAssistant, permissions.createUserAccount];
return [
permissions.accessAssistant,
permissions.accessHoldingsChart,
permissions.createUserAccount
];
case 'USER':
return [
permissions.accessAssistant,
permissions.accessHoldingsChart,
permissions.createAccess,
permissions.createAccount,
permissions.createAccountBalance,

12
libs/common/src/lib/personal-finance-tools.ts

@ -351,6 +351,18 @@ export const personalFinanceTools: Product[] = [
origin: `Italy`,
slogan: 'Your Personal Finance Hub'
},
{
founded: 2008,
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'pocketsmith',
languages: ['English'],
name: 'PocketSmith',
origin: `New Zealand`,
pricingPerYear: '$120',
region: `Global`,
slogan: 'Know where your money is going'
},
{
hasFreePlan: false,
hasSelfHostingAbility: false,

1
libs/common/src/lib/types/holding-view-mode.type.ts

@ -0,0 +1 @@
export type HoldingViewMode = 'CHART' | 'TABLE';

2
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,

2
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

1
libs/ui/src/lib/treemap-chart/index.ts

@ -0,0 +1 @@
export * from './treemap-chart.component';

13
libs/ui/src/lib/treemap-chart/treemap-chart.component.html

@ -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>

4
libs/ui/src/lib/treemap-chart/treemap-chart.component.scss

@ -0,0 +1,4 @@
:host {
aspect-ratio: 16 / 9;
display: block;
}

168
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

@ -0,0 +1,168 @@
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];
}
},
borderRadius: 4,
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;
}
}

4
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.94.0",
"version": "2.96.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -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

@ -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…
Cancel
Save