mirror of https://github.com/ghostfolio/ghostfolio
21 changed files with 1842 additions and 34 deletions
@ -0,0 +1,389 @@ |
|||||
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; |
||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; |
||||
|
import { prettifySymbol } from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
AssetProfileIdentifier, |
||||
|
PortfolioDetails, |
||||
|
PortfolioPosition, |
||||
|
User |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { MarketAdvanced } from '@ghostfolio/common/types'; |
||||
|
import { translate } from '@ghostfolio/ui/i18n'; |
||||
|
import { GfAllocationCardsGridComponent } from '@ghostfolio/ui/allocation-cards-grid'; |
||||
|
import { GfAllocationDonutCardsComponent } from '@ghostfolio/ui/allocation-donut-cards'; |
||||
|
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; |
||||
|
import { DataService } from '@ghostfolio/ui/services'; |
||||
|
|
||||
|
import { |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { MatCardModule } from '@angular/material/card'; |
||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar'; |
||||
|
import { MatTabsModule } from '@angular/material/tabs'; |
||||
|
import { Router } from '@angular/router'; |
||||
|
import { |
||||
|
Account, |
||||
|
AssetClass, |
||||
|
AssetSubClass, |
||||
|
DataSource, |
||||
|
Platform |
||||
|
} from '@prisma/client'; |
||||
|
import { isNumber } from 'lodash'; |
||||
|
|
||||
|
@Component({ |
||||
|
imports: [ |
||||
|
GfAllocationCardsGridComponent, |
||||
|
GfAllocationDonutCardsComponent, |
||||
|
GfPremiumIndicatorComponent, |
||||
|
MatCardModule, |
||||
|
MatProgressBarModule, |
||||
|
MatTabsModule |
||||
|
], |
||||
|
selector: 'gf-allocations-v2-page', |
||||
|
styleUrls: ['./allocations-v2-page.scss'], |
||||
|
templateUrl: './allocations-v2-page.html' |
||||
|
}) |
||||
|
export class GfAllocationsV2PageComponent implements OnInit { |
||||
|
public accounts: { |
||||
|
[id: string]: Pick<Account, 'name'> & { |
||||
|
id: string; |
||||
|
value: number; |
||||
|
}; |
||||
|
}; |
||||
|
public continents: { |
||||
|
[code: string]: { name: string; value: number }; |
||||
|
}; |
||||
|
public countries: { |
||||
|
[code: string]: { name: string; value: number }; |
||||
|
}; |
||||
|
public hasImpersonationId: boolean; |
||||
|
public holdings: { |
||||
|
[symbol: string]: Pick< |
||||
|
PortfolioPosition, |
||||
|
| 'assetClass' |
||||
|
| 'assetClassLabel' |
||||
|
| 'assetSubClass' |
||||
|
| 'assetSubClassLabel' |
||||
|
| 'currency' |
||||
|
| 'exchange' |
||||
|
| 'name' |
||||
|
> & { etfProvider: string; value: number }; |
||||
|
}; |
||||
|
public isLoading = false; |
||||
|
public marketsAdvanced: { |
||||
|
[key in MarketAdvanced]: { |
||||
|
id: MarketAdvanced; |
||||
|
name: string; |
||||
|
value: number; |
||||
|
}; |
||||
|
}; |
||||
|
public platforms: { |
||||
|
[id: string]: Pick<Platform, 'name'> & { |
||||
|
id: string; |
||||
|
value: number; |
||||
|
}; |
||||
|
}; |
||||
|
public portfolioDetails: PortfolioDetails; |
||||
|
public sectors: { |
||||
|
[name: string]: { name: string; value: number }; |
||||
|
}; |
||||
|
public symbols: { |
||||
|
[name: string]: { |
||||
|
dataSource?: DataSource; |
||||
|
name: string; |
||||
|
symbol: string; |
||||
|
value: number; |
||||
|
}; |
||||
|
}; |
||||
|
public UNKNOWN_KEY = UNKNOWN_KEY; |
||||
|
public user: User; |
||||
|
|
||||
|
// Toggle between donut-cards (Option 1) and cards-grid (Option 3)
|
||||
|
public selectedTabIndex = 0; |
||||
|
|
||||
|
public constructor( |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private dataService: DataService, |
||||
|
private destroyRef: DestroyRef, |
||||
|
private impersonationStorageService: ImpersonationStorageService, |
||||
|
private router: Router, |
||||
|
private userService: UserService |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.impersonationStorageService |
||||
|
.onChangeHasImpersonation() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((impersonationId) => { |
||||
|
this.hasImpersonationId = !!impersonationId; |
||||
|
}); |
||||
|
|
||||
|
this.userService.stateChanged |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((state) => { |
||||
|
if (state?.user) { |
||||
|
this.user = state.user; |
||||
|
|
||||
|
this.isLoading = true; |
||||
|
this.initialize(); |
||||
|
|
||||
|
this.fetchPortfolioDetails() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((portfolioDetails) => { |
||||
|
this.initialize(); |
||||
|
this.portfolioDetails = portfolioDetails; |
||||
|
this.initializeAllocationsData(); |
||||
|
this.isLoading = false; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
|
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
this.initialize(); |
||||
|
} |
||||
|
|
||||
|
public onSymbolClicked({ dataSource, symbol }: AssetProfileIdentifier) { |
||||
|
if (dataSource && symbol) { |
||||
|
this.router.navigate([], { |
||||
|
queryParams: { dataSource, symbol, holdingDetailDialog: true } |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private extractEtfProvider({ |
||||
|
assetSubClass, |
||||
|
name |
||||
|
}: { |
||||
|
assetSubClass: PortfolioPosition['assetSubClass']; |
||||
|
name: string; |
||||
|
}) { |
||||
|
if (assetSubClass === 'ETF') { |
||||
|
const [firstWord] = name.split(' '); |
||||
|
return firstWord; |
||||
|
} |
||||
|
return UNKNOWN_KEY; |
||||
|
} |
||||
|
|
||||
|
private fetchPortfolioDetails() { |
||||
|
return this.dataService.fetchPortfolioDetails({ |
||||
|
filters: this.userService.getFilters(), |
||||
|
withMarkets: true |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private initialize() { |
||||
|
this.accounts = {}; |
||||
|
this.continents = { |
||||
|
[UNKNOWN_KEY]: { name: UNKNOWN_KEY, value: 0 } |
||||
|
}; |
||||
|
this.countries = { |
||||
|
[UNKNOWN_KEY]: { name: UNKNOWN_KEY, value: 0 } |
||||
|
}; |
||||
|
this.holdings = {}; |
||||
|
this.marketsAdvanced = { |
||||
|
[UNKNOWN_KEY]: { id: UNKNOWN_KEY, name: UNKNOWN_KEY, value: 0 }, |
||||
|
asiaPacific: { |
||||
|
id: 'asiaPacific', |
||||
|
name: translate('Asia-Pacific'), |
||||
|
value: 0 |
||||
|
}, |
||||
|
emergingMarkets: { |
||||
|
id: 'emergingMarkets', |
||||
|
name: translate('Emerging Markets'), |
||||
|
value: 0 |
||||
|
}, |
||||
|
europe: { id: 'europe', name: translate('Europe'), value: 0 }, |
||||
|
japan: { id: 'japan', name: translate('Japan'), value: 0 }, |
||||
|
northAmerica: { |
||||
|
id: 'northAmerica', |
||||
|
name: translate('North America'), |
||||
|
value: 0 |
||||
|
}, |
||||
|
otherMarkets: { |
||||
|
id: 'otherMarkets', |
||||
|
name: translate('Other Markets'), |
||||
|
value: 0 |
||||
|
} |
||||
|
}; |
||||
|
this.platforms = {}; |
||||
|
this.portfolioDetails = { |
||||
|
accounts: {}, |
||||
|
createdAt: undefined, |
||||
|
holdings: {}, |
||||
|
platforms: {}, |
||||
|
summary: undefined |
||||
|
}; |
||||
|
this.sectors = { |
||||
|
[UNKNOWN_KEY]: { name: UNKNOWN_KEY, value: 0 } |
||||
|
}; |
||||
|
this.symbols = { |
||||
|
[UNKNOWN_KEY]: { name: UNKNOWN_KEY, symbol: UNKNOWN_KEY, value: 0 } |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private initializeAllocationsData() { |
||||
|
for (const [ |
||||
|
id, |
||||
|
{ name, valueInBaseCurrency, valueInPercentage } |
||||
|
] of Object.entries(this.portfolioDetails.accounts)) { |
||||
|
let value = 0; |
||||
|
if (this.hasImpersonationId) { |
||||
|
value = valueInPercentage; |
||||
|
} else { |
||||
|
value = valueInBaseCurrency; |
||||
|
} |
||||
|
this.accounts[id] = { id, name, value }; |
||||
|
} |
||||
|
|
||||
|
for (const [symbol, position] of Object.entries( |
||||
|
this.portfolioDetails.holdings |
||||
|
)) { |
||||
|
let value = 0; |
||||
|
if (this.hasImpersonationId) { |
||||
|
value = position.allocationInPercentage; |
||||
|
} else { |
||||
|
value = position.valueInBaseCurrency; |
||||
|
} |
||||
|
|
||||
|
this.holdings[symbol] = { |
||||
|
value, |
||||
|
assetClass: position.assetClass || (UNKNOWN_KEY as AssetClass), |
||||
|
assetClassLabel: position.assetClassLabel || UNKNOWN_KEY, |
||||
|
assetSubClass: |
||||
|
position.assetSubClass || (UNKNOWN_KEY as AssetSubClass), |
||||
|
assetSubClassLabel: position.assetSubClassLabel || UNKNOWN_KEY, |
||||
|
currency: position.currency, |
||||
|
etfProvider: this.extractEtfProvider({ |
||||
|
assetSubClass: position.assetSubClass, |
||||
|
name: position.name |
||||
|
}), |
||||
|
exchange: position.exchange, |
||||
|
name: position.name |
||||
|
}; |
||||
|
|
||||
|
if (position.assetClass !== AssetClass.LIQUIDITY) { |
||||
|
if (position.countries.length > 0) { |
||||
|
for (const country of position.countries) { |
||||
|
const { code, continent, name, weight } = country; |
||||
|
if (this.continents[continent]?.value) { |
||||
|
this.continents[continent].value += |
||||
|
weight * |
||||
|
(isNumber(position.valueInBaseCurrency) |
||||
|
? position.valueInBaseCurrency |
||||
|
: position.valueInPercentage); |
||||
|
} else { |
||||
|
this.continents[continent] = { |
||||
|
name: continent, |
||||
|
value: |
||||
|
weight * |
||||
|
(isNumber(position.valueInBaseCurrency) |
||||
|
? this.portfolioDetails.holdings[symbol] |
||||
|
.valueInBaseCurrency |
||||
|
: this.portfolioDetails.holdings[symbol] |
||||
|
.valueInPercentage) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
if (this.countries[code]?.value) { |
||||
|
this.countries[code].value += |
||||
|
weight * |
||||
|
(isNumber(position.valueInBaseCurrency) |
||||
|
? position.valueInBaseCurrency |
||||
|
: position.valueInPercentage); |
||||
|
} else { |
||||
|
this.countries[code] = { |
||||
|
name, |
||||
|
value: |
||||
|
weight * |
||||
|
(isNumber(position.valueInBaseCurrency) |
||||
|
? this.portfolioDetails.holdings[symbol] |
||||
|
.valueInBaseCurrency |
||||
|
: this.portfolioDetails.holdings[symbol] |
||||
|
.valueInPercentage) |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
} else { |
||||
|
this.continents[UNKNOWN_KEY].value += isNumber( |
||||
|
position.valueInBaseCurrency |
||||
|
) |
||||
|
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency |
||||
|
: this.portfolioDetails.holdings[symbol].valueInPercentage; |
||||
|
|
||||
|
this.countries[UNKNOWN_KEY].value += isNumber( |
||||
|
position.valueInBaseCurrency |
||||
|
) |
||||
|
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency |
||||
|
: this.portfolioDetails.holdings[symbol].valueInPercentage; |
||||
|
} |
||||
|
|
||||
|
if (position.sectors.length > 0) { |
||||
|
for (const sector of position.sectors) { |
||||
|
const { name, weight } = sector; |
||||
|
if (this.sectors[name]?.value) { |
||||
|
this.sectors[name].value += |
||||
|
weight * |
||||
|
(isNumber(position.valueInBaseCurrency) |
||||
|
? position.valueInBaseCurrency |
||||
|
: position.valueInPercentage); |
||||
|
} else { |
||||
|
this.sectors[name] = { |
||||
|
name, |
||||
|
value: |
||||
|
weight * |
||||
|
(isNumber(position.valueInBaseCurrency) |
||||
|
? this.portfolioDetails.holdings[symbol] |
||||
|
.valueInBaseCurrency |
||||
|
: this.portfolioDetails.holdings[symbol] |
||||
|
.valueInPercentage) |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
} else { |
||||
|
this.sectors[UNKNOWN_KEY].value += isNumber( |
||||
|
position.valueInBaseCurrency |
||||
|
) |
||||
|
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency |
||||
|
: this.portfolioDetails.holdings[symbol].valueInPercentage; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
this.symbols[prettifySymbol(symbol)] = { |
||||
|
dataSource: position.dataSource, |
||||
|
name: position.name, |
||||
|
symbol: prettifySymbol(symbol), |
||||
|
value: isNumber(position.valueInBaseCurrency) |
||||
|
? position.valueInBaseCurrency |
||||
|
: position.valueInPercentage |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
Object.values(this.portfolioDetails.marketsAdvanced).forEach( |
||||
|
({ id, valueInBaseCurrency, valueInPercentage }) => { |
||||
|
this.marketsAdvanced[id].value = isNumber(valueInBaseCurrency) |
||||
|
? valueInBaseCurrency |
||||
|
: valueInPercentage; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
for (const [ |
||||
|
id, |
||||
|
{ name, valueInBaseCurrency, valueInPercentage } |
||||
|
] of Object.entries(this.portfolioDetails.platforms)) { |
||||
|
let value = 0; |
||||
|
if (this.hasImpersonationId) { |
||||
|
value = valueInPercentage; |
||||
|
} else { |
||||
|
value = valueInBaseCurrency; |
||||
|
} |
||||
|
this.platforms[id] = { id, name, value }; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,452 @@ |
|||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n> |
||||
|
Allocations — Comparison View |
||||
|
</h1> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Tab toggle between the two new visualization styles --> |
||||
|
<mat-tab-group |
||||
|
[(selectedIndex)]="selectedTabIndex" |
||||
|
animationDuration="200ms" |
||||
|
class="mb-4" |
||||
|
> |
||||
|
<!-- TAB 1: Donut + Cards (recommended hybrid) --> |
||||
|
<mat-tab label="Donut + Cards"> |
||||
|
<div class="charts-grid"> |
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title class="text-truncate" i18n |
||||
|
>By Platform</mat-card-title |
||||
|
> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-donut-cards |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="platforms" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['id']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title |
||||
|
class="align-items-center d-flex text-truncate" |
||||
|
> |
||||
|
<span i18n>By Currency</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-donut-cards |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="holdings" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['currency']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title |
||||
|
class="align-items-center d-flex text-truncate" |
||||
|
> |
||||
|
<span i18n>By Asset Class</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-donut-cards |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="holdings" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['assetClassLabel', 'assetSubClassLabel']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="chart-full-width mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title class="text-truncate" i18n |
||||
|
>By Holding</mat-card-title |
||||
|
> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-donut-cards |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="symbols" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['symbol']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
[maxItems]="15" |
||||
|
(sliceClicked)="onSymbolClicked($event)" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title |
||||
|
class="align-items-center d-flex text-truncate" |
||||
|
> |
||||
|
<span i18n>By Sector</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-donut-cards |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="sectors" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['name']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
[maxItems]="10" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title |
||||
|
class="align-items-center d-flex text-truncate" |
||||
|
> |
||||
|
<span i18n>By Continent</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-donut-cards |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="continents" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['name']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title |
||||
|
class="align-items-center d-flex text-truncate" |
||||
|
> |
||||
|
<span i18n>By Market</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-donut-cards |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="marketsAdvanced" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title class="text-truncate" i18n |
||||
|
>By Account</mat-card-title |
||||
|
> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-donut-cards |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="accounts" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['id']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title |
||||
|
class="align-items-center d-flex text-truncate" |
||||
|
> |
||||
|
<span i18n>By Country</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-donut-cards |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="countries" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['name']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
[maxItems]="10" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
</div> |
||||
|
</mat-tab> |
||||
|
|
||||
|
<!-- TAB 2: Cards Grid only (Option 3) --> |
||||
|
<mat-tab label="Cards Grid"> |
||||
|
<div class="charts-grid"> |
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title class="text-truncate" i18n |
||||
|
>By Platform</mat-card-title |
||||
|
> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-cards-grid |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="platforms" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['id']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title |
||||
|
class="align-items-center d-flex text-truncate" |
||||
|
> |
||||
|
<span i18n>By Currency</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-cards-grid |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="holdings" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['currency']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title |
||||
|
class="align-items-center d-flex text-truncate" |
||||
|
> |
||||
|
<span i18n>By Asset Class</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-cards-grid |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="holdings" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['assetClassLabel', 'assetSubClassLabel']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="chart-full-width mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title class="text-truncate" i18n |
||||
|
>By Holding</mat-card-title |
||||
|
> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-cards-grid |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="symbols" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['symbol']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
[maxItems]="15" |
||||
|
(cardClicked)="onSymbolClicked($event)" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title |
||||
|
class="align-items-center d-flex text-truncate" |
||||
|
> |
||||
|
<span i18n>By Sector</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-cards-grid |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="sectors" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['name']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
[maxItems]="10" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title |
||||
|
class="align-items-center d-flex text-truncate" |
||||
|
> |
||||
|
<span i18n>By Continent</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-cards-grid |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="continents" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['name']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title |
||||
|
class="align-items-center d-flex text-truncate" |
||||
|
> |
||||
|
<span i18n>By Market</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-cards-grid |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="marketsAdvanced" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title class="text-truncate" i18n |
||||
|
>By Account</mat-card-title |
||||
|
> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-cards-grid |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="accounts" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['id']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<mat-card appearance="outlined" class="mb-3"> |
||||
|
<mat-card-header class="overflow-hidden w-100"> |
||||
|
<mat-card-title |
||||
|
class="align-items-center d-flex text-truncate" |
||||
|
> |
||||
|
<span i18n>By Country</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</mat-card-title> |
||||
|
</mat-card-header> |
||||
|
<mat-card-content> |
||||
|
<gf-allocation-cards-grid |
||||
|
[baseCurrency]="user?.settings?.baseCurrency" |
||||
|
[colorScheme]="user?.settings?.colorScheme" |
||||
|
[data]="countries" |
||||
|
[isInPercent]=" |
||||
|
hasImpersonationId || user?.settings?.isRestrictedView |
||||
|
" |
||||
|
[keys]="['name']" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
[maxItems]="10" |
||||
|
/> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
</div> |
||||
|
</mat-tab> |
||||
|
</mat-tab-group> |
||||
|
</div> |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { GfAllocationsV2PageComponent } from './allocations-v2-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: GfAllocationsV2PageComponent, |
||||
|
path: '', |
||||
|
title: $localize`Allocations V2` |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,41 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
|
||||
|
.charts-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); |
||||
|
gap: 0; |
||||
|
padding-top: 1rem; |
||||
|
} |
||||
|
|
||||
|
.chart-full-width { |
||||
|
grid-column: 1 / -1; |
||||
|
} |
||||
|
|
||||
|
.mat-mdc-card { |
||||
|
.mat-mdc-card-header { |
||||
|
::ng-deep { |
||||
|
.mat-mdc-card-header-text { |
||||
|
flex: 1 1 auto; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.mat-mdc-tab-group { |
||||
|
::ng-deep { |
||||
|
.mat-mdc-tab-body-wrapper { |
||||
|
flex: 1; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 768px) { |
||||
|
:host { |
||||
|
.charts-grid { |
||||
|
grid-template-columns: 1fr; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
@if (isLoading) { |
||||
|
<ngx-skeleton-loader |
||||
|
animation="pulse" |
||||
|
[theme]="{ height: '200px', width: '100%', borderRadius: '12px' }" |
||||
|
/> |
||||
|
} @else { |
||||
|
<div class="allocation-cards-grid"> |
||||
|
<!-- Stacked bar at top showing proportional segments --> |
||||
|
<div class="proportion-bar"> |
||||
|
@for (card of cards; track card.key) { |
||||
|
<div |
||||
|
class="bar-segment" |
||||
|
[style.width.%]="card.percentage * 100" |
||||
|
[style.background]="card.color" |
||||
|
[matTooltip]="card.name + ': ' + (card.percentage | percent : '1.1-1')" |
||||
|
></div> |
||||
|
} |
||||
|
</div> |
||||
|
|
||||
|
<!-- Cards --> |
||||
|
<div class="cards-container"> |
||||
|
@for (card of cards; track card.key) { |
||||
|
<div |
||||
|
class="allocation-card" |
||||
|
(click)="onCardClick(card)" |
||||
|
> |
||||
|
<div class="card-accent" [style.background]="card.color"></div> |
||||
|
<div class="card-body"> |
||||
|
<div class="card-percentage"> |
||||
|
{{ card.percentage | percent : '1.1-1' }} |
||||
|
</div> |
||||
|
<div class="card-name">{{ card.name }}</div> |
||||
|
@if (!isInPercent) { |
||||
|
<div class="card-value"> |
||||
|
{{ card.value | currency : baseCurrency : 'symbol' : '1.0-0' }} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
|
||||
|
<!-- Total --> |
||||
|
<div class="total-row"> |
||||
|
<span class="total-label" i18n>Total</span> |
||||
|
<span class="total-value">{{ totalFormatted }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
@ -0,0 +1,113 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
||||
|
|
||||
|
.allocation-cards-grid { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 0.75rem; |
||||
|
} |
||||
|
|
||||
|
// Proportional stacked bar |
||||
|
.proportion-bar { |
||||
|
display: flex; |
||||
|
height: 8px; |
||||
|
border-radius: 4px; |
||||
|
overflow: hidden; |
||||
|
gap: 2px; |
||||
|
} |
||||
|
|
||||
|
.bar-segment { |
||||
|
min-width: 4px; |
||||
|
transition: opacity 0.2s; |
||||
|
cursor: default; |
||||
|
border-radius: 2px; |
||||
|
|
||||
|
&:hover { |
||||
|
opacity: 0.75; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Cards grid |
||||
|
.cards-container { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); |
||||
|
gap: 0.5rem; |
||||
|
} |
||||
|
|
||||
|
.allocation-card { |
||||
|
display: flex; |
||||
|
border-radius: 10px; |
||||
|
overflow: hidden; |
||||
|
background: var(--surface-color, rgba(128, 128, 128, 0.04)); |
||||
|
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.1)); |
||||
|
cursor: pointer; |
||||
|
transition: box-shadow 0.2s ease, transform 0.15s ease; |
||||
|
|
||||
|
&:hover { |
||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); |
||||
|
transform: translateY(-2px); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.card-accent { |
||||
|
width: 4px; |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
.card-body { |
||||
|
padding: 0.6rem 0.7rem; |
||||
|
min-width: 0; |
||||
|
flex: 1; |
||||
|
} |
||||
|
|
||||
|
.card-percentage { |
||||
|
font-weight: 700; |
||||
|
font-size: 1.1rem; |
||||
|
line-height: 1.2; |
||||
|
letter-spacing: -0.02em; |
||||
|
} |
||||
|
|
||||
|
.card-name { |
||||
|
font-size: 0.75rem; |
||||
|
opacity: 0.55; |
||||
|
white-space: nowrap; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
margin-top: 0.1rem; |
||||
|
line-height: 1.3; |
||||
|
} |
||||
|
|
||||
|
.card-value { |
||||
|
font-weight: 600; |
||||
|
font-size: 0.8rem; |
||||
|
margin-top: 0.2rem; |
||||
|
} |
||||
|
|
||||
|
// Total |
||||
|
.total-row { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
padding-top: 0.5rem; |
||||
|
border-top: 1px solid var(--border-color, rgba(128, 128, 128, 0.12)); |
||||
|
} |
||||
|
|
||||
|
.total-label { |
||||
|
font-size: 0.8rem; |
||||
|
opacity: 0.5; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: 0.05em; |
||||
|
} |
||||
|
|
||||
|
.total-value { |
||||
|
font-weight: 700; |
||||
|
font-size: 1rem; |
||||
|
} |
||||
|
|
||||
|
// Responsive |
||||
|
@media (max-width: 480px) { |
||||
|
.cards-container { |
||||
|
grid-template-columns: repeat(2, 1fr); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,212 @@ |
|||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; |
||||
|
import { getLocale, getTextColor } from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
AssetProfileIdentifier, |
||||
|
PortfolioPosition |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { ColorScheme } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { CommonModule, CurrencyPipe, PercentPipe } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
Input, |
||||
|
OnChanges, |
||||
|
output, |
||||
|
SimpleChanges |
||||
|
} from '@angular/core'; |
||||
|
import { MatTooltipModule } from '@angular/material/tooltip'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { Big } from 'big.js'; |
||||
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
||||
|
import OpenColor from 'open-color'; |
||||
|
|
||||
|
import { translate } from '../i18n'; |
||||
|
|
||||
|
const { |
||||
|
blue, |
||||
|
cyan, |
||||
|
grape, |
||||
|
green, |
||||
|
indigo, |
||||
|
lime, |
||||
|
orange, |
||||
|
pink, |
||||
|
red, |
||||
|
teal, |
||||
|
violet, |
||||
|
yellow |
||||
|
} = OpenColor; |
||||
|
|
||||
|
export interface AllocationCard { |
||||
|
color: string; |
||||
|
key: string; |
||||
|
name: string; |
||||
|
percentage: number; |
||||
|
value: number; |
||||
|
} |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
CurrencyPipe, |
||||
|
MatTooltipModule, |
||||
|
NgxSkeletonLoaderModule, |
||||
|
PercentPipe |
||||
|
], |
||||
|
selector: 'gf-allocation-cards-grid', |
||||
|
styleUrls: ['./allocation-cards-grid.component.scss'], |
||||
|
templateUrl: './allocation-cards-grid.component.html' |
||||
|
}) |
||||
|
export class GfAllocationCardsGridComponent implements OnChanges { |
||||
|
@Input() baseCurrency: string; |
||||
|
@Input() colorScheme: ColorScheme; |
||||
|
@Input() data: { |
||||
|
[symbol: string]: Pick<PortfolioPosition, 'type'> & { |
||||
|
dataSource?: DataSource; |
||||
|
name: string; |
||||
|
value: number; |
||||
|
}; |
||||
|
} = {}; |
||||
|
@Input() isInPercent = false; |
||||
|
@Input() keys: string[] = []; |
||||
|
@Input() locale = getLocale(); |
||||
|
@Input() maxItems = 12; |
||||
|
|
||||
|
public cards: AllocationCard[] = []; |
||||
|
public isLoading = true; |
||||
|
public totalValue = 0; |
||||
|
|
||||
|
protected readonly cardClicked = output<AssetProfileIdentifier>(); |
||||
|
|
||||
|
private readonly OTHER_KEY = 'OTHER'; |
||||
|
|
||||
|
public ngOnChanges(_changes: SimpleChanges) { |
||||
|
if (this.data) { |
||||
|
this.initialize(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public onCardClick(card: AllocationCard) { |
||||
|
const entry = Object.entries(this.data).find(([, item]) => { |
||||
|
return item.name === card.name && item.dataSource; |
||||
|
}); |
||||
|
|
||||
|
if (entry) { |
||||
|
const [symbol, item] = entry; |
||||
|
if (item.dataSource) { |
||||
|
this.cardClicked.emit({ dataSource: item.dataSource, symbol }); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public get totalFormatted(): string { |
||||
|
if (this.isInPercent) { |
||||
|
return '100%'; |
||||
|
} |
||||
|
if (this.totalValue >= 1_000_000) { |
||||
|
return `$${(this.totalValue / 1_000_000).toFixed(1)}M`; |
||||
|
} |
||||
|
if (this.totalValue >= 1_000) { |
||||
|
return `$${(this.totalValue / 1_000).toFixed(0)}K`; |
||||
|
} |
||||
|
return `$${this.totalValue.toFixed(0)}`; |
||||
|
} |
||||
|
|
||||
|
private initialize() { |
||||
|
this.isLoading = true; |
||||
|
|
||||
|
const chartData: { |
||||
|
[key: string]: { name: string; value: Big }; |
||||
|
} = {}; |
||||
|
|
||||
|
const primaryKey = this.keys?.[0]; |
||||
|
|
||||
|
if (primaryKey) { |
||||
|
Object.keys(this.data).forEach((symbol) => { |
||||
|
const asset = this.data[symbol]; |
||||
|
const assetValue = asset.value || 0; |
||||
|
const keyValue = |
||||
|
(asset[primaryKey] as string)?.toUpperCase() || UNKNOWN_KEY; |
||||
|
|
||||
|
if (chartData[keyValue]) { |
||||
|
chartData[keyValue].value = |
||||
|
chartData[keyValue].value.plus(assetValue); |
||||
|
} else { |
||||
|
chartData[keyValue] = { |
||||
|
name: (asset[primaryKey] as string) || UNKNOWN_KEY, |
||||
|
value: new Big(assetValue) |
||||
|
}; |
||||
|
} |
||||
|
}); |
||||
|
} else { |
||||
|
Object.keys(this.data).forEach((symbol) => { |
||||
|
chartData[symbol] = { |
||||
|
name: this.data[symbol].name, |
||||
|
value: new Big(this.data[symbol].value || 0) |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
let sorted = Object.entries(chartData) |
||||
|
.sort(([, a], [, b]) => b.value.minus(a.value).toNumber()) |
||||
|
.filter(([, item]) => item.value.gt(0)); |
||||
|
|
||||
|
if (this.maxItems && sorted.length > this.maxItems) { |
||||
|
const rest = sorted.splice(this.maxItems); |
||||
|
const otherValue = rest.reduce( |
||||
|
(sum, [, item]) => sum.plus(item.value), |
||||
|
new Big(0) |
||||
|
); |
||||
|
sorted.push([this.OTHER_KEY, { name: 'Other', value: otherValue }]); |
||||
|
} |
||||
|
|
||||
|
this.totalValue = sorted.reduce( |
||||
|
(sum, [, item]) => sum + item.value.toNumber(), |
||||
|
0 |
||||
|
); |
||||
|
|
||||
|
const palette = this.getColorPalette(); |
||||
|
this.cards = sorted.map(([key, item], index) => { |
||||
|
let color: string; |
||||
|
if (key === this.OTHER_KEY) { |
||||
|
color = `rgba(${getTextColor(this.colorScheme)}, 0.24)`; |
||||
|
} else if (key === UNKNOWN_KEY) { |
||||
|
color = `rgba(${getTextColor(this.colorScheme)}, 0.12)`; |
||||
|
} else { |
||||
|
color = palette[index % palette.length]; |
||||
|
} |
||||
|
|
||||
|
const percentage = |
||||
|
this.totalValue > 0 ? item.value.toNumber() / this.totalValue : 0; |
||||
|
|
||||
|
return { |
||||
|
color, |
||||
|
key, |
||||
|
name: translate(item.name) || key, |
||||
|
percentage, |
||||
|
value: item.value.toNumber() |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
this.isLoading = false; |
||||
|
} |
||||
|
|
||||
|
private getColorPalette(): string[] { |
||||
|
return [ |
||||
|
blue[5], |
||||
|
teal[5], |
||||
|
lime[5], |
||||
|
orange[5], |
||||
|
pink[5], |
||||
|
violet[5], |
||||
|
indigo[5], |
||||
|
cyan[5], |
||||
|
green[5], |
||||
|
yellow[5], |
||||
|
red[5], |
||||
|
grape[5] |
||||
|
]; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
export * from './allocation-cards-grid.component'; |
||||
@ -0,0 +1,49 @@ |
|||||
|
@if (isLoading) { |
||||
|
<ngx-skeleton-loader |
||||
|
animation="pulse" |
||||
|
[theme]="{ height: '200px', width: '100%', borderRadius: '12px' }" |
||||
|
/> |
||||
|
} @else { |
||||
|
<div class="allocation-donut-cards"> |
||||
|
<!-- Donut --> |
||||
|
<div class="donut-wrapper"> |
||||
|
<canvas #donutCanvas></canvas> |
||||
|
<div class="donut-center"> |
||||
|
<span class="donut-total">{{ totalFormatted }}</span> |
||||
|
<span class="donut-label" i18n>Total</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- Detail cards --> |
||||
|
<div class="detail-cards"> |
||||
|
@for (slice of slices; track slice.key) { |
||||
|
<div |
||||
|
class="detail-card" |
||||
|
[matTooltip]=" |
||||
|
slice.name + |
||||
|
': ' + |
||||
|
(isInPercent |
||||
|
? (slice.percentage | percent : '1.1-1') |
||||
|
: (slice.value | currency : baseCurrency : 'symbol' : '1.0-0')) |
||||
|
" |
||||
|
(click)="onSliceClick(slice)" |
||||
|
> |
||||
|
<div class="card-top"> |
||||
|
<span class="color-dot" [style.background]="slice.color"></span> |
||||
|
<span class="card-pct">{{ |
||||
|
slice.percentage | percent : '1.1-1' |
||||
|
}}</span> |
||||
|
</div> |
||||
|
<div class="card-name">{{ slice.name }}</div> |
||||
|
@if (!isInPercent) { |
||||
|
<div class="card-value"> |
||||
|
{{ |
||||
|
slice.value | currency : baseCurrency : 'symbol' : '1.0-0' |
||||
|
}} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
@ -0,0 +1,119 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
||||
|
|
||||
|
.allocation-donut-cards { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.donut-wrapper { |
||||
|
position: relative; |
||||
|
width: 180px; |
||||
|
height: 180px; |
||||
|
flex-shrink: 0; |
||||
|
|
||||
|
canvas { |
||||
|
width: 100% !important; |
||||
|
height: 100% !important; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.donut-center { |
||||
|
position: absolute; |
||||
|
top: 50%; |
||||
|
left: 50%; |
||||
|
transform: translate(-50%, -50%); |
||||
|
text-align: center; |
||||
|
pointer-events: none; |
||||
|
} |
||||
|
|
||||
|
.donut-total { |
||||
|
font-size: 1.15rem; |
||||
|
font-weight: 600; |
||||
|
display: block; |
||||
|
line-height: 1.2; |
||||
|
} |
||||
|
|
||||
|
.donut-label { |
||||
|
font-size: 0.7rem; |
||||
|
opacity: 0.5; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: 0.05em; |
||||
|
} |
||||
|
|
||||
|
.detail-cards { |
||||
|
display: flex; |
||||
|
flex-wrap: wrap; |
||||
|
gap: 0.5rem; |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.detail-card { |
||||
|
padding: 0.5rem 0.65rem; |
||||
|
border-radius: 8px; |
||||
|
background: var(--surface-color, rgba(128, 128, 128, 0.06)); |
||||
|
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.12)); |
||||
|
cursor: pointer; |
||||
|
transition: box-shadow 0.2s, transform 0.15s; |
||||
|
min-width: 100px; |
||||
|
flex: 0 1 auto; |
||||
|
|
||||
|
&:hover { |
||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); |
||||
|
transform: translateY(-1px); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.card-top { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 0.4rem; |
||||
|
margin-bottom: 0.15rem; |
||||
|
} |
||||
|
|
||||
|
.color-dot { |
||||
|
width: 8px; |
||||
|
height: 8px; |
||||
|
border-radius: 50%; |
||||
|
flex-shrink: 0; |
||||
|
} |
||||
|
|
||||
|
.card-pct { |
||||
|
font-weight: 600; |
||||
|
font-size: 0.85rem; |
||||
|
line-height: 1; |
||||
|
} |
||||
|
|
||||
|
.card-name { |
||||
|
font-size: 0.75rem; |
||||
|
opacity: 0.65; |
||||
|
white-space: nowrap; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
max-width: 120px; |
||||
|
} |
||||
|
|
||||
|
.card-value { |
||||
|
font-weight: 600; |
||||
|
font-size: 0.8rem; |
||||
|
margin-top: 0.1rem; |
||||
|
} |
||||
|
|
||||
|
// Responsive: stack vertically on small screens |
||||
|
@media (max-width: 480px) { |
||||
|
.allocation-donut-cards { |
||||
|
flex-direction: column; |
||||
|
} |
||||
|
|
||||
|
.donut-wrapper { |
||||
|
width: 150px; |
||||
|
height: 150px; |
||||
|
} |
||||
|
|
||||
|
.detail-cards { |
||||
|
justify-content: center; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,286 @@ |
|||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; |
||||
|
import { getLocale, getTextColor } from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
AssetProfileIdentifier, |
||||
|
PortfolioPosition |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { ColorScheme } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { CommonModule, CurrencyPipe, PercentPipe } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
ElementRef, |
||||
|
Input, |
||||
|
OnChanges, |
||||
|
OnDestroy, |
||||
|
output, |
||||
|
SimpleChanges, |
||||
|
viewChild |
||||
|
} from '@angular/core'; |
||||
|
import { MatTooltipModule } from '@angular/material/tooltip'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { Big } from 'big.js'; |
||||
|
import { |
||||
|
ArcElement, |
||||
|
Chart, |
||||
|
type ChartData, |
||||
|
type ChartDataset, |
||||
|
DoughnutController, |
||||
|
LinearScale, |
||||
|
Tooltip |
||||
|
} from 'chart.js'; |
||||
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
||||
|
import OpenColor from 'open-color'; |
||||
|
|
||||
|
import { translate } from '../i18n'; |
||||
|
|
||||
|
const { |
||||
|
blue, |
||||
|
cyan, |
||||
|
grape, |
||||
|
green, |
||||
|
indigo, |
||||
|
lime, |
||||
|
orange, |
||||
|
pink, |
||||
|
red, |
||||
|
teal, |
||||
|
violet, |
||||
|
yellow |
||||
|
} = OpenColor; |
||||
|
|
||||
|
export interface AllocationSlice { |
||||
|
color: string; |
||||
|
key: string; |
||||
|
name: string; |
||||
|
percentage: number; |
||||
|
value: number; |
||||
|
} |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
CurrencyPipe, |
||||
|
MatTooltipModule, |
||||
|
NgxSkeletonLoaderModule, |
||||
|
PercentPipe |
||||
|
], |
||||
|
selector: 'gf-allocation-donut-cards', |
||||
|
styleUrls: ['./allocation-donut-cards.component.scss'], |
||||
|
templateUrl: './allocation-donut-cards.component.html' |
||||
|
}) |
||||
|
export class GfAllocationDonutCardsComponent implements OnChanges, OnDestroy { |
||||
|
@Input() baseCurrency: string; |
||||
|
@Input() colorScheme: ColorScheme; |
||||
|
@Input() data: { |
||||
|
[symbol: string]: Pick<PortfolioPosition, 'type'> & { |
||||
|
dataSource?: DataSource; |
||||
|
name: string; |
||||
|
value: number; |
||||
|
}; |
||||
|
} = {}; |
||||
|
@Input() isInPercent = false; |
||||
|
@Input() keys: string[] = []; |
||||
|
@Input() locale = getLocale(); |
||||
|
@Input() maxItems = 12; |
||||
|
@Input() title = ''; |
||||
|
|
||||
|
public chart: Chart<'doughnut'>; |
||||
|
public isLoading = true; |
||||
|
public slices: AllocationSlice[] = []; |
||||
|
public totalValue = 0; |
||||
|
|
||||
|
protected readonly sliceClicked = output<AssetProfileIdentifier>(); |
||||
|
|
||||
|
private readonly OTHER_KEY = 'OTHER'; |
||||
|
|
||||
|
private readonly chartCanvas = |
||||
|
viewChild<ElementRef<HTMLCanvasElement>>('donutCanvas'); |
||||
|
|
||||
|
public constructor() { |
||||
|
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip); |
||||
|
} |
||||
|
|
||||
|
public ngOnChanges(_changes: SimpleChanges) { |
||||
|
if (this.data) { |
||||
|
this.initialize(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
this.chart?.destroy(); |
||||
|
} |
||||
|
|
||||
|
public onSliceClick(slice: AllocationSlice) { |
||||
|
const entry = Object.entries(this.data).find(([, item]) => { |
||||
|
return item.name === slice.name || item.dataSource; |
||||
|
}); |
||||
|
|
||||
|
if (entry) { |
||||
|
const [symbol, item] = entry; |
||||
|
if (item.dataSource) { |
||||
|
this.sliceClicked.emit({ dataSource: item.dataSource, symbol }); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public get totalFormatted(): string { |
||||
|
if (this.isInPercent) { |
||||
|
return '100%'; |
||||
|
} |
||||
|
if (this.totalValue >= 1_000_000) { |
||||
|
return `$${(this.totalValue / 1_000_000).toFixed(1)}M`; |
||||
|
} |
||||
|
if (this.totalValue >= 1_000) { |
||||
|
return `$${(this.totalValue / 1_000).toFixed(0)}K`; |
||||
|
} |
||||
|
return `$${this.totalValue.toFixed(0)}`; |
||||
|
} |
||||
|
|
||||
|
private initialize() { |
||||
|
this.isLoading = true; |
||||
|
|
||||
|
const chartData: { |
||||
|
[key: string]: { name: string; value: Big }; |
||||
|
} = {}; |
||||
|
|
||||
|
const primaryKey = this.keys?.[0]; |
||||
|
|
||||
|
if (primaryKey) { |
||||
|
Object.keys(this.data).forEach((symbol) => { |
||||
|
const asset = this.data[symbol]; |
||||
|
const assetValue = asset.value || 0; |
||||
|
const keyValue = (asset[primaryKey] as string)?.toUpperCase() || UNKNOWN_KEY; |
||||
|
|
||||
|
if (chartData[keyValue]) { |
||||
|
chartData[keyValue].value = chartData[keyValue].value.plus(assetValue); |
||||
|
} else { |
||||
|
chartData[keyValue] = { |
||||
|
name: (asset[primaryKey] as string) || UNKNOWN_KEY, |
||||
|
value: new Big(assetValue) |
||||
|
}; |
||||
|
} |
||||
|
}); |
||||
|
} else { |
||||
|
Object.keys(this.data).forEach((symbol) => { |
||||
|
chartData[symbol] = { |
||||
|
name: this.data[symbol].name, |
||||
|
value: new Big(this.data[symbol].value || 0) |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Sort descending by value
|
||||
|
let sorted = Object.entries(chartData) |
||||
|
.sort(([, a], [, b]) => b.value.minus(a.value).toNumber()) |
||||
|
.filter(([, item]) => item.value.gt(0)); |
||||
|
|
||||
|
// Group overflow into "Other"
|
||||
|
if (this.maxItems && sorted.length > this.maxItems) { |
||||
|
const rest = sorted.splice(this.maxItems); |
||||
|
const otherValue = rest.reduce( |
||||
|
(sum, [, item]) => sum.plus(item.value), |
||||
|
new Big(0) |
||||
|
); |
||||
|
sorted.push([this.OTHER_KEY, { name: 'Other', value: otherValue }]); |
||||
|
} |
||||
|
|
||||
|
// Calculate total
|
||||
|
this.totalValue = sorted.reduce( |
||||
|
(sum, [, item]) => sum + item.value.toNumber(), |
||||
|
0 |
||||
|
); |
||||
|
|
||||
|
// Build slices with colors
|
||||
|
const palette = this.getColorPalette(); |
||||
|
this.slices = sorted.map(([key, item], index) => { |
||||
|
let color: string; |
||||
|
if (key === this.OTHER_KEY) { |
||||
|
color = `rgba(${getTextColor(this.colorScheme)}, 0.24)`; |
||||
|
} else if (key === UNKNOWN_KEY) { |
||||
|
color = `rgba(${getTextColor(this.colorScheme)}, 0.12)`; |
||||
|
} else { |
||||
|
color = palette[index % palette.length]; |
||||
|
} |
||||
|
|
||||
|
const percentage = |
||||
|
this.totalValue > 0 ? item.value.toNumber() / this.totalValue : 0; |
||||
|
|
||||
|
return { |
||||
|
color, |
||||
|
key, |
||||
|
name: translate(item.name) || key, |
||||
|
percentage, |
||||
|
value: item.value.toNumber() |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
// Build chart
|
||||
|
this.buildChart(); |
||||
|
this.isLoading = false; |
||||
|
} |
||||
|
|
||||
|
private buildChart() { |
||||
|
const canvas = this.chartCanvas(); |
||||
|
if (!canvas) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const backgrounds = this.slices.map((s) => s.color); |
||||
|
const values = this.slices.map((s) => s.value); |
||||
|
|
||||
|
const datasets: ChartDataset<'doughnut'>[] = [ |
||||
|
{ |
||||
|
backgroundColor: backgrounds.length > 0 ? backgrounds : [`rgba(${getTextColor(this.colorScheme)}, 0.12)`], |
||||
|
borderWidth: 0, |
||||
|
data: values.length > 0 ? values : [1], |
||||
|
hoverOffset: 4 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const data: ChartData<'doughnut'> = { |
||||
|
datasets, |
||||
|
labels: this.slices.map((s) => s.name) |
||||
|
}; |
||||
|
|
||||
|
if (this.chart) { |
||||
|
this.chart.data = data; |
||||
|
this.chart.update(); |
||||
|
} else { |
||||
|
this.chart = new Chart<'doughnut'>(canvas.nativeElement, { |
||||
|
data, |
||||
|
options: { |
||||
|
animation: false, |
||||
|
cutout: '75%', |
||||
|
layout: { padding: 0 }, |
||||
|
plugins: { |
||||
|
legend: { display: false }, |
||||
|
tooltip: { enabled: false } |
||||
|
}, |
||||
|
responsive: true, |
||||
|
maintainAspectRatio: true |
||||
|
}, |
||||
|
type: 'doughnut' |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private getColorPalette(): string[] { |
||||
|
return [ |
||||
|
blue[5], |
||||
|
teal[5], |
||||
|
lime[5], |
||||
|
orange[5], |
||||
|
pink[5], |
||||
|
violet[5], |
||||
|
indigo[5], |
||||
|
cyan[5], |
||||
|
green[5], |
||||
|
yellow[5], |
||||
|
red[5], |
||||
|
grape[5] |
||||
|
]; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
export * from './allocation-donut-cards.component'; |
||||
Loading…
Reference in new issue