Merge pull request #5548 from mempool/mononaut/utxo-chart-optimization
utxo chart optimization
This commit is contained in:
commit
758122db5e
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
266
frontend/src/app/services/time.service.ts
Normal file
266
frontend/src/app/services/time.service.ts
Normal 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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user