From 7bef8653b11d88630be7d219b85c07cf6cadb353 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 12 Jun 2024 11:47:57 +0200 Subject: [PATCH 1/7] Add support for USD in address history graph --- .../address-graph/address-graph.component.ts | 150 +++++++++++++++--- .../transactions-list.component.ts | 4 +- .../src/app/interfaces/electrs.interface.ts | 2 + frontend/src/app/services/price.service.ts | 73 ++++++++- 4 files changed, 208 insertions(+), 21 deletions(-) diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index 842e96cdd..2ba0cc0a6 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -1,13 +1,16 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { echarts, EChartsOption } from '../../graphs/echarts'; 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 { ElectrsApiService } from '../../services/electrs-api.service'; import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; import { Router } from '@angular/router'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; 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 = { '1d': (60 * 60 * 24), @@ -45,6 +48,8 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { data: any[] = []; hoverData: any[] = []; + showFiat = false; + conversions: any; subscription: Subscription; redraw$: BehaviorSubject = new BehaviorSubject(false); @@ -66,6 +71,9 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { private amountShortenerPipe: AmountShortenerPipe, private cd: ChangeDetectorRef, private relativeUrlPipe: RelativeUrlPipe, + private priceService: PriceService, + private fiatCurrencyPipe: FiatCurrencyPipe, + private fiatShortenerPipe: FiatShortenerPipe ) {} 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'}`; return of(null); }), - )) - ]).subscribe(([redraw, addressSummary]) => { + )), + this.stateService.conversions$ + ]).subscribe(([redraw, addressSummary, conversions]) => { if (addressSummary) { this.error = null; + this.conversions = conversions; this.prepareChartOptions(addressSummary); } this.isLoading = false; @@ -101,15 +111,36 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { } } - prepareChartOptions(summary): void { + prepareChartOptions(summary: AddressTxSummary[]) { if (!summary || !this.stats) { 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); this.data = summary.map(d => { const balance = total; + const fiatValue = balance * d.price / 100_000_000; 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(); if (this.period !== 'all') { @@ -130,6 +161,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { { offset: 0, color: '#FDD835' }, { offset: 1, color: '#FB8C00' }, ]), + new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#4CAF50' }, + { offset: 1, color: '#1B5E20' }, + ]), ], animation: false, grid: { @@ -138,6 +173,34 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { right: this.right, 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: { show: !this.isMobile(), 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.length} transactions`; 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); - const color = val === 0 ? '' : (val > 0 ? 'var(--green)' : 'var(--red)'); - const symbol = val > 0 ? '+' : ''; - return ` -
- ${header} -
- ${symbol} ${(val / 100_000_000).toFixed(8)} BTC
- ${(data[0].data[1] / 100_000_000).toFixed(8)} BTC + if (this.showFiat) { + const val = data.reduce((total, d) => total + d.data[2].fiatValue, 0); + const color = val === 0 ? '' : (val > 0 ? 'var(--green)' : 'var(--red)'); + const symbol = val > 0 ? '+' : ''; + return ` +
+ ${header} +
+ ${symbol} ${this.fiatCurrencyPipe.transform(val, null, 'USD')}
+ ${this.fiatCurrencyPipe.transform(data[0].data[1], null, 'USD')} +
+ ${date}
- ${date} -
- `; + `; + } 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 ` +
+ ${header} +
+ ${symbol} ${(val / 100_000_000).toFixed(8)} BTC
+ ${(data[0].data[1] / 100_000_000).toFixed(8)} BTC +
+ ${date} +
+ `; + } }.bind(this) }, xAxis: { @@ -211,14 +290,44 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { }, 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: [ { name: $localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`, + yAxisIndex: 0, showSymbol: false, symbol: 'circle', 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: { 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]); } + onLegendSelectChanged(e) { + this.showFiat = e.name === 'Fiat'; + } + onChartInit(ec) { this.chartInstance = ec; this.chartInstance.on('showTip', this.onTooltip.bind(this)); this.chartInstance.on('click', 'series', this.onChartClick.bind(this)); + this.chartInstance.on('legendselectchanged', this.onLegendSelectChanged.bind(this)); } ngOnDestroy(): void { diff --git a/frontend/src/app/components/transactions-list/transactions-list.component.ts b/frontend/src/app/components/transactions-list/transactions-list.component.ts index 688c941b0..316a6ab85 100644 --- a/frontend/src/app/components/transactions-list/transactions-list.component.ts +++ b/frontend/src/app/components/transactions-list/transactions-list.component.ts @@ -150,7 +150,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { this.transactions.forEach((tx) => { if (!this.blockTime) { 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), ).subscribe(); } @@ -235,7 +235,7 @@ export class TransactionsListComponent implements OnInit, OnChanges { } 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), ).subscribe(); } diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index ab96488fe..f38a16a70 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -156,6 +156,8 @@ export interface AddressTxSummary { value: number; height: number; time: number; + price?: number; + fiatValue?: number; } export interface ChainStats { diff --git a/frontend/src/app/services/price.service.ts b/frontend/src/app/services/price.service.ts index a27c65df8..c342796e0 100644 --- a/frontend/src/app/services/price.service.ts +++ b/frontend/src/app/services/price.service.ts @@ -249,4 +249,75 @@ export class PriceService { ); } } -} + + getPriceByBulk$(timestamps: number[], currency: string): Observable { + 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; + })); + } +} \ No newline at end of file From 2b44055fc718f04f2d5458f8a8a024941a0b0495 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 12 Jun 2024 12:57:13 +0200 Subject: [PATCH 2/7] Add support for zooming in address balance graph --- .../address-graph.component.html | 2 +- .../address-graph.component.scss | 1 - .../address-graph/address-graph.component.ts | 26 ++++++++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/address-graph/address-graph.component.html b/frontend/src/app/components/address-graph/address-graph.component.html index 32e16913a..c9dd072c8 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.html +++ b/frontend/src/app/components/address-graph/address-graph.component.html @@ -2,7 +2,7 @@
-
diff --git a/frontend/src/app/components/address-graph/address-graph.component.scss b/frontend/src/app/components/address-graph/address-graph.component.scss index 62393644b..1b5e0320d 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.scss +++ b/frontend/src/app/components/address-graph/address-graph.component.scss @@ -46,7 +46,6 @@ display: flex; flex: 1; width: 100%; - padding-bottom: 10px; padding-right: 10px; } .chart-widget { diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index 2ba0cc0a6..e496af84b 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -50,6 +50,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { hoverData: any[] = []; showFiat = false; conversions: any; + allowZoom: boolean = false; subscription: Subscription; redraw$: BehaviorSubject = new BehaviorSubject(false); @@ -100,6 +101,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { if (addressSummary) { this.error = null; this.conversions = conversions; + this.allowZoom = addressSummary.length > 100 && !this.widget; this.prepareChartOptions(addressSummary); } this.isLoading = false; @@ -169,7 +171,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { animation: false, grid: { top: 20, - bottom: 20, + bottom: this.allowZoom ? 65 : 20, right: this.right, left: this.left, }, @@ -337,6 +339,28 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { step: 'end' } ], + dataZoom: this.allowZoom ? [{ + type: 'inside', + realtime: true, + zoomLock: true, + maxSpan: 100, + minSpan: 5, + moveOnMouseMove: false, + }, { + showDetail: false, + show: true, + type: 'slider', + brushSelect: false, + realtime: true, + left: this.left, + right: this.right, + selectedDataBackground: { + lineStyle: { + color: '#fff', + opacity: 0.45, + }, + }, + }] : undefined }; } From 824bf5fc636d495fa9107ed7122fdcd8f3577af2 Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 12 Jun 2024 16:57:19 +0200 Subject: [PATCH 3/7] Fix price fetching causing race condition --- .../address-graph/address-graph.component.ts | 76 ++++++++++++------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index e496af84b..c17e6d3b8 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { echarts, EChartsOption } from '../../graphs/echarts'; import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; import { AddressTxSummary, ChainStats } from '../../interfaces/electrs.interface'; import { ElectrsApiService } from '../../services/electrs-api.service'; import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; @@ -47,6 +47,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { @Input() widget: boolean = false; data: any[] = []; + fiatData: any[] = []; hoverData: any[] = []; showFiat = false; conversions: any; @@ -97,7 +98,32 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { }), )), this.stateService.conversions$ - ]).subscribe(([redraw, addressSummary, conversions]) => { + ]).pipe( + switchMap(([redraw, addressSummary, conversions]) => { + if (addressSummary) { + return this.priceService.getPriceByBulk$(addressSummary.map(d => d.time), 'USD').pipe( + tap((prices) => { + if (prices.length !== addressSummary.length) { + addressSummary = addressSummary.map(item => ({ ...item, price: 0 })); + } else { + addressSummary = addressSummary.map((item, index) => { + let price = 0; + if (prices[index].price) { + price = prices[index].price['USD']; + } else if (this.conversions && this.conversions['USD']) { + price = this.conversions['USD']; + } + return { ...item, price: price } + }); + } + }), + map(() => [redraw, addressSummary, conversions]) + ) + } else { + return of([redraw, addressSummary, conversions]); + } + }) + ).subscribe(([redraw, addressSummary, conversions]) => { if (addressSummary) { this.error = null; this.conversions = conversions; @@ -117,41 +143,35 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { if (!summary || !this.stats) { 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); - this.data = summary.map(d => { - const balance = total; - const fiatValue = balance * d.price / 100_000_000; - total -= d.value; - d.fiatValue = d.value * d.price / 100_000_000; - return [d.time * 1000, balance, d, fiatValue]; + const processData = summary.map(d => { + const balance = total; + const fiatBalance = total * d.price / 100_000_000; + total -= d.value; + d.fiatValue = d.value * d.price / 100_000_000; + return { + time: d.time * 1000, + balance, + fiatBalance, + d + }; }).reverse(); + + this.data = processData.map(({ time, balance, d }) => [time, balance, d]); + this.fiatData = processData.map(({ time, fiatBalance, d }) => [time, fiatBalance, d]); if (this.period !== 'all') { const now = Date.now(); const start = now - (periodSeconds[this.period] * 1000); this.data = this.data.filter(d => d[0] >= start); + this.fiatData = this.fiatData.filter(d => d[0] >= start); this.data.push( {value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }} ); + this.fiatData.push( + {value: [now, this.fiatData[this.fiatData.length - 1][1]], symbol: 'none', tooltip: { show: false }} + ); } const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0); @@ -314,7 +334,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { showSymbol: false, symbol: 'circle', symbolSize: 8, - data: this.data.map(d => [d[0], d[1], d[2]]), + data: this.data, areaStyle: { opacity: 0.5, }, @@ -329,7 +349,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { showSymbol: false, symbol: 'circle', symbolSize: 8, - data: this.data.map(d => [d[0], d[3], d[2]]), + data: this.fiatData, areaStyle: { opacity: 0.5, }, From 60a30aaede8afb0df3908493d0c10597378850ea Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 12 Jun 2024 20:01:48 +0200 Subject: [PATCH 4/7] Allow to open transaction in new tab/page when click on address graph --- .../address-graph/address-graph.component.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index c17e6d3b8..d65dc2ad7 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, LOCALE_ID, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { echarts, EChartsOption } from '../../graphs/echarts'; import { BehaviorSubject, Observable, Subscription, combineLatest, of } from 'rxjs'; import { catchError, map, switchMap, tap } from 'rxjs/operators'; @@ -75,7 +75,8 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { private relativeUrlPipe: RelativeUrlPipe, private priceService: PriceService, private fiatCurrencyPipe: FiatCurrencyPipe, - private fiatShortenerPipe: FiatShortenerPipe + private fiatShortenerPipe: FiatShortenerPipe, + private zone: NgZone, ) {} ngOnChanges(changes: SimpleChanges): void { @@ -386,7 +387,14 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { onChartClick(e) { if (this.hoverData?.length && this.hoverData[0]?.[2]?.txid) { - this.router.navigate([this.relativeUrlPipe.transform('/tx/'), this.hoverData[0][2].txid]); + this.zone.run(() => { + const url = this.relativeUrlPipe.transform(`/tx/${this.hoverData[0][2].txid}`); + if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) { + window.open(url); + } else { + this.router.navigate([url]); + } + }); } } From 2156924d7ea5aa1f7a70f0b638520162120b031a Mon Sep 17 00:00:00 2001 From: natsoni Date: Wed, 12 Jun 2024 20:02:54 +0200 Subject: [PATCH 5/7] Prevent address txs widget to send too many price requests --- .../address-transactions-widget.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts index c3fc4260e..998d269ba 100644 --- a/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts +++ b/frontend/src/app/components/address-transactions-widget/address-transactions-widget.component.ts @@ -58,7 +58,7 @@ export class AddressTransactionsWidgetComponent implements OnInit, OnChanges, On return summary?.slice(0, 6); }), switchMap(txs => { - return (zip(txs.map(tx => this.priceService.getBlockPrice$(tx.time, true, this.currency).pipe( + return (zip(txs.map(tx => this.priceService.getBlockPrice$(tx.time, txs.length < 3, this.currency).pipe( map(price => { return { ...tx, From 26968605cc791dfc899af80617e770fe0704d1b5 Mon Sep 17 00:00:00 2001 From: natsoni Date: Thu, 13 Jun 2024 15:03:22 +0200 Subject: [PATCH 6/7] Display both BTC and USD in address history graph --- .../address-graph/address-graph.component.ts | 173 ++++++++++++------ .../src/app/interfaces/electrs.interface.ts | 1 - .../app/shared/pipes/fiat-shortener.pipe.ts | 2 +- 3 files changed, 118 insertions(+), 58 deletions(-) diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index d65dc2ad7..f4967f740 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -49,9 +49,11 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { data: any[] = []; fiatData: any[] = []; hoverData: any[] = []; - showFiat = false; conversions: any; allowZoom: boolean = false; + initialRight = this.right; + initialLeft = this.left; + selected = { [$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`]: true, 'Fiat': false }; subscription: Subscription; redraw$: BehaviorSubject = new BehaviorSubject(false); @@ -101,13 +103,15 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { this.stateService.conversions$ ]).pipe( switchMap(([redraw, addressSummary, conversions]) => { + this.conversions = conversions; if (addressSummary) { - return this.priceService.getPriceByBulk$(addressSummary.map(d => d.time), 'USD').pipe( + let extendedSummary = this.extendSummary(addressSummary); + return this.priceService.getPriceByBulk$(extendedSummary.map(d => d.time), 'USD').pipe( tap((prices) => { - if (prices.length !== addressSummary.length) { - addressSummary = addressSummary.map(item => ({ ...item, price: 0 })); + if (prices.length !== extendedSummary.length) { + extendedSummary = extendedSummary.map(item => ({ ...item, price: 0 })); } else { - addressSummary = addressSummary.map((item, index) => { + extendedSummary = extendedSummary.map((item, index) => { let price = 0; if (prices[index].price) { price = prices[index].price['USD']; @@ -118,7 +122,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { }); } }), - map(() => [redraw, addressSummary, conversions]) + map(() => [redraw, extendedSummary, conversions]) ) } else { return of([redraw, addressSummary, conversions]); @@ -127,7 +131,6 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { ).subscribe(([redraw, addressSummary, conversions]) => { if (addressSummary) { this.error = null; - this.conversions = conversions; this.allowZoom = addressSummary.length > 100 && !this.widget; this.prepareChartOptions(addressSummary); } @@ -150,7 +153,6 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { const balance = total; const fiatBalance = total * d.price / 100_000_000; total -= d.value; - d.fiatValue = d.value * d.price / 100_000_000; return { time: d.time * 1000, balance, @@ -159,21 +161,18 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { }; }).reverse(); - this.data = processData.map(({ time, balance, d }) => [time, balance, d]); - this.fiatData = processData.map(({ time, fiatBalance, d }) => [time, fiatBalance, d]); + this.data = processData.filter(({ d }) => d.txid !== undefined).map(({ time, balance, d }) => [time, balance, d]); + this.fiatData = processData.map(({ time, fiatBalance, balance, d }) => [time, fiatBalance, d, balance]); + const now = Date.now(); if (this.period !== 'all') { - const now = Date.now(); const start = now - (periodSeconds[this.period] * 1000); this.data = this.data.filter(d => d[0] >= start); this.fiatData = this.fiatData.filter(d => d[0] >= start); - this.data.push( - {value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }} - ); - this.fiatData.push( - {value: [now, this.fiatData[this.fiatData.length - 1][1]], symbol: 'none', tooltip: { show: false }} - ); } + this.data.push( + {value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }} + ); const maxValue = this.data.reduce((acc, d) => Math.max(acc, Math.abs(d[1] ?? d.value[1])), 0); const minValue = this.data.reduce((acc, d) => Math.min(acc, Math.abs(d[1] ?? d.value[1])), maxValue); @@ -215,11 +214,7 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { icon: 'roundRect', } ], - selected: { - 'Balance': !this.showFiat, - 'Fiat': this.showFiat - }, - selectedMode: 'single', + selected: this.selected, formatter: function (name) { return name === 'Fiat' ? 'USD' : 'BTC'; } @@ -238,43 +233,64 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { align: 'left', }, borderColor: '#000', - formatter: function (data): string { - if (!data?.length || !data[0]?.data?.[2]?.txid) { + formatter: function (data) { + const btcData = data.filter(d => d.seriesName !== 'Fiat'); + const fiatData = data.filter(d => d.seriesName === 'Fiat'); + data = btcData.length ? btcData : fiatData; + if ((!btcData.length || !btcData[0]?.data?.[2]?.txid) && !fiatData.length) { return ''; } - const header = data.length === 1 + let tooltip = '
'; + + const hasTx = data[0].data[2].txid; + if (hasTx) { + const header = data.length === 1 ? `${data[0].data[2].txid.slice(0, 6)}...${data[0].data[2].txid.slice(-6)}` : `${data.length} transactions`; - const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); - if (this.showFiat) { - const val = data.reduce((total, d) => total + d.data[2].fiatValue, 0); - const color = val === 0 ? '' : (val > 0 ? 'var(--green)' : 'var(--red)'); - const symbol = val > 0 ? '+' : ''; - return ` -
- ${header} -
- ${symbol} ${this.fiatCurrencyPipe.transform(val, null, 'USD')}
- ${this.fiatCurrencyPipe.transform(data[0].data[1], null, 'USD')} -
- ${date} -
- `; - } 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 ` -
- ${header} -
- ${symbol} ${(val / 100_000_000).toFixed(8)} BTC
- ${(data[0].data[1] / 100_000_000).toFixed(8)} BTC -
- ${date} -
- `; + tooltip += `${header}`; } + + const date = new Date(data[0].data[0]).toLocaleTimeString(this.locale, { year: 'numeric', month: 'short', day: 'numeric' }); + + tooltip += `
+
`; + + const formatBTC = (val, decimal) => (val / 100_000_000).toFixed(decimal); + const formatFiat = (val) => this.fiatCurrencyPipe.transform(val, null, 'USD'); + + const btcVal = btcData.reduce((total, d) => total + d.data[2].value, 0); + const fiatVal = fiatData.reduce((total, d) => total + d.data[2].value * d.data[2].price / 100_000_000, 0); + const btcColor = btcVal === 0 ? '' : (btcVal > 0 ? 'var(--green)' : 'var(--red)'); + const fiatColor = fiatVal === 0 ? '' : (fiatVal > 0 ? 'var(--green)' : 'var(--red)'); + const btcSymbol = btcVal > 0 ? '+' : ''; + const fiatSymbol = fiatVal > 0 ? '+' : ''; + + if (btcData.length && fiatData.length) { + tooltip += `
+ ${btcSymbol} ${formatBTC(btcVal, 4)} BTC + ${fiatSymbol} ${formatFiat(fiatVal)} +
+
+ ${formatBTC(btcData[0].data[1], 4)} BTC + ${formatFiat(fiatData[0].data[1])} +
`; + } else if (btcData.length) { + tooltip += `${btcSymbol} ${formatBTC(btcVal, 8)} BTC
+ ${formatBTC(data[0].data[1], 8)} BTC`; + } else { + if (this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`]) { + tooltip += `
+ ${formatBTC(data[0].data[3], 4)} BTC + ${formatFiat(data[0].data[1])} +
`; + } else { + tooltip += `${hasTx ? `${fiatSymbol} ${formatFiat(fiatVal)}
` : ''} + ${formatFiat(data[0].data[1])}`; + } + } + + tooltip += `
${date}
`; + return tooltip; }.bind(this) }, xAxis: { @@ -315,7 +331,6 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { }, { type: 'value', - position: 'left', axisLabel: { color: 'rgb(110, 112, 121)', formatter: function(val) { @@ -403,7 +418,30 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { } onLegendSelectChanged(e) { - this.showFiat = e.name === 'Fiat'; + this.selected = e.selected; + this.right = this.selected['Fiat'] ? +this.initialRight + 40 : this.initialRight; + this.left = this.selected[$localize`:@@7e69426bd97a606d8ae6026762858e6e7c86a1fd:Balance`] ? this.initialLeft : +this.initialLeft - 40; + + this.chartOptions = { + grid: { + right: this.right, + left: this.left, + }, + legend: { + selected: this.selected, + }, + dataZoom: this.allowZoom ? [{ + left: this.left, + right: this.right, + }, { + left: this.left, + right: this.right, + }] : undefined + }; + + if (this.chartInstance) { + this.chartInstance.setOption(this.chartOptions); + } } onChartInit(ec) { @@ -422,4 +460,27 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { isMobile() { return (window.innerWidth <= 767.98); } + + extendSummary(summary) { + let extendedSummary = summary.slice(); + + // Add a point at today's date to make the graph end at the current time + extendedSummary.unshift({ time: Date.now() / 1000, value: 0 }); + extendedSummary.reverse(); + + let oneHour = 60 * 60; + // Fill gaps longer than interval + for (let i = 0; i < extendedSummary.length - 1; i++) { + let hours = Math.floor((extendedSummary[i + 1].time - extendedSummary[i].time) / oneHour); + if (hours > 1) { + for (let j = 1; j < hours; j++) { + let newTime = extendedSummary[i].time + oneHour * j; + extendedSummary.splice(i + j, 0, { time: newTime, value: 0 }); + } + i += hours - 1; + } + } + + return extendedSummary.reverse(); + } } diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index f38a16a70..726649090 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -157,7 +157,6 @@ export interface AddressTxSummary { height: number; time: number; price?: number; - fiatValue?: number; } export interface ChainStats { diff --git a/frontend/src/app/shared/pipes/fiat-shortener.pipe.ts b/frontend/src/app/shared/pipes/fiat-shortener.pipe.ts index 93ab5cf8f..4ce171054 100644 --- a/frontend/src/app/shared/pipes/fiat-shortener.pipe.ts +++ b/frontend/src/app/shared/pipes/fiat-shortener.pipe.ts @@ -31,7 +31,7 @@ export class FiatShortenerPipe implements PipeTransform { { value: 1, symbol: '' }, { value: 1e3, symbol: 'k' }, { value: 1e6, symbol: 'M' }, - { value: 1e9, symbol: 'G' }, + { value: 1e9, symbol: 'B' }, { value: 1e12, symbol: 'T' }, { value: 1e15, symbol: 'P' }, { value: 1e18, symbol: 'E' } From 25e9741fc2b74d6a394c822a41294ab8ef989163 Mon Sep 17 00:00:00 2001 From: natsoni Date: Sat, 22 Jun 2024 15:01:42 +0900 Subject: [PATCH 7/7] Set same start time for BTC and USD lines --- .../app/components/address-graph/address-graph.component.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/address-graph/address-graph.component.ts b/frontend/src/app/components/address-graph/address-graph.component.ts index f4967f740..86090a0d2 100644 --- a/frontend/src/app/components/address-graph/address-graph.component.ts +++ b/frontend/src/app/components/address-graph/address-graph.component.ts @@ -168,7 +168,8 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { if (this.period !== 'all') { const start = now - (periodSeconds[this.period] * 1000); this.data = this.data.filter(d => d[0] >= start); - this.fiatData = this.fiatData.filter(d => d[0] >= start); + const startFiat = this.data[0]?.[0] ?? start; // Make sure USD data starts at the same time as BTC data + this.fiatData = this.fiatData.filter(d => d[0] >= startFiat); } this.data.push( {value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }}