Add support for USD in address history graph

This commit is contained in:
natsoni 2024-06-12 11:47:57 +02:00
parent 684ad9f0e6
commit 7bef8653b1
No known key found for this signature in database
GPG Key ID: C65917583181743B
4 changed files with 208 additions and 21 deletions

View File

@ -1,13 +1,16 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { echarts, EChartsOption } from '../../graphs/echarts'; import { echarts, EChartsOption } from '../../graphs/echarts';
import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs'; import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError, tap } from 'rxjs/operators';
import { AddressTxSummary, ChainStats } from '../../interfaces/electrs.interface'; import { AddressTxSummary, ChainStats } from '../../interfaces/electrs.interface';
import { ElectrsApiService } from '../../services/electrs-api.service'; import { ElectrsApiService } from '../../services/electrs-api.service';
import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { PriceService } from '../../services/price.service';
import { FiatCurrencyPipe } from '../../shared/pipes/fiat-currency.pipe';
import { FiatShortenerPipe } from '../../shared/pipes/fiat-shortener.pipe';
const periodSeconds = { const periodSeconds = {
'1d': (60 * 60 * 24), '1d': (60 * 60 * 24),
@ -45,6 +48,8 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
data: any[] = []; data: any[] = [];
hoverData: any[] = []; hoverData: any[] = [];
showFiat = false;
conversions: any;
subscription: Subscription; subscription: Subscription;
redraw$: BehaviorSubject<boolean> = new BehaviorSubject(false); redraw$: BehaviorSubject<boolean> = new BehaviorSubject(false);
@ -66,6 +71,9 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
private amountShortenerPipe: AmountShortenerPipe, private amountShortenerPipe: AmountShortenerPipe,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
private relativeUrlPipe: RelativeUrlPipe, private relativeUrlPipe: RelativeUrlPipe,
private priceService: PriceService,
private fiatCurrencyPipe: FiatCurrencyPipe,
private fiatShortenerPipe: FiatShortenerPipe
) {} ) {}
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
@ -86,10 +94,12 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`; this.error = `Failed to fetch address balance history: ${e?.status || ''} ${e?.statusText || 'unknown error'}`;
return of(null); return of(null);
}), }),
)) )),
]).subscribe(([redraw, addressSummary]) => { this.stateService.conversions$
]).subscribe(([redraw, addressSummary, conversions]) => {
if (addressSummary) { if (addressSummary) {
this.error = null; this.error = null;
this.conversions = conversions;
this.prepareChartOptions(addressSummary); this.prepareChartOptions(addressSummary);
} }
this.isLoading = false; this.isLoading = false;
@ -101,15 +111,36 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
} }
} }
prepareChartOptions(summary): void { prepareChartOptions(summary: AddressTxSummary[]) {
if (!summary || !this.stats) { if (!summary || !this.stats) {
return; return;
} }
this.priceService.getPriceByBulk$(summary.map(d => d.time), 'USD').pipe(
tap((prices) => {
if (prices.length !== summary.length) {
summary = summary.map(item => ({ ...item, price: 0 }));
} else {
summary = summary.map((item, index) => {
let price = 0;
if (prices[index].price) {
price = prices[index].price['USD'];
} else if (this.conversions['USD']) {
price = this.conversions['USD'];
}
return { ...item, price: price }
});
}
})
).subscribe();
let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum);
this.data = summary.map(d => { this.data = summary.map(d => {
const balance = total; const balance = total;
const fiatValue = balance * d.price / 100_000_000;
total -= d.value; total -= d.value;
return [d.time * 1000, balance, d]; d.fiatValue = d.value * d.price / 100_000_000;
return [d.time * 1000, balance, d, fiatValue];
}).reverse(); }).reverse();
if (this.period !== 'all') { if (this.period !== 'all') {
@ -130,6 +161,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
{ offset: 0, color: '#FDD835' }, { offset: 0, color: '#FDD835' },
{ offset: 1, color: '#FB8C00' }, { offset: 1, color: '#FB8C00' },
]), ]),
new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#4CAF50' },
{ offset: 1, color: '#1B5E20' },
]),
], ],
animation: false, animation: false,
grid: { grid: {
@ -138,6 +173,34 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
right: this.right, right: this.right,
left: this.left, left: this.left,
}, },
legend: {
data: [
{
name: $localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`,
inactiveColor: 'var(--grey)',
textStyle: {
color: 'white',
},
icon: 'roundRect',
},
{
name: 'Fiat',
inactiveColor: 'var(--grey)',
textStyle: {
color: 'white',
},
icon: 'roundRect',
}
],
selected: {
'Balance': !this.showFiat,
'Fiat': this.showFiat
},
selectedMode: 'single',
formatter: function (name) {
return name === 'Fiat' ? 'USD' : 'BTC';
}
},
tooltip: { tooltip: {
show: !this.isMobile(), show: !this.isMobile(),
trigger: 'axis', trigger: 'axis',
@ -160,19 +223,35 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}` ? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}`
: `${data.length} transactions`; : `${data.length} transactions`;
const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' });
const val = data.reduce((total, d) => total + d.data[2].value, 0); if (this.showFiat) {
const color = val === 0 ? '' : (val > 0 ? 'var(--green)' : 'var(--red)'); const val = data.reduce((total, d) => total + d.data[2].fiatValue, 0);
const symbol = val > 0 ? '+' : ''; const color = val === 0 ? '' : (val > 0 ? 'var(--green)' : 'var(--red)');
return ` const symbol = val > 0 ? '+' : '';
<div> return `
<span><b>${header}</b></span> <div>
<div style="text-align: right;"> <span><b>${header}</b></span>
<span style="color: ${color}">${symbol} ${(val / 100_000_000).toFixed(8)} BTC</span><br> <div style="text-align: right;">
<span>${(data[0].data[1] / 100_000_000).toFixed(8)} BTC</span> <span style="color: ${color}">${symbol} ${this.fiatCurrencyPipe.transform(val, null, 'USD')}</span><br>
<span>${this.fiatCurrencyPipe.transform(data[0].data[1], null, 'USD')}</span>
</div>
<span>${date}</span>
</div> </div>
<span>${date}</span> `;
</div> } else {
`; const val = data.reduce((total, d) => total + d.data[2].value, 0);
const color = val === 0 ? '' : (val > 0 ? 'var(--green)' : 'var(--red)');
const symbol = val > 0 ? '+' : '';
return `
<div>
<span><b>${header}</b></span>
<div style="text-align: right;">
<span style="color: ${color}">${symbol} ${(val / 100_000_000).toFixed(8)} BTC</span><br>
<span>${(data[0].data[1] / 100_000_000).toFixed(8)} BTC</span>
</div>
<span>${date}</span>
</div>
`;
}
}.bind(this) }.bind(this)
}, },
xAxis: { xAxis: {
@ -211,14 +290,44 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
}, },
min: this.period === 'all' ? 0 : 'dataMin' min: this.period === 'all' ? 0 : 'dataMin'
}, },
{
type: 'value',
position: 'left',
axisLabel: {
color: 'rgb(110, 112, 121)',
formatter: function(val) {
return this.fiatShortenerPipe.transform(val, null, 'USD');
}.bind(this)
},
splitLine: {
show: false,
},
min: this.period === 'all' ? 0 : 'dataMin'
},
], ],
series: [ series: [
{ {
name: $localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`, name: $localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`,
yAxisIndex: 0,
showSymbol: false, showSymbol: false,
symbol: 'circle', symbol: 'circle',
symbolSize: 8, symbolSize: 8,
data: this.data, data: this.data.map(d => [d[0], d[1], d[2]]),
areaStyle: {
opacity: 0.5,
},
triggerLineEvent: true,
type: 'line',
smooth: false,
step: 'end'
},
{
name: 'Fiat',
yAxisIndex: 1,
showSymbol: false,
symbol: 'circle',
symbolSize: 8,
data: this.data.map(d => [d[0], d[3], d[2]]),
areaStyle: { areaStyle: {
opacity: 0.5, opacity: 0.5,
}, },
@ -241,10 +350,15 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
this.hoverData = (e?.dataByCoordSys?.[0]?.dataByAxis?.[0]?.seriesDataIndices || []).map(indices => this.data[indices.dataIndex]); this.hoverData = (e?.dataByCoordSys?.[0]?.dataByAxis?.[0]?.seriesDataIndices || []).map(indices => this.data[indices.dataIndex]);
} }
onLegendSelectChanged(e) {
this.showFiat = e.name === 'Fiat';
}
onChartInit(ec) { onChartInit(ec) {
this.chartInstance = ec; this.chartInstance = ec;
this.chartInstance.on('showTip', this.onTooltip.bind(this)); this.chartInstance.on('showTip', this.onTooltip.bind(this));
this.chartInstance.on('click', 'series', this.onChartClick.bind(this)); this.chartInstance.on('click', 'series', this.onChartClick.bind(this));
this.chartInstance.on('legendselectchanged', this.onLegendSelectChanged.bind(this));
} }
ngOnDestroy(): void { ngOnDestroy(): void {

View File

@ -150,7 +150,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
this.transactions.forEach((tx) => { this.transactions.forEach((tx) => {
if (!this.blockTime) { if (!this.blockTime) {
if (tx.status.block_time) { if (tx.status.block_time) {
this.priceService.getBlockPrice$(tx.status.block_time, confirmedTxs < 10, this.currency).pipe( this.priceService.getBlockPrice$(tx.status.block_time, confirmedTxs < 3, this.currency).pipe(
tap((price) => tx['price'] = price), tap((price) => tx['price'] = price),
).subscribe(); ).subscribe();
} }
@ -235,7 +235,7 @@ export class TransactionsListComponent implements OnInit, OnChanges {
} }
if (!this.blockTime && tx.status.block_time && this.currency) { if (!this.blockTime && tx.status.block_time && this.currency) {
this.priceService.getBlockPrice$(tx.status.block_time, confirmedTxs < 10, this.currency).pipe( this.priceService.getBlockPrice$(tx.status.block_time, confirmedTxs < 3, this.currency).pipe(
tap((price) => tx['price'] = price), tap((price) => tx['price'] = price),
).subscribe(); ).subscribe();
} }

View File

@ -156,6 +156,8 @@ export interface AddressTxSummary {
value: number; value: number;
height: number; height: number;
time: number; time: number;
price?: number;
fiatValue?: number;
} }
export interface ChainStats { export interface ChainStats {

View File

@ -249,4 +249,75 @@ export class PriceService {
); );
} }
} }
}
getPriceByBulk$(timestamps: number[], currency: string): Observable<Price[]> {
if (this.stateService.env.BASE_MODULE !== 'mempool' || !this.stateService.env.HISTORICAL_PRICE) {
return of([]);
}
const now = new Date().getTime() / 1000;
if (!this.priceObservable$ || (this.priceObservable$ && (now - this.lastPriceHistoryUpdate > 3600 || currency !== this.lastQueriedHistoricalCurrency || this.networkChangedSinceLastQuery))) {
this.priceObservable$ = this.apiService.getHistoricalPrice$(undefined, currency).pipe(shareReplay());
this.lastPriceHistoryUpdate = new Date().getTime() / 1000;
this.lastQueriedHistoricalCurrency = currency;
this.networkChangedSinceLastQuery = false;
}
return this.priceObservable$.pipe(
map((conversion) => {
if (!conversion) {
return undefined;
}
const historicalPrice = {
prices: {},
exchangeRates: conversion.exchangeRates,
};
for (const price of conversion.prices) {
historicalPrice.prices[price.time] = this.stateService.env.ADDITIONAL_CURRENCIES ? {
USD: price.USD, EUR: price.EUR, GBP: price.GBP, CAD: price.CAD, CHF: price.CHF, AUD: price.AUD,
JPY: price.JPY, BGN: price.BGN, BRL: price.BRL, CNY: price.CNY, CZK: price.CZK, DKK: price.DKK,
HKD: price.HKD, HRK: price.HRK, HUF: price.HUF, IDR: price.IDR, ILS: price.ILS, INR: price.INR,
ISK: price.ISK, KRW: price.KRW, MXN: price.MXN, MYR: price.MYR, NOK: price.NOK, NZD: price.NZD,
PHP: price.PHP, PLN: price.PLN, RON: price.RON, RUB: price.RUB, SEK: price.SEK, SGD: price.SGD,
THB: price.THB, TRY: price.TRY, ZAR: price.ZAR
} : {
USD: price.USD, EUR: price.EUR, GBP: price.GBP, CAD: price.CAD, CHF: price.CHF, AUD: price.AUD, JPY: price.JPY
};
}
const priceTimestamps = Object.keys(historicalPrice.prices).map(Number);
priceTimestamps.push(Number.MAX_SAFE_INTEGER);
priceTimestamps.sort((a, b) => b - a);
const prices: Price[] = [];
for (const timestamp of timestamps) {
let left = 0;
let right = priceTimestamps.length - 1;
let match = -1;
// Binary search to find the closest larger element
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (priceTimestamps[mid] > timestamp) {
match = mid;
left = mid + 1;
} else {
right = mid - 1;
}
}
if (match !== -1) {
const priceTimestamp = priceTimestamps[match];
prices.push({
price: historicalPrice.prices[priceTimestamp],
exchangeRates: historicalPrice.exchangeRates,
});
}
}
return prices;
}));
}
}