From a5fe71c7a3deb6c62cf33d0458102088e7b4b28a Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 2 May 2025 22:28:30 +0000 Subject: [PATCH] add pie chart to treasuries dashboard --- .../treasuries-graph.component.ts | 28 +- .../treasuries-pie.component.html | 7 + .../treasuries-pie.component.scss | 135 +++++++++ .../treasuries-pie.component.ts | 280 ++++++++++++++++++ .../treasuries/treasuries.component.html | 88 +++--- .../treasuries/treasuries.component.scss | 10 +- .../treasuries/treasuries.component.ts | 16 +- frontend/src/app/graphs/graphs.module.ts | 2 + 8 files changed, 509 insertions(+), 57 deletions(-) create mode 100644 frontend/src/app/components/treasuries/treasuries-pie/treasuries-pie.component.html create mode 100644 frontend/src/app/components/treasuries/treasuries-pie/treasuries-pie.component.scss create mode 100644 frontend/src/app/components/treasuries/treasuries-pie/treasuries-pie.component.ts diff --git a/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.ts b/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.ts index 8dbd73254..095038f63 100644 --- a/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.ts +++ b/frontend/src/app/components/treasuries/treasuries-graph/treasuries-graph.component.ts @@ -10,6 +10,12 @@ import { StateService } from '@app/services/state.service'; import { FiatCurrencyPipe } from '@app/shared/pipes/fiat-currency.pipe'; import { SeriesOption } from 'echarts'; import { WalletStats } from '@app/shared/wallet-stats'; +import { chartColors } from '@app/app.constants'; + + +// export const treasuriesPalette = [ +// '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', +// ]; const periodSeconds = { '1d': (60 * 60 * 24), @@ -65,11 +71,6 @@ export class TreasuriesGraphComponent implements OnInit, OnChanges, OnDestroy { isLoading = true; chartInstance: any = undefined; - // Color palette for multiple wallets - colorPalette = [ - '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', - ]; - constructor( @Inject(LOCALE_ID) public locale: string, public stateService: StateService, @@ -215,7 +216,7 @@ export class TreasuriesGraphComponent implements OnInit, OnChanges, OnDestroy { })); this.chartOptions = { - color: this.colorPalette, + color: chartColors, animation: false, grid: { top: 20, @@ -260,11 +261,12 @@ export class TreasuriesGraphComponent implements OnInit, OnChanges, OnDestroy { tooltip += `
${date}
`; // Get all active wallet IDs from the selected wallets - const activeWalletIds = Object.keys(this.selectedWallets) - .filter(walletId => this.selectedWallets[walletId] && this.walletData[walletId]); + const activeWalletIds: { walletId: string, index: number }[] = this.wallets + .map((walletId, index) => ({ walletId, index })) + .filter(({ walletId }) => this.selectedWallets[walletId] && this.walletData[walletId]); // For each active wallet, find and display the most recent balance - activeWalletIds.forEach((walletId, index) => { + activeWalletIds.forEach(({ walletId, index }) => { const walletPoints = this.walletData[walletId]; if (!walletPoints || !walletPoints.length) { return; @@ -274,7 +276,7 @@ export class TreasuriesGraphComponent implements OnInit, OnChanges, OnDestroy { let mostRecentPoint: any = null; for (let i = 0; i < walletPoints.length; i++) { const point: any = walletPoints[i]; - const pointTime = Array.isArray(point) ? point[0] : + const pointTime = Array.isArray(point) ? point[0] : (point && typeof point === 'object' && 'value' in point ? point.value[0] : null); if (pointTime && pointTime <= tooltipTime) { @@ -293,11 +295,7 @@ export class TreasuriesGraphComponent implements OnInit, OnChanges, OnDestroy { (mostRecentPoint && typeof mostRecentPoint === 'object' && 'value' in mostRecentPoint ? mostRecentPoint.value[1] : null); if (balance !== null && !isNaN(balance)) { - // Create a marker for this series using the color from colorPalette - const colorIndex = index % this.colorPalette.length; - - // Get color for marker - use direct color from palette - const markerColor = this.colorPalette[colorIndex]; + const markerColor = chartColors[index % chartColors.length]; const marker = ``; diff --git a/frontend/src/app/components/treasuries/treasuries-pie/treasuries-pie.component.html b/frontend/src/app/components/treasuries/treasuries-pie/treasuries-pie.component.html new file mode 100644 index 000000000..c4e6ac024 --- /dev/null +++ b/frontend/src/app/components/treasuries/treasuries-pie/treasuries-pie.component.html @@ -0,0 +1,7 @@ +
+
+
+
+
+
diff --git a/frontend/src/app/components/treasuries/treasuries-pie/treasuries-pie.component.scss b/frontend/src/app/components/treasuries/treasuries-pie/treasuries-pie.component.scss new file mode 100644 index 000000000..cf53ebe14 --- /dev/null +++ b/frontend/src/app/components/treasuries/treasuries-pie/treasuries-pie.component.scss @@ -0,0 +1,135 @@ +.card-header { + border-bottom: 0; + font-size: 18px; + @media (min-width: 465px) { + font-size: 20px; + } +} + +.full-container { + padding: 0px 15px; + width: 100%; + height: calc(100% - 140px); + padding-bottom: 20px; + @media (max-width: 992px) { + height: calc(100% - 190px); + }; + @media (max-width: 575px) { + height: calc(100% - 230px); + }; +} + +.chart { + max-height: 400px; + @media (max-width: 767.98px) { + max-height: 230px; + } + margin-bottom: 20px; +} +.chart-widget { + width: 100%; + height: 100%; + @media (max-width: 767px) { + max-height: 240px; + } + @media (max-width: 485px) { + max-height: 200px; + } +} + +@media (max-width: 767.98px) { + .pools-table th, + .pools-table td { + padding: .3em !important; + } +} + +@media (max-width: 430px) { + .pool-name { + max-width: 110px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .health-column { + display: none; + } +} + +.loadingGraphs { + position: absolute; + top: 50%; + left: calc(50% - 15px); + z-index: 99; +} + +.pool-distribution { + min-height: 56px; + display: block; + @media (min-width: 485px) { + display: flex; + flex-direction: row; + } + h5 { + margin-bottom: 5px; + } + .item { + max-width: 160px; + width: 50%; + display: inline-block; + margin: 0px auto 20px; + &:nth-child(2) { + order: 2; + @media (min-width: 485px) { + order: 3; + } + } + &:nth-child(3) { + width: 50%; + order: 3; + @media (min-width: 485px) { + order: 2; + display: block; + } + @media (min-width: 768px) { + display: none; + } + @media (min-width: 992px) { + display: block; + } + } + .card-title { + font-size: 1rem; + color: var(--title-fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .card-text { + font-size: 18px; + span { + color: var(--transparent-fg); + font-size: 12px; + } + } + } +} + +.skeleton-loader { + width: 100%; + display: block; + max-width: 80px; + margin: 15px auto 3px; +} + + +td { + .difference { + &.positive { + color: rgb(66, 183, 71); + } + &.negative { + color: rgb(183, 66, 66); + } + } +} diff --git a/frontend/src/app/components/treasuries/treasuries-pie/treasuries-pie.component.ts b/frontend/src/app/components/treasuries/treasuries-pie/treasuries-pie.component.ts new file mode 100644 index 000000000..c2da96a40 --- /dev/null +++ b/frontend/src/app/components/treasuries/treasuries-pie/treasuries-pie.component.ts @@ -0,0 +1,280 @@ +import { ChangeDetectionStrategy, Component, Inject, LOCALE_ID, Input, NgZone, OnChanges, SimpleChanges, ChangeDetectorRef, EventEmitter, Output } from '@angular/core'; +import { Router } from '@angular/router'; +import { EChartsOption, PieSeriesOption } from '@app/graphs/echarts'; +import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; +import { StateService } from '@app/services/state.service'; +import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; +import { download } from '@app/shared/graphs.utils'; +import { isMobile } from '@app/shared/common.utils'; +import { WalletStats } from '@app/shared/wallet-stats'; +import { AddressTxSummary } from '@interfaces/electrs.interface'; +import { chartColors } from '@app/app.constants'; +import { formatNumber } from '@angular/common'; + +@Component({ + selector: 'app-treasuries-pie', + templateUrl: './treasuries-pie.component.html', + styleUrls: ['./treasuries-pie.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TreasuriesPieComponent implements OnChanges { + @Input() height: number = 300; + @Input() mode: 'relative' | 'all' = 'relative'; + @Input() walletStats: Record; + @Input() walletSummaries$: Observable>; + @Input() selectedWallets: Record = {}; + @Input() wallets: string[] = []; + @Output() navigateToWallet: EventEmitter = new EventEmitter(); + + chartOptions: EChartsOption = {}; + chartInitOptions = { + renderer: 'svg', + }; + chartInstance: any = undefined; + error: any; + isLoading = true; + subscription: Subscription; + redraw$: BehaviorSubject = new BehaviorSubject(false); + + walletBalance: Record = {}; + + constructor( + @Inject(LOCALE_ID) public locale: string, + public stateService: StateService, + private router: Router, + private zone: NgZone, + private cd: ChangeDetectorRef, + ) { + } + + ngOnInit(): void { + this.isLoading = true; + this.setupSubscription(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.walletSummaries$ || changes.selectedWallets || changes.mode) { + if (this.subscription) { + this.subscription.unsubscribe(); + } + this.setupSubscription(); + } else { + // re-trigger subscription + this.redraw$.next(true); + } + } + + setupSubscription(): void { + this.subscription = combineLatest([ + this.redraw$, + this.walletSummaries$ + ]).subscribe(([_, walletSummaries]) => { + if (walletSummaries) { + this.error = null; + this.processWalletData(walletSummaries); + this.prepareChartOptions(); + } + this.isLoading = false; + this.cd.markForCheck(); + }); + } + + processWalletData(walletSummaries: Record): void { + this.walletBalance = {}; + + Object.entries(walletSummaries).forEach(([walletId, summary]) => { + if (summary?.length) { + const total = this.walletStats[walletId] ? this.walletStats[walletId].balance : summary.reduce((acc, tx) => acc + tx.value, 0); + this.walletBalance[walletId] = total; + } + }); + } + + generateChartSeriesData(): PieSeriesOption[] { + let sliceThreshold = 1; + if (isMobile()) { + sliceThreshold = 2; + } + + const data: object[] = []; + + let edgeDistance: any = '20%'; + if (isMobile()) { + edgeDistance = 0; + } else { + edgeDistance = 10; + } + + const treasuriesTotal = Object.values(this.walletBalance).reduce((acc, v) => acc + v, 0); + const total = this.mode === 'relative' ? treasuriesTotal : 2099999997690000; + + const entries = this.wallets.map((id, index) => ({ + id, + balance: this.walletBalance[id], + share: (this.walletBalance[id] / total) * 100, + color: chartColors[index % chartColors.length], + })); + if (this.mode === 'all') { + entries.unshift({ + id: 'remaining', + balance: (total - treasuriesTotal), + share: ((total - treasuriesTotal) / total) * 100, + color: 'orange' + }); + + console.log('ALL! ', entries); + } + + const otherEntry = { id: 'other', balance: 0, share: 0 }; + + entries.forEach((entry) => { + if (entry.share < sliceThreshold) { + otherEntry.balance += entry.balance; + otherEntry.share = (otherEntry.balance / total) * 100; + return; + } + data.push({ + itemStyle: { + color: entry.color, + }, + value: entry.share, + name: entry.id, + label: { + overflow: 'none', + color: 'var(--tooltip-grey)', + alignTo: 'edge', + edgeDistance: edgeDistance, + }, + tooltip: { + show: !isMobile(), + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: 'var(--tooltip-grey)', + }, + borderColor: '#000', + formatter: () => { + return `${entry.id} (${entry.share.toFixed(2)}%)
+ ${formatNumber(entry.balance / 100_000_000, this.locale, '1.3-3')} BTC
`; + } + }, + data: entry.id as any, + } as PieSeriesOption); + }); + + const percentage = otherEntry.share.toFixed(2) + '%'; + + if (otherEntry.share > 0) { + data.push({ + itemStyle: { + color: '#6b6b6b', + }, + value: otherEntry.share, + name: $localize`Other (${percentage})`, + label: { + overflow: 'none', + color: 'var(--tooltip-grey)', + alignTo: 'edge', + edgeDistance: edgeDistance + }, + tooltip: { + backgroundColor: 'rgba(17, 19, 31, 1)', + borderRadius: 4, + shadowColor: 'rgba(0, 0, 0, 0.5)', + textStyle: { + color: 'var(--tooltip-grey)', + }, + borderColor: '#000', + formatter: () => { + return `${otherEntry.id} (${otherEntry.share}%)
+ ${formatNumber(otherEntry.balance, this.locale, '1.3-3')}
`; + } + }, + data: 9999 as any, + } as PieSeriesOption); + } + + return data; + } + + prepareChartOptions(): void { + const pieSize = ['20%', '80%']; // Desktop + + this.chartOptions = { + animation: false, + color: chartColors, + tooltip: { + trigger: 'item', + textStyle: { + align: 'left', + } + }, + series: [ + { + zlevel: 0, + minShowLabelAngle: 1.8, + name: 'Treasuries', + type: 'pie', + radius: pieSize, + data: this.generateChartSeriesData(), + labelLine: { + lineStyle: { + width: 2, + }, + }, + label: { + fontSize: 14, + }, + itemStyle: { + borderRadius: 1, + borderWidth: 1, + borderColor: '#000', + }, + emphasis: { + itemStyle: { + shadowBlur: 40, + shadowColor: 'rgba(0, 0, 0, 0.75)', + }, + labelLine: { + lineStyle: { + width: 3, + } + } + } + } + ], + }; + } + + onChartInit(ec): void { + if (this.chartInstance !== undefined) { + return; + } + + this.chartInstance = ec; + this.chartInstance.on('click', (e) => { + if (e.data.data === 9999) { // "Other" + return; + } + this.navigateToWallet.emit(e.data.data); + }); + } + + onSaveChart(): void { + const now = new Date(); + this.chartOptions.backgroundColor = 'var(--active-bg)'; + this.chartInstance.setOption(this.chartOptions); + download(this.chartInstance.getDataURL({ + pixelRatio: 2, + excludeComponents: ['dataZoom'], + }), `treasuries-pie-${Math.round(now.getTime() / 1000)}.svg`); + this.chartOptions.backgroundColor = 'none'; + this.chartInstance.setOption(this.chartOptions); + } + + isEllipsisActive(e: HTMLElement): boolean { + return (e.offsetWidth < e.scrollWidth); + } +} + diff --git a/frontend/src/app/components/treasuries/treasuries.component.html b/frontend/src/app/components/treasuries/treasuries.component.html index f2fbe1ac2..5a1a5f416 100644 --- a/frontend/src/app/components/treasuries/treasuries.component.html +++ b/frontend/src/app/components/treasuries/treasuries.component.html @@ -1,5 +1,6 @@
-
+ +
@@ -12,47 +13,68 @@ USD Value - - -
-
- {{i + 1}} -
- - {{wallet}} - - - - - - - + + + +
+
+ {{i + 1}} +
+ + {{wallet}} + + + + + + + +
-
-
-
+
+
Treasury Distribution
- +
+
+
+
+
+
+
+
+
+
Balance History
+
+
diff --git a/frontend/src/app/components/treasuries/treasuries.component.scss b/frontend/src/app/components/treasuries/treasuries.component.scss index 6dad9a9d7..fd19065ff 100644 --- a/frontend/src/app/components/treasuries/treasuries.component.scss +++ b/frontend/src/app/components/treasuries/treasuries.component.scss @@ -107,11 +107,13 @@ } .table-cell-position { width: 10%; + min-width: 50px; text-align: center; .position-container { display: flex; align-items: center; justify-content: center; + min-width: 40px; .color-swatch { width: 16px; height: 16px; @@ -124,7 +126,7 @@ } } .table-cell-name { - width: 40%; + width: 25%; text-align: left; } .table-cell-balance { @@ -135,6 +137,12 @@ width: 25%; text-align: right; } + + @media (max-width: 1080px) { + .table-cell-value { + display: none; + } + } } .position-container { diff --git a/frontend/src/app/components/treasuries/treasuries.component.ts b/frontend/src/app/components/treasuries/treasuries.component.ts index 8c58ee503..0159681f0 100644 --- a/frontend/src/app/components/treasuries/treasuries.component.ts +++ b/frontend/src/app/components/treasuries/treasuries.component.ts @@ -7,7 +7,7 @@ import { catchError, map, scan, shareReplay, startWith, switchMap, tap } from 'r import { WalletStats } from '@app/shared/wallet-stats'; import { ElectrsApiService } from '@app/services/electrs-api.service'; import { Router } from '@angular/router'; - +import { chartColors } from '@app/app.constants'; @Component({ selector: 'app-treasuries', templateUrl: './treasuries.component.html', @@ -83,8 +83,8 @@ export class TreasuriesComponent implements OnInit, OnDestroy { mempool_stats: { funded_txo_count: 0, funded_txo_sum: 0, - spent_txo_count: 0, - spent_txo_sum: 0, + spent_txo_count: 0, + spent_txo_sum: 0, tx_count: 0 }, }; @@ -270,12 +270,12 @@ export class TreasuriesComponent implements OnInit, OnDestroy { } getWalletColor(wallet: string): string { - // Use a consistent color for each wallet based on its position in the sorted list - const colors = [ - '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', - ]; const index = this.currentSortedWallets.indexOf(wallet); - return colors[index % colors.length]; + return chartColors[index % chartColors.length]; + } + + onNavigateToWallet(wallet: string): void { + this.navigateToWallet(wallet); } navigateToWallet(wallet: string): void { diff --git a/frontend/src/app/graphs/graphs.module.ts b/frontend/src/app/graphs/graphs.module.ts index fe6f41b55..a6847af3b 100644 --- a/frontend/src/app/graphs/graphs.module.ts +++ b/frontend/src/app/graphs/graphs.module.ts @@ -39,6 +39,7 @@ import { WalletComponent } from '@components/wallet/wallet.component'; import { WalletPreviewComponent } from '@components/wallet/wallet-preview.component'; import { AddressGraphComponent } from '@components/address-graph/address-graph.component'; import { TreasuriesGraphComponent } from '@components/treasuries/treasuries-graph/treasuries-graph.component'; +import { TreasuriesPieComponent } from '@components/treasuries/treasuries-pie/treasuries-pie.component'; import { UtxoGraphComponent } from '@components/utxo-graph/utxo-graph.component'; import { ActiveAccelerationBox } from '@components/acceleration/active-acceleration-box/active-acceleration-box.component'; import { AddressesTreemap } from '@components/addresses-treemap/addresses-treemap.component'; @@ -85,6 +86,7 @@ import { AsmStylerPipe } from '@app/shared/pipes/asm-styler/asm-styler.pipe'; BlockHealthGraphComponent, AddressGraphComponent, TreasuriesGraphComponent, + TreasuriesPieComponent, UtxoGraphComponent, ActiveAccelerationBox, AddressesTreemap,