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 842e96cdd..86090a0d2 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 { 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 } 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'; 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), @@ -44,7 +47,13 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { @Input() widget: boolean = false; data: any[] = []; + fiatData: any[] = []; hoverData: any[] = []; + 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); @@ -66,6 +75,10 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { private amountShortenerPipe: AmountShortenerPipe, private cd: ChangeDetectorRef, private relativeUrlPipe: RelativeUrlPipe, + private priceService: PriceService, + private fiatCurrencyPipe: FiatCurrencyPipe, + private fiatShortenerPipe: FiatShortenerPipe, + private zone: NgZone, ) {} ngOnChanges(changes: SimpleChanges): void { @@ -86,10 +99,39 @@ 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$ + ]).pipe( + switchMap(([redraw, addressSummary, conversions]) => { + this.conversions = conversions; + if (addressSummary) { + let extendedSummary = this.extendSummary(addressSummary); + return this.priceService.getPriceByBulk$(extendedSummary.map(d => d.time), 'USD').pipe( + tap((prices) => { + if (prices.length !== extendedSummary.length) { + extendedSummary = extendedSummary.map(item => ({ ...item, price: 0 })); + } else { + extendedSummary = extendedSummary.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, extendedSummary, conversions]) + ) + } else { + return of([redraw, addressSummary, conversions]); + } + }) + ).subscribe(([redraw, addressSummary, conversions]) => { if (addressSummary) { this.error = null; + this.allowZoom = addressSummary.length > 100 && !this.widget; this.prepareChartOptions(addressSummary); } this.isLoading = false; @@ -101,25 +143,37 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { } } - prepareChartOptions(summary): void { + prepareChartOptions(summary: AddressTxSummary[]) { if (!summary || !this.stats) { return; } + let total = (this.stats.funded_txo_sum - this.stats.spent_txo_sum); - this.data = summary.map(d => { - const balance = total; - total -= d.value; - return [d.time * 1000, balance, d]; + const processData = summary.map(d => { + const balance = total; + const fiatBalance = total * d.price / 100_000_000; + total -= d.value; + return { + time: d.time * 1000, + balance, + fiatBalance, + d + }; }).reverse(); + + 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.data.push( - {value: [now, this.stats.funded_txo_sum - this.stats.spent_txo_sum], symbol: 'none', tooltip: { show: false }} - ); + 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 }} + ); 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); @@ -130,14 +184,42 @@ 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: { top: 20, - bottom: 20, + bottom: this.allowZoom ? 65 : 20, 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: this.selected, + formatter: function (name) { + return name === 'Fiat' ? 'USD' : 'BTC'; + } + }, tooltip: { show: !this.isMobile(), trigger: 'axis', @@ -152,27 +234,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`; + tooltip += `${header}`; + } + 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 -
- ${date} + + 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: { @@ -211,10 +330,24 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { }, min: this.period === 'all' ? 0 : 'dataMin' }, + { + type: 'value', + 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, @@ -226,14 +359,58 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { type: 'line', smooth: false, step: 'end' + }, + { + name: 'Fiat', + yAxisIndex: 1, + showSymbol: false, + symbol: 'circle', + symbolSize: 8, + data: this.fiatData, + areaStyle: { + opacity: 0.5, + }, + triggerLineEvent: true, + type: 'line', + smooth: false, + 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 }; } 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]); + } + }); } } @@ -241,10 +418,38 @@ export class AddressGraphComponent implements OnChanges, OnDestroy { this.hoverData = (e?.dataByCoordSys?.[0]?.dataByAxis?.[0]?.seriesDataIndices || []).map(indices => this.data[indices.dataIndex]); } + onLegendSelectChanged(e) { + 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) { 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 { @@ -256,4 +461,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/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, 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..726649090 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -156,6 +156,7 @@ export interface AddressTxSummary { value: number; height: number; time: number; + price?: 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 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' }