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