add pie chart to treasuries dashboard

This commit is contained in:
Mononaut
2025-05-02 22:28:30 +00:00
parent 2ec8cdda93
commit a5fe71c7a3
8 changed files with 509 additions and 57 deletions

View File

@@ -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 += `<div><b style="color: white; margin-left: 2px">${date}</b><br>`;
// 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;
@@ -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 = `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${markerColor};"></span>`;

View File

@@ -0,0 +1,7 @@
<div>
<div class="container pb-lg-0">
<div class="chart-widget" *browserOnly [style]="{ height: (height + 'px'), opacity: isLoading ? 0.5 : 1 }" echarts [initOpts]="chartInitOptions" [options]="chartOptions"
(chartInit)="onChartInit($event)">
</div>
</div>
</div>

View File

@@ -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);
}
}
}

View File

@@ -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<string, WalletStats>;
@Input() walletSummaries$: Observable<Record<string, AddressTxSummary[]>>;
@Input() selectedWallets: Record<string, boolean> = {};
@Input() wallets: string[] = [];
@Output() navigateToWallet: EventEmitter<string> = new EventEmitter();
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
chartInstance: any = undefined;
error: any;
isLoading = true;
subscription: Subscription;
redraw$: BehaviorSubject<boolean> = new BehaviorSubject(false);
walletBalance: Record<string, number> = {};
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<string, AddressTxSummary[]>): 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 `<b style="color: white">${entry.id} (${entry.share.toFixed(2)}%)</b><br>
${formatNumber(entry.balance / 100_000_000, this.locale, '1.3-3')} BTC<br>`;
}
},
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 `<b style="color: white">${otherEntry.id} (${otherEntry.share}%)</b><br>
${formatNumber(otherEntry.balance, this.locale, '1.3-3')}<br>`;
}
},
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);
}
}

View File

@@ -1,5 +1,6 @@
<div class="container-xl dashboard-container">
<div class="row">
<div class="row row-cols-1 row-cols-md-2">
<div class="col">
<div class="card">
<div class="card-body">
@@ -12,33 +13,54 @@
<th class="table-cell-value" i18n="dashboard.treasury-leaderboard.value">USD Value</th>
</thead>
<tbody *ngIf="walletStats$ | async as walletStats; else leaderboardSkeleton">
<tr *ngFor="let wallet of sortedWallets$ | async; let i = index"
(click)="navigateToWallet(wallet)"
class="clickable-row">
<td class="table-cell-position">
<div class="position-container">
<div class="color-swatch" [style.background-color]="getWalletColor(wallet)"></div>
<span class="position-number">{{i + 1}}</span>
</div>
</td>
<td class="table-cell-name">{{wallet}}</td>
<td class="table-cell-balance">
<app-amount [satoshis]="walletStats[wallet]?.balance || 0" digitsInfo="1.2-4" [noFiat]="true"></app-amount>
</td>
<td class="table-cell-value">
<app-fiat [value]="walletStats[wallet]?.balance || 0" digitsInfo="1.0-0"></app-fiat>
</td>
</tr>
<ng-container *ngIf="(sortedWallets$ | async) as sortedWallets">
<tr *ngFor="let wallet of sortedWallets.slice(0, 7); let i = index"
(click)="navigateToWallet(wallet)"
class="clickable-row">
<td class="table-cell-position">
<div class="position-container">
<div class="color-swatch" [style.background-color]="getWalletColor(wallet)"></div>
<span class="position-number">{{i + 1}}</span>
</div>
</td>
<td class="table-cell-name">{{wallet}}</td>
<td class="table-cell-balance">
<app-amount [satoshis]="walletStats[wallet]?.balance || 0" digitsInfo="1.2-4" [noFiat]="true"></app-amount>
</td>
<td class="table-cell-value">
<app-fiat [value]="walletStats[wallet]?.balance || 0" digitsInfo="1.0-0"></app-fiat>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="card">
<div class="card-body pl-2 pr-2">
<h5 class="card-title" i18n="dashboard.treasury-distribution">Treasury Distribution</h5>
<div *ngIf="walletStats$ | async as walletStats">
<app-treasuries-pie
[walletStats]="walletStats"
[walletSummaries$]="walletSummaries$"
[selectedWallets]="selectedWallets"
[wallets]="currentSortedWallets"
[height]="375"
mode="relative"
(navigateToWallet)="onNavigateToWallet($event)"
/>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title" i18n="dashboard.balance-history">Balance History</h5>
<div *ngIf="walletStats$ | async as walletStats">
<app-treasuries-graph
[walletStats]="walletStats"

View File

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

View File

@@ -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',
@@ -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 {

View File

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