Merge pull request #5548 from mempool/mononaut/utxo-chart-optimization

utxo chart optimization
This commit is contained in:
softsimon 2024-09-25 11:50:36 +08:00 committed by GitHub
commit 758122db5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 518 additions and 334 deletions

View File

@ -21,6 +21,7 @@ import { StorageService } from './services/storage.service';
import { HttpCacheInterceptor } from './services/http-cache.interceptor'; import { HttpCacheInterceptor } from './services/http-cache.interceptor';
import { LanguageService } from './services/language.service'; import { LanguageService } from './services/language.service';
import { ThemeService } from './services/theme.service'; import { ThemeService } from './services/theme.service';
import { TimeService } from './services/time.service';
import { FiatShortenerPipe } from './shared/pipes/fiat-shortener.pipe'; import { FiatShortenerPipe } from './shared/pipes/fiat-shortener.pipe';
import { FiatCurrencyPipe } from './shared/pipes/fiat-currency.pipe'; import { FiatCurrencyPipe } from './shared/pipes/fiat-currency.pipe';
import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-string.pipe'; import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-string.pipe';
@ -42,6 +43,7 @@ const providers = [
EnterpriseService, EnterpriseService,
LanguageService, LanguageService,
ThemeService, ThemeService,
TimeService,
ShortenStringPipe, ShortenStringPipe,
FiatShortenerPipe, FiatShortenerPipe,
FiatCurrencyPipe, FiatCurrencyPipe,

View File

@ -319,6 +319,7 @@ export class AddressComponent implements OnInit, OnDestroy {
this.transactions = this.transactions.slice(); this.transactions = this.transactions.slice();
this.mempoolStats.removeTx(transaction); this.mempoolStats.removeTx(transaction);
this.audioService.playSound('magic'); this.audioService.playSound('magic');
this.confirmTransaction(tx);
} else { } else {
if (this.addTransaction(transaction, false)) { if (this.addTransaction(transaction, false)) {
this.audioService.playSound('magic'); this.audioService.playSound('magic');
@ -345,10 +346,12 @@ export class AddressComponent implements OnInit, OnDestroy {
} }
// update utxos in-place // update utxos in-place
let utxosChanged = false;
for (const vin of transaction.vin) { for (const vin of transaction.vin) {
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === vin.txid && utxo.vout === vin.vout); const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === vin.txid && utxo.vout === vin.vout);
if (utxoIndex !== -1) { if (utxoIndex !== -1) {
this.utxos.splice(utxoIndex, 1); this.utxos.splice(utxoIndex, 1);
utxosChanged = true;
} }
} }
for (const [index, vout] of transaction.vout.entries()) { for (const [index, vout] of transaction.vout.entries()) {
@ -359,8 +362,12 @@ export class AddressComponent implements OnInit, OnDestroy {
value: vout.value, value: vout.value,
status: JSON.parse(JSON.stringify(transaction.status)), status: JSON.parse(JSON.stringify(transaction.status)),
}); });
utxosChanged = true;
} }
} }
if (utxosChanged) {
this.utxos = this.utxos.slice();
}
return true; return true;
} }
@ -374,6 +381,7 @@ export class AddressComponent implements OnInit, OnDestroy {
this.transactions = this.transactions.slice(); this.transactions = this.transactions.slice();
// update utxos in-place // update utxos in-place
let utxosChanged = false;
for (const vin of transaction.vin) { for (const vin of transaction.vin) {
if (vin.prevout?.scriptpubkey_address === this.address.address) { if (vin.prevout?.scriptpubkey_address === this.address.address) {
this.utxos.push({ this.utxos.push({
@ -382,6 +390,7 @@ export class AddressComponent implements OnInit, OnDestroy {
value: vin.prevout.value, value: vin.prevout.value,
status: { confirmed: true }, // Assuming the input was confirmed status: { confirmed: true }, // Assuming the input was confirmed
}); });
utxosChanged = true;
} }
} }
for (const [index, vout] of transaction.vout.entries()) { for (const [index, vout] of transaction.vout.entries()) {
@ -389,13 +398,43 @@ export class AddressComponent implements OnInit, OnDestroy {
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === transaction.txid && utxo.vout === index); const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === transaction.txid && utxo.vout === index);
if (utxoIndex !== -1) { if (utxoIndex !== -1) {
this.utxos.splice(utxoIndex, 1); this.utxos.splice(utxoIndex, 1);
utxosChanged = true;
} }
} }
} }
if (utxosChanged) {
this.utxos = this.utxos.slice();
}
return true; return true;
} }
confirmTransaction(transaction: Transaction): void {
// update utxos in-place
let utxosChanged = false;
for (const vin of transaction.vin) {
if (vin.prevout?.scriptpubkey_address === this.address.address) {
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === vin.txid && utxo.vout === vin.vout);
if (utxoIndex !== -1) {
this.utxos[utxoIndex].status = JSON.parse(JSON.stringify(transaction.status));
utxosChanged = true;
}
}
}
for (const [index, vout] of transaction.vout.entries()) {
if (vout.scriptpubkey_address === this.address.address) {
const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === transaction.txid && utxo.vout === index);
if (utxoIndex !== -1) {
this.utxos[utxoIndex].status = JSON.parse(JSON.stringify(transaction.status));
utxosChanged = true;
}
}
}
if (utxosChanged) {
this.utxos = this.utxos.slice();
}
}
loadMore(): void { loadMore(): void {
if (this.isLoadingTransactions || this.fullyLoaded) { if (this.isLoadingTransactions || this.fullyLoaded) {
return; return;

View File

@ -11,6 +11,10 @@ export function hexToColor(hex: string): Color {
}; };
} }
export function colorToHex(color: Color): string {
return [color.r, color.g, color.b].map(c => Math.round(c * 255).toString(16)).join('');
}
export function desaturate(color: Color, amount: number): Color { export function desaturate(color: Color, amount: number): Color {
const gray = (color.r + color.g + color.b) / 6; const gray = (color.r + color.g + color.b) / 6;
return { return {
@ -30,6 +34,15 @@ export function darken(color: Color, amount: number): Color {
}; };
} }
export function mix(color1: Color, color2: Color, amount: number): Color {
return {
r: color1.r * (1 - amount) + color2.r * amount,
g: color1.g * (1 - amount) + color2.g * amount,
b: color1.b * (1 - amount) + color2.b * amount,
a: color1.a * (1 - amount) + color2.a * amount,
};
}
export function setOpacity(color: Color, opacity: number): Color { export function setOpacity(color: Color, opacity: number): Color {
return { return {
...color, ...color,

View File

@ -1,7 +1,6 @@
import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnChanges } from '@angular/core'; import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnChanges } from '@angular/core';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
import { dates } from '../../shared/i18n/dates'; import { TimeService } from '../../services/time.service';
import { DatePipe } from '@angular/common';
@Component({ @Component({
selector: 'app-time', selector: 'app-time',
@ -12,19 +11,9 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
interval: number; interval: number;
text: string; text: string;
tooltip: string; tooltip: string;
precisionThresholds = {
year: 100,
month: 18,
week: 12,
day: 31,
hour: 48,
minute: 90,
second: 90
};
intervals = {};
@Input() time: number; @Input() time: number;
@Input() dateString: number; @Input() dateString: string;
@Input() kind: 'plain' | 'since' | 'until' | 'span' | 'before' | 'within' = 'plain'; @Input() kind: 'plain' | 'since' | 'until' | 'span' | 'before' | 'within' = 'plain';
@Input() fastRender = false; @Input() fastRender = false;
@Input() fixedRender = false; @Input() fixedRender = false;
@ -40,37 +29,26 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
constructor( constructor(
private ref: ChangeDetectorRef, private ref: ChangeDetectorRef,
private stateService: StateService, private stateService: StateService,
private datePipe: DatePipe, private timeService: TimeService,
) { ) {}
this.intervals = {
year: 31536000,
month: 2592000,
week: 604800,
day: 86400,
hour: 3600,
minute: 60,
second: 1
};
}
ngOnInit() { ngOnInit() {
this.calculateTime();
if(this.fixedRender){ if(this.fixedRender){
this.text = this.calculate();
return; return;
} }
if (!this.stateService.isBrowser) { if (!this.stateService.isBrowser) {
this.text = this.calculate();
this.ref.markForCheck(); this.ref.markForCheck();
return; return;
} }
this.interval = window.setInterval(() => { this.interval = window.setInterval(() => {
this.text = this.calculate(); this.calculateTime();
this.ref.markForCheck(); this.ref.markForCheck();
}, 1000 * (this.fastRender ? 1 : 60)); }, 1000 * (this.fastRender ? 1 : 60));
} }
ngOnChanges() { ngOnChanges() {
this.text = this.calculate(); this.calculateTime();
this.ref.markForCheck(); this.ref.markForCheck();
} }
@ -78,224 +56,21 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy {
clearInterval(this.interval); clearInterval(this.interval);
} }
calculate() { calculateTime(): void {
if (this.time == null) { const { text, tooltip } = this.timeService.calculate(
return; this.time,
} this.kind,
this.relative,
let seconds: number; this.precision,
switch (this.kind) { this.minUnit,
case 'since': this.showTooltip,
seconds = Math.floor((+new Date() - +new Date(this.dateString || this.time * 1000)) / 1000); this.units,
this.tooltip = this.datePipe.transform(new Date(this.dateString || this.time * 1000), 'yyyy-MM-dd HH:mm'); this.dateString,
break; this.lowercaseStart,
case 'until': this.numUnits,
case 'within': this.fractionDigits,
seconds = (+new Date(this.time) - +new Date()) / 1000; );
this.tooltip = this.datePipe.transform(new Date(this.time), 'yyyy-MM-dd HH:mm'); this.text = text;
break; this.tooltip = tooltip;
default:
seconds = Math.floor(this.time);
this.tooltip = '';
}
if (!this.showTooltip || this.relative) {
this.tooltip = '';
}
if (seconds < 1 && this.kind === 'span') {
return $localize`:@@date-base.immediately:Immediately`;
} else if (seconds < 60) {
if (this.relative || this.kind === 'since') {
if (this.lowercaseStart) {
return $localize`:@@date-base.just-now:Just now`.charAt(0).toLowerCase() + $localize`:@@date-base.just-now:Just now`.slice(1);
}
return $localize`:@@date-base.just-now:Just now`;
} else if (this.kind === 'until' || this.kind === 'within') {
seconds = 60;
}
}
let counter: number;
const result = [];
let usedUnits = 0;
for (const [index, unit] of this.units.entries()) {
let precisionUnit = this.units[Math.min(this.units.length - 1, index + this.precision)];
counter = Math.floor(seconds / this.intervals[unit]);
const precisionCounter = Math.round(seconds / this.intervals[precisionUnit]);
if (precisionCounter > this.precisionThresholds[precisionUnit]) {
precisionUnit = unit;
}
if (this.units.indexOf(precisionUnit) === this.units.indexOf(this.minUnit)) {
counter = Math.max(1, counter);
}
if (counter > 0) {
let rounded;
const roundFactor = Math.pow(10,this.fractionDigits || 0);
if ((this.kind === 'until' || this.kind === 'within') && usedUnits < this.numUnits) {
rounded = Math.floor((seconds / this.intervals[precisionUnit]) * roundFactor) / roundFactor;
} else {
rounded = Math.round((seconds / this.intervals[precisionUnit]) * roundFactor) / roundFactor;
}
if ((this.kind !== 'until' && this.kind !== 'within')|| this.numUnits === 1) {
return this.formatTime(this.kind, precisionUnit, rounded);
} else {
if (!usedUnits) {
result.push(this.formatTime(this.kind, precisionUnit, rounded));
} else {
result.push(this.formatTime('', precisionUnit, rounded));
}
seconds -= (rounded * this.intervals[precisionUnit]);
usedUnits++;
if (usedUnits >= this.numUnits) {
return result.join(', ');
}
}
}
}
return result.join(', ');
}
private formatTime(kind, unit, number): string {
const dateStrings = dates(number);
switch (kind) {
case 'since':
if (number === 1) {
switch (unit) { // singular (1 day)
case 'year': return $localize`:@@time-since:${dateStrings.i18nYear}:DATE: ago`; break;
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonth}:DATE: ago`; break;
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeek}:DATE: ago`; break;
case 'day': return $localize`:@@time-since:${dateStrings.i18nDay}:DATE: ago`; break;
case 'hour': return $localize`:@@time-since:${dateStrings.i18nHour}:DATE: ago`; break;
case 'minute': return $localize`:@@time-since:${dateStrings.i18nMinute}:DATE: ago`; break;
case 'second': return $localize`:@@time-since:${dateStrings.i18nSecond}:DATE: ago`; break;
}
} else {
switch (unit) { // plural (2 days)
case 'year': return $localize`:@@time-since:${dateStrings.i18nYears}:DATE: ago`; break;
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonths}:DATE: ago`; break;
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeeks}:DATE: ago`; break;
case 'day': return $localize`:@@time-since:${dateStrings.i18nDays}:DATE: ago`; break;
case 'hour': return $localize`:@@time-since:${dateStrings.i18nHours}:DATE: ago`; break;
case 'minute': return $localize`:@@time-since:${dateStrings.i18nMinutes}:DATE: ago`; break;
case 'second': return $localize`:@@time-since:${dateStrings.i18nSeconds}:DATE: ago`; break;
}
}
break;
case 'until':
if (number === 1) {
switch (unit) { // singular (In ~1 day)
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYear}:DATE:`; break;
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonth}:DATE:`; break;
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeek}:DATE:`; break;
case 'day': return $localize`:@@time-until:In ~${dateStrings.i18nDay}:DATE:`; break;
case 'hour': return $localize`:@@time-until:In ~${dateStrings.i18nHour}:DATE:`; break;
case 'minute': return $localize`:@@time-until:In ~${dateStrings.i18nMinute}:DATE:`;
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSecond}:DATE:`;
}
} else {
switch (unit) { // plural (In ~2 days)
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYears}:DATE:`; break;
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonths}:DATE:`; break;
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeeks}:DATE:`; break;
case 'day': return $localize`:@@time-until:In ~${dateStrings.i18nDays}:DATE:`; break;
case 'hour': return $localize`:@@time-until:In ~${dateStrings.i18nHours}:DATE:`; break;
case 'minute': return $localize`:@@time-until:In ~${dateStrings.i18nMinutes}:DATE:`; break;
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSeconds}:DATE:`; break;
}
}
break;
case 'within':
if (number === 1) {
switch (unit) { // singular (In ~1 day)
case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYear}:DATE:`; break;
case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonth}:DATE:`; break;
case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeek}:DATE:`; break;
case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDay}:DATE:`; break;
case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHour}:DATE:`; break;
case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinute}:DATE:`;
case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSecond}:DATE:`;
}
} else {
switch (unit) { // plural (In ~2 days)
case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYears}:DATE:`; break;
case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonths}:DATE:`; break;
case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeeks}:DATE:`; break;
case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDays}:DATE:`; break;
case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHours}:DATE:`; break;
case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinutes}:DATE:`; break;
case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSeconds}:DATE:`; break;
}
}
break;
case 'span':
if (number === 1) {
switch (unit) { // singular (1 day)
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYear}:DATE:`; break;
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonth}:DATE:`; break;
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeek}:DATE:`; break;
case 'day': return $localize`:@@time-span:After ${dateStrings.i18nDay}:DATE:`; break;
case 'hour': return $localize`:@@time-span:After ${dateStrings.i18nHour}:DATE:`; break;
case 'minute': return $localize`:@@time-span:After ${dateStrings.i18nMinute}:DATE:`; break;
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSecond}:DATE:`; break;
}
} else {
switch (unit) { // plural (2 days)
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYears}:DATE:`; break;
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonths}:DATE:`; break;
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeeks}:DATE:`; break;
case 'day': return $localize`:@@time-span:After ${dateStrings.i18nDays}:DATE:`; break;
case 'hour': return $localize`:@@time-span:After ${dateStrings.i18nHours}:DATE:`; break;
case 'minute': return $localize`:@@time-span:After ${dateStrings.i18nMinutes}:DATE:`; break;
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSeconds}:DATE:`; break;
}
}
break;
case 'before':
if (number === 1) {
switch (unit) { // singular (1 day)
case 'year': return $localize`:@@time-before:${dateStrings.i18nYear}:DATE: before`; break;
case 'month': return $localize`:@@time-before:${dateStrings.i18nMonth}:DATE: before`; break;
case 'week': return $localize`:@@time-before:${dateStrings.i18nWeek}:DATE: before`; break;
case 'day': return $localize`:@@time-before:${dateStrings.i18nDay}:DATE: before`; break;
case 'hour': return $localize`:@@time-before:${dateStrings.i18nHour}:DATE: before`; break;
case 'minute': return $localize`:@@time-before:${dateStrings.i18nMinute}:DATE: before`; break;
case 'second': return $localize`:@@time-before:${dateStrings.i18nSecond}:DATE: before`; break;
}
} else {
switch (unit) { // plural (2 days)
case 'year': return $localize`:@@time-before:${dateStrings.i18nYears}:DATE: before`; break;
case 'month': return $localize`:@@time-before:${dateStrings.i18nMonths}:DATE: before`; break;
case 'week': return $localize`:@@time-before:${dateStrings.i18nWeeks}:DATE: before`; break;
case 'day': return $localize`:@@time-before:${dateStrings.i18nDays}:DATE: before`; break;
case 'hour': return $localize`:@@time-before:${dateStrings.i18nHours}:DATE: before`; break;
case 'minute': return $localize`:@@time-before:${dateStrings.i18nMinutes}:DATE: before`; break;
case 'second': return $localize`:@@time-before:${dateStrings.i18nSeconds}:DATE: before`; break;
}
}
break;
default:
if (number === 1) {
switch (unit) { // singular (1 day)
case 'year': return dateStrings.i18nYear; break;
case 'month': return dateStrings.i18nMonth; break;
case 'week': return dateStrings.i18nWeek; break;
case 'day': return dateStrings.i18nDay; break;
case 'hour': return dateStrings.i18nHour; break;
case 'minute': return dateStrings.i18nMinute; break;
case 'second': return dateStrings.i18nSecond; break;
}
} else {
switch (unit) { // plural (2 days)
case 'year': return dateStrings.i18nYears; break;
case 'month': return dateStrings.i18nMonths; break;
case 'week': return dateStrings.i18nWeeks; break;
case 'day': return dateStrings.i18nDays; break;
case 'hour': return dateStrings.i18nHours; break;
case 'minute': return dateStrings.i18nMinutes; break;
case 'second': return dateStrings.i18nSeconds; break;
}
}
}
} }
} }

View File

@ -1,12 +1,44 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { EChartsOption } from '../../graphs/echarts'; import { EChartsOption } from '../../graphs/echarts';
import { BehaviorSubject, Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { Utxo } from '../../interfaces/electrs.interface'; import { Utxo } from '../../interfaces/electrs.interface';
import { StateService } from '../../services/state.service'; import { StateService } from '../../services/state.service';
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 { renderSats } from '../../shared/common.utils'; import { renderSats } from '../../shared/common.utils';
import { colorToHex, hexToColor, mix } from '../block-overview-graph/utils';
import { TimeService } from '../../services/time.service';
const newColorHex = '1bd8f4';
const oldColorHex = '9339f4';
const pendingColorHex = 'eba814';
const newColor = hexToColor(newColorHex);
const oldColor = hexToColor(oldColorHex);
interface Circle {
x: number,
y: number,
r: number,
i: number,
}
interface UtxoCircle extends Circle {
utxo: Utxo;
}
function sortedInsert(positions: { c1: Circle, c2: Circle, d: number, p: number, side?: boolean }[], newPosition: { c1: Circle, c2: Circle, d: number, p: number }): void {
let left = 0;
let right = positions.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
if (positions[mid].p > newPosition.p) {
right = mid;
} else {
left = mid + 1;
}
}
positions.splice(left, 0, newPosition, {...newPosition, side: true });
}
@Component({ @Component({
selector: 'app-utxo-graph', selector: 'app-utxo-graph',
templateUrl: './utxo-graph.component.html', templateUrl: './utxo-graph.component.html',
@ -29,7 +61,8 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
@Input() widget: boolean = false; @Input() widget: boolean = false;
subscription: Subscription; subscription: Subscription;
redraw$: BehaviorSubject<boolean> = new BehaviorSubject(false); lastUpdate: number = 0;
updateInterval;
chartOptions: EChartsOption = {}; chartOptions: EChartsOption = {};
chartInitOptions = { chartInitOptions = {
@ -46,7 +79,15 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
private zone: NgZone, private zone: NgZone,
private router: Router, private router: Router,
private relativeUrlPipe: RelativeUrlPipe, private relativeUrlPipe: RelativeUrlPipe,
) {} private timeService: TimeService,
) {
// re-render the chart every 10 seconds, to keep the age colors up to date
this.updateInterval = setInterval(() => {
if (this.lastUpdate < Date.now() - 10000 && this.utxos) {
this.prepareChartOptions(this.utxos);
}
}, 10000);
}
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
this.isLoading = true; this.isLoading = true;
@ -58,7 +99,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
} }
} }
prepareChartOptions(utxos: Utxo[]) { prepareChartOptions(utxos: Utxo[]): void {
if (!utxos || utxos.length === 0) { if (!utxos || utxos.length === 0) {
return; return;
} }
@ -67,94 +108,110 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
// Helper functions // Helper functions
const distance = (x1: number, y1: number, x2: number, y2: number): number => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); const distance = (x1: number, y1: number, x2: number, y2: number): number => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
const intersectionPoints = (x1: number, y1: number, r1: number, x2: number, y2: number, r2: number): [number, number][] => { const intersection = (c1: Circle, c2: Circle, d: number, r: number, side: boolean): { x: number, y: number} => {
const d = distance(x1, y1, x2, y2); const d1 = c1.r + r;
const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d); const d2 = c2.r + r;
const h = Math.sqrt(r1 * r1 - a * a); const a = (d1 * d1 - d2 * d2 + d * d) / (2 * d);
const x3 = x1 + a * (x2 - x1) / d; const h = Math.sqrt(d1 * d1 - a * a);
const y3 = y1 + a * (y2 - y1) / d; const x3 = c1.x + a * (c2.x - c1.x) / d;
return [ const y3 = c1.y + a * (c2.y - c1.y) / d;
[x3 + h * (y2 - y1) / d, y3 - h * (x2 - x1) / d], return side
[x3 - h * (y2 - y1) / d, y3 + h * (x2 - x1) / d] ? { x: x3 + h * (c2.y - c1.y) / d, y: y3 - h * (c2.x - c1.x) / d }
]; : { x: x3 - h * (c2.y - c1.y) / d, y: y3 + h * (c2.x - c1.x) / d };
}; };
// Naive algorithm to pack circles as tightly as possible without overlaps // ~Linear algorithm to pack circles as tightly as possible without overlaps
const placedCircles: { x: number, y: number, r: number, utxo: Utxo, distances: number[] }[] = []; const placedCircles: UtxoCircle[] = [];
const positions: { c1: Circle, c2: Circle, d: number, p: number, side?: boolean }[] = [];
// Pack in descending order of value, and limit to the top 500 to preserve performance // Pack in descending order of value, and limit to the top 500 to preserve performance
const sortedUtxos = utxos.sort((a, b) => b.value - a.value).slice(0, 500); const sortedUtxos = utxos.sort((a, b) => {
let centerOfMass = { x: 0, y: 0 }; if (a.value === b.value) {
let weightOfMass = 0; if (a.status.confirmed && !b.status.confirmed) {
return -1;
} else if (!a.status.confirmed && b.status.confirmed) {
return 1;
} else {
return a.status.block_height - b.status.block_height;
}
}
return b.value - a.value;
}).slice(0, 500);
const maxR = Math.sqrt(sortedUtxos.reduce((max, utxo) => Math.max(max, utxo.value), 0));
sortedUtxos.forEach((utxo, index) => { sortedUtxos.forEach((utxo, index) => {
// area proportional to value // area proportional to value
const r = Math.sqrt(utxo.value); const r = Math.sqrt(utxo.value);
// special cases for the first two utxos // special cases for the first two utxos
if (index === 0) { if (index === 0) {
placedCircles.push({ x: 0, y: 0, r, utxo, distances: [0] }); placedCircles.push({ x: 0, y: 0, r, utxo, i: index });
return; return;
} }
if (index === 1) { if (index === 1) {
const c = placedCircles[0]; const c = placedCircles[0];
placedCircles.push({ x: c.r + r, y: 0, r, utxo, distances: [c.r + r, 0] }); placedCircles.push({ x: c.r + r, y: 0, r, utxo, i: index });
c.distances.push(c.r + r); sortedInsert(positions, { c1: c, c2: placedCircles[1], d: c.r + r, p: 0 });
return;
}
if (index === 2) {
const c = placedCircles[0];
placedCircles.push({ x: -c.r - r, y: 0, r, utxo, i: index });
sortedInsert(positions, { c1: c, c2: placedCircles[2], d: c.r + r, p: 0 });
return; return;
} }
// The best position will be touching two other circles // The best position will be touching two other circles
// generate a list of candidate points by finding all such positions // find the closest such position to the center of the graph
// where the circle can be placed without overlapping other circles // where the circle can be placed without overlapping other circles
const candidates: [number, number, number[]][] = [];
const numCircles = placedCircles.length; const numCircles = placedCircles.length;
for (let i = 0; i < numCircles; i++) { let newCircle: UtxoCircle = null;
for (let j = i + 1; j < numCircles; j++) { while (positions.length > 0) {
const c1 = placedCircles[i]; const position = positions.shift();
const c2 = placedCircles[j]; // if the circles are too far apart, skip
if (c1.distances[j] > (c1.r + c2.r + r + r)) { if (position.d > (position.c1.r + position.c2.r + r + r)) {
// too far apart for new circle to touch both continue;
}
const { x, y } = intersection(position.c1, position.c2, position.d, r, position.side);
if (isNaN(x) || isNaN(y)) {
// should never happen
continue;
}
// check if the circle would overlap any other circles here
let valid = true;
const nearbyCircles: { c: UtxoCircle, d: number, s: number }[] = [];
for (let k = 0; k < numCircles; k++) {
const c = placedCircles[k];
if (k === position.c1.i || k === position.c2.i) {
nearbyCircles.push({ c, d: c.r + r, s: 0 });
continue; continue;
} }
const points = intersectionPoints(c1.x, c1.y, c1.r + r, c2.x, c2.y, c2.r + r); const d = distance(x, y, c.x, c.y);
points.forEach(([x, y]) => { if (d < (r + c.r)) {
const distances: number[] = []; valid = false;
let valid = true; break;
for (let k = 0; k < numCircles; k++) { } else {
const c = placedCircles[k]; nearbyCircles.push({ c, d, s: d - c.r - r });
const d = distance(x, y, c.x, c.y); }
if (k !== i && k !== j && d < (r + c.r)) { }
valid = false; if (valid) {
break; newCircle = { x, y, r, utxo, i: index };
} else { // add new positions to the candidate list
distances.push(d); const nearest = nearbyCircles.sort((a, b) => a.s - b.s).slice(0, 5);
} for (const n of nearest) {
if (n.d < (n.c.r + r + maxR + maxR)) {
sortedInsert(positions, { c1: newCircle, c2: n.c, d: n.d, p: distance((n.c.x + x) / 2, (n.c.y + y), 0, 0) });
} }
if (valid) { }
candidates.push([x, y, distances]); break;
}
});
} }
} }
if (newCircle) {
// Pick the candidate closest to the center of mass placedCircles.push(newCircle);
const [x, y, distances] = candidates.length ? candidates.reduce((closest, candidate) => } else {
distance(candidate[0], candidate[1], centerOfMass[0], centerOfMass[1]) < // should never happen
distance(closest[0], closest[1], centerOfMass[0], centerOfMass[1]) return;
? candidate
: closest
) : [0, 0, []];
placedCircles.push({ x, y, r, utxo, distances });
for (let i = 0; i < distances.length; i++) {
placedCircles[i].distances.push(distances[i]);
} }
distances.push(0);
// Update center of mass
centerOfMass = {
x: (centerOfMass.x * weightOfMass + x) / (weightOfMass + r),
y: (centerOfMass.y * weightOfMass + y) / (weightOfMass + r),
};
weightOfMass += r;
}); });
// Precompute the bounding box of the graph // Precompute the bounding box of the graph
@ -165,23 +222,26 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
const width = maxX - minX; const width = maxX - minX;
const height = maxY - minY; const height = maxY - minY;
const data = placedCircles.map((circle, index) => [ const data = placedCircles.map((circle) => [
circle.utxo.txid + circle.utxo.vout,
circle.utxo, circle.utxo,
index,
circle.x, circle.x,
circle.y, circle.y,
circle.r circle.r,
]); ]);
this.chartOptions = { this.chartOptions = {
series: [{ series: [{
type: 'custom', type: 'custom',
coordinateSystem: undefined, coordinateSystem: undefined,
data, data: data,
encode: {
itemName: 0,
x: 2,
y: 3,
r: 4,
},
renderItem: (params, api) => { renderItem: (params, api) => {
const idx = params.dataIndex;
const datum = data[idx];
const utxo = datum[0] as Utxo;
const chartWidth = api.getWidth(); const chartWidth = api.getWidth();
const chartHeight = api.getHeight(); const chartHeight = api.getHeight();
const scale = Math.min(chartWidth / width, chartHeight / height); const scale = Math.min(chartWidth / width, chartHeight / height);
@ -189,34 +249,34 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
const scaledHeight = height * scale; const scaledHeight = height * scale;
const offsetX = (chartWidth - scaledWidth) / 2 - minX * scale; const offsetX = (chartWidth - scaledWidth) / 2 - minX * scale;
const offsetY = (chartHeight - scaledHeight) / 2 - minY * scale; const offsetY = (chartHeight - scaledHeight) / 2 - minY * scale;
const datum = data[params.dataIndex];
const utxo = datum[1] as Utxo;
const x = datum[2] as number; const x = datum[2] as number;
const y = datum[3] as number; const y = datum[3] as number;
const r = datum[4] as number; const r = datum[4] as number;
if (r * scale < 3) { if (r * scale < 2) {
// skip items too small to render cleanly // skip items too small to render cleanly
return; return;
} }
const valueStr = renderSats(utxo.value, this.stateService.network); const valueStr = renderSats(utxo.value, this.stateService.network);
const elements: any[] = [ const elements: any[] = [
{ {
type: 'circle', type: 'circle',
autoBatch: true, autoBatch: true,
shape: { shape: {
cx: (x * scale) + offsetX,
cy: (y * scale) + offsetY,
r: (r * scale) - 1, r: (r * scale) - 1,
}, },
style: { style: {
fill: '#5470c6', fill: '#' + this.getColor(utxo),
} }
}, },
]; ];
const labelFontSize = Math.min(36, r * scale * 0.25); const labelFontSize = Math.min(36, r * scale * 0.3);
if (labelFontSize > 8) { if (labelFontSize > 8) {
elements.push({ elements.push({
type: 'text', type: 'text',
x: (x * scale) + offsetX,
y: (y * scale) + offsetY,
style: { style: {
text: valueStr, text: valueStr,
fontSize: labelFontSize, fontSize: labelFontSize,
@ -228,9 +288,11 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
} }
return { return {
type: 'group', type: 'group',
x: (x * scale) + offsetX,
y: (y * scale) + offsetY,
children: elements, children: elements,
}; };
} },
}], }],
tooltip: { tooltip: {
backgroundColor: 'rgba(17, 19, 31, 1)', backgroundColor: 'rgba(17, 19, 31, 1)',
@ -242,19 +304,45 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
}, },
borderColor: '#000', borderColor: '#000',
formatter: (params: any): string => { formatter: (params: any): string => {
const utxo = params.data[0] as Utxo; const utxo = params.data[1] as Utxo;
const valueStr = renderSats(utxo.value, this.stateService.network); const valueStr = renderSats(utxo.value, this.stateService.network);
return ` return `
<b style="color: white;">${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}</b> <b style="color: white;">${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}</b>
<br> <br>
${valueStr}`; ${valueStr}
<br>
${utxo.status.confirmed ? 'Confirmed ' + this.timeService.calculate(utxo.status.block_time, 'since', true, 1, 'minute').text : 'Pending'}
`;
}, },
} }
}; };
this.lastUpdate = Date.now();
this.cd.markForCheck(); this.cd.markForCheck();
} }
getColor(utxo: Utxo): string {
if (utxo.status.confirmed) {
const age = Date.now() / 1000 - utxo.status.block_time;
const oneHour = 60 * 60;
const fourYears = 4 * 365 * 24 * 60 * 60;
if (age < oneHour) {
return newColorHex;
} else if (age >= fourYears) {
return oldColorHex;
} else {
// Logarithmic scale between 1 hour and 4 years
const logAge = Math.log(age / oneHour);
const logMax = Math.log(fourYears / oneHour);
const t = logAge / logMax;
return colorToHex(mix(newColor, oldColor, t));
}
} else {
return pendingColorHex;
}
}
onChartClick(e): void { onChartClick(e): void {
if (e.data?.[0]?.txid) { if (e.data?.[0]?.txid) {
this.zone.run(() => { this.zone.run(() => {
@ -277,6 +365,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
if (this.subscription) { if (this.subscription) {
this.subscription.unsubscribe(); this.subscription.unsubscribe();
} }
clearInterval(this.updateInterval);
} }
isMobile(): boolean { isMobile(): boolean {

View File

@ -0,0 +1,266 @@
import { Injectable } from '@angular/core';
import { DatePipe } from '@angular/common';
import { dates } from '../shared/i18n/dates';
const intervals = {
year: 31536000,
month: 2592000,
week: 604800,
day: 86400,
hour: 3600,
minute: 60,
second: 1
};
const precisionThresholds = {
year: 100,
month: 18,
week: 12,
day: 31,
hour: 48,
minute: 90,
second: 90
};
@Injectable({
providedIn: 'root'
})
export class TimeService {
constructor(private datePipe: DatePipe) {}
calculate(
time: number,
kind: 'plain' | 'since' | 'until' | 'span' | 'before' | 'within',
relative: boolean = false,
precision: number = 0,
minUnit: 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' = 'second',
showTooltip: boolean = false,
units: string[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'],
dateString?: string,
lowercaseStart: boolean = false,
numUnits: number = 1,
fractionDigits: number = 0,
): { text: string, tooltip: string } {
if (time == null) {
return { text: '', tooltip: '' };
}
let seconds: number;
let tooltip: string = '';
switch (kind) {
case 'since':
seconds = Math.floor((+new Date() - +new Date(dateString || time * 1000)) / 1000);
tooltip = this.datePipe.transform(new Date(dateString || time * 1000), 'yyyy-MM-dd HH:mm') || '';
break;
case 'until':
case 'within':
seconds = (+new Date(time) - +new Date()) / 1000;
tooltip = this.datePipe.transform(new Date(time), 'yyyy-MM-dd HH:mm') || '';
break;
default:
seconds = Math.floor(time);
tooltip = '';
}
if (!showTooltip || relative) {
tooltip = '';
}
if (seconds < 1 && kind === 'span') {
return { tooltip, text: $localize`:@@date-base.immediately:Immediately` };
} else if (seconds < 60) {
if (relative || kind === 'since') {
if (lowercaseStart) {
return { tooltip, text: $localize`:@@date-base.just-now:Just now`.charAt(0).toLowerCase() + $localize`:@@date-base.just-now:Just now`.slice(1) };
}
return { tooltip, text: $localize`:@@date-base.just-now:Just now` };
} else if (kind === 'until' || kind === 'within') {
seconds = 60;
}
}
let counter: number;
const result: string[] = [];
let usedUnits = 0;
for (const [index, unit] of units.entries()) {
let precisionUnit = units[Math.min(units.length - 1, index + precision)];
counter = Math.floor(seconds / intervals[unit]);
const precisionCounter = Math.round(seconds / intervals[precisionUnit]);
if (precisionCounter > precisionThresholds[precisionUnit]) {
precisionUnit = unit;
}
if (units.indexOf(precisionUnit) === units.indexOf(minUnit)) {
counter = Math.max(1, counter);
}
if (counter > 0) {
let rounded;
const roundFactor = Math.pow(10,fractionDigits || 0);
if ((kind === 'until' || kind === 'within') && usedUnits < numUnits) {
rounded = Math.floor((seconds / intervals[precisionUnit]) * roundFactor) / roundFactor;
} else {
rounded = Math.round((seconds / intervals[precisionUnit]) * roundFactor) / roundFactor;
}
if ((kind !== 'until' && kind !== 'within')|| numUnits === 1) {
return { tooltip, text: this.formatTime(kind, precisionUnit, rounded) };
} else {
if (!usedUnits) {
result.push(this.formatTime(kind, precisionUnit, rounded));
} else {
result.push(this.formatTime('', precisionUnit, rounded));
}
seconds -= (rounded * intervals[precisionUnit]);
usedUnits++;
if (usedUnits >= numUnits) {
return { tooltip, text: result.join(', ') };
}
}
}
}
return { tooltip, text: result.join(', ') };
}
private formatTime(kind, unit, number): string {
const dateStrings = dates(number);
switch (kind) {
case 'since':
if (number === 1) {
switch (unit) { // singular (1 day)
case 'year': return $localize`:@@time-since:${dateStrings.i18nYear}:DATE: ago`; break;
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonth}:DATE: ago`; break;
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeek}:DATE: ago`; break;
case 'day': return $localize`:@@time-since:${dateStrings.i18nDay}:DATE: ago`; break;
case 'hour': return $localize`:@@time-since:${dateStrings.i18nHour}:DATE: ago`; break;
case 'minute': return $localize`:@@time-since:${dateStrings.i18nMinute}:DATE: ago`; break;
case 'second': return $localize`:@@time-since:${dateStrings.i18nSecond}:DATE: ago`; break;
}
} else {
switch (unit) { // plural (2 days)
case 'year': return $localize`:@@time-since:${dateStrings.i18nYears}:DATE: ago`; break;
case 'month': return $localize`:@@time-since:${dateStrings.i18nMonths}:DATE: ago`; break;
case 'week': return $localize`:@@time-since:${dateStrings.i18nWeeks}:DATE: ago`; break;
case 'day': return $localize`:@@time-since:${dateStrings.i18nDays}:DATE: ago`; break;
case 'hour': return $localize`:@@time-since:${dateStrings.i18nHours}:DATE: ago`; break;
case 'minute': return $localize`:@@time-since:${dateStrings.i18nMinutes}:DATE: ago`; break;
case 'second': return $localize`:@@time-since:${dateStrings.i18nSeconds}:DATE: ago`; break;
}
}
break;
case 'until':
if (number === 1) {
switch (unit) { // singular (In ~1 day)
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYear}:DATE:`; break;
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonth}:DATE:`; break;
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeek}:DATE:`; break;
case 'day': return $localize`:@@time-until:In ~${dateStrings.i18nDay}:DATE:`; break;
case 'hour': return $localize`:@@time-until:In ~${dateStrings.i18nHour}:DATE:`; break;
case 'minute': return $localize`:@@time-until:In ~${dateStrings.i18nMinute}:DATE:`;
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSecond}:DATE:`;
}
} else {
switch (unit) { // plural (In ~2 days)
case 'year': return $localize`:@@time-until:In ~${dateStrings.i18nYears}:DATE:`; break;
case 'month': return $localize`:@@time-until:In ~${dateStrings.i18nMonths}:DATE:`; break;
case 'week': return $localize`:@@time-until:In ~${dateStrings.i18nWeeks}:DATE:`; break;
case 'day': return $localize`:@@time-until:In ~${dateStrings.i18nDays}:DATE:`; break;
case 'hour': return $localize`:@@time-until:In ~${dateStrings.i18nHours}:DATE:`; break;
case 'minute': return $localize`:@@time-until:In ~${dateStrings.i18nMinutes}:DATE:`; break;
case 'second': return $localize`:@@time-until:In ~${dateStrings.i18nSeconds}:DATE:`; break;
}
}
break;
case 'within':
if (number === 1) {
switch (unit) { // singular (In ~1 day)
case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYear}:DATE:`; break;
case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonth}:DATE:`; break;
case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeek}:DATE:`; break;
case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDay}:DATE:`; break;
case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHour}:DATE:`; break;
case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinute}:DATE:`;
case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSecond}:DATE:`;
}
} else {
switch (unit) { // plural (In ~2 days)
case 'year': return $localize`:@@time-within:within ~${dateStrings.i18nYears}:DATE:`; break;
case 'month': return $localize`:@@time-within:within ~${dateStrings.i18nMonths}:DATE:`; break;
case 'week': return $localize`:@@time-within:within ~${dateStrings.i18nWeeks}:DATE:`; break;
case 'day': return $localize`:@@time-within:within ~${dateStrings.i18nDays}:DATE:`; break;
case 'hour': return $localize`:@@time-within:within ~${dateStrings.i18nHours}:DATE:`; break;
case 'minute': return $localize`:@@time-within:within ~${dateStrings.i18nMinutes}:DATE:`; break;
case 'second': return $localize`:@@time-within:within ~${dateStrings.i18nSeconds}:DATE:`; break;
}
}
break;
case 'span':
if (number === 1) {
switch (unit) { // singular (1 day)
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYear}:DATE:`; break;
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonth}:DATE:`; break;
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeek}:DATE:`; break;
case 'day': return $localize`:@@time-span:After ${dateStrings.i18nDay}:DATE:`; break;
case 'hour': return $localize`:@@time-span:After ${dateStrings.i18nHour}:DATE:`; break;
case 'minute': return $localize`:@@time-span:After ${dateStrings.i18nMinute}:DATE:`; break;
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSecond}:DATE:`; break;
}
} else {
switch (unit) { // plural (2 days)
case 'year': return $localize`:@@time-span:After ${dateStrings.i18nYears}:DATE:`; break;
case 'month': return $localize`:@@time-span:After ${dateStrings.i18nMonths}:DATE:`; break;
case 'week': return $localize`:@@time-span:After ${dateStrings.i18nWeeks}:DATE:`; break;
case 'day': return $localize`:@@time-span:After ${dateStrings.i18nDays}:DATE:`; break;
case 'hour': return $localize`:@@time-span:After ${dateStrings.i18nHours}:DATE:`; break;
case 'minute': return $localize`:@@time-span:After ${dateStrings.i18nMinutes}:DATE:`; break;
case 'second': return $localize`:@@time-span:After ${dateStrings.i18nSeconds}:DATE:`; break;
}
}
break;
case 'before':
if (number === 1) {
switch (unit) { // singular (1 day)
case 'year': return $localize`:@@time-before:${dateStrings.i18nYear}:DATE: before`; break;
case 'month': return $localize`:@@time-before:${dateStrings.i18nMonth}:DATE: before`; break;
case 'week': return $localize`:@@time-before:${dateStrings.i18nWeek}:DATE: before`; break;
case 'day': return $localize`:@@time-before:${dateStrings.i18nDay}:DATE: before`; break;
case 'hour': return $localize`:@@time-before:${dateStrings.i18nHour}:DATE: before`; break;
case 'minute': return $localize`:@@time-before:${dateStrings.i18nMinute}:DATE: before`; break;
case 'second': return $localize`:@@time-before:${dateStrings.i18nSecond}:DATE: before`; break;
}
} else {
switch (unit) { // plural (2 days)
case 'year': return $localize`:@@time-before:${dateStrings.i18nYears}:DATE: before`; break;
case 'month': return $localize`:@@time-before:${dateStrings.i18nMonths}:DATE: before`; break;
case 'week': return $localize`:@@time-before:${dateStrings.i18nWeeks}:DATE: before`; break;
case 'day': return $localize`:@@time-before:${dateStrings.i18nDays}:DATE: before`; break;
case 'hour': return $localize`:@@time-before:${dateStrings.i18nHours}:DATE: before`; break;
case 'minute': return $localize`:@@time-before:${dateStrings.i18nMinutes}:DATE: before`; break;
case 'second': return $localize`:@@time-before:${dateStrings.i18nSeconds}:DATE: before`; break;
}
}
break;
default:
if (number === 1) {
switch (unit) { // singular (1 day)
case 'year': return dateStrings.i18nYear; break;
case 'month': return dateStrings.i18nMonth; break;
case 'week': return dateStrings.i18nWeek; break;
case 'day': return dateStrings.i18nDay; break;
case 'hour': return dateStrings.i18nHour; break;
case 'minute': return dateStrings.i18nMinute; break;
case 'second': return dateStrings.i18nSecond; break;
}
} else {
switch (unit) { // plural (2 days)
case 'year': return dateStrings.i18nYears; break;
case 'month': return dateStrings.i18nMonths; break;
case 'week': return dateStrings.i18nWeeks; break;
case 'day': return dateStrings.i18nDays; break;
case 'hour': return dateStrings.i18nHours; break;
case 'minute': return dateStrings.i18nMinutes; break;
case 'second': return dateStrings.i18nSeconds; break;
}
}
}
return '';
}
}

View File

@ -204,12 +204,12 @@ export function renderSats(value: number, network: string, mode: 'sats' | 'btc'
break; break;
} }
if (mode === 'btc' || (mode === 'auto' && value >= 1000000)) { if (mode === 'btc' || (mode === 'auto' && value >= 1000000)) {
return `${amountShortenerPipe.transform(value / 100000000)} ${prefix}BTC`; return `${amountShortenerPipe.transform(value / 100000000, 2)} ${prefix}BTC`;
} else { } else {
if (prefix.length) { if (prefix.length) {
prefix += '-'; prefix += '-';
} }
return `${amountShortenerPipe.transform(value)} ${prefix}sats`; return `${amountShortenerPipe.transform(value, 2)} ${prefix}sats`;
} }
} }