From 06e699e52b38b051712a2f4775d37887c808bb3a Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sun, 22 Sep 2024 16:49:08 +0000 Subject: [PATCH 1/4] address utxo chart color by age & updates --- .../components/address/address.component.ts | 39 +++++ .../components/block-overview-graph/utils.ts | 13 ++ .../src/app/components/time/time.component.ts | 161 +++++++++++------- .../utxo-graph/utxo-graph.component.ts | 68 +++++++- frontend/src/app/shared/common.utils.ts | 4 +- 5 files changed, 211 insertions(+), 74 deletions(-) diff --git a/frontend/src/app/components/address/address.component.ts b/frontend/src/app/components/address/address.component.ts index 5ce82ef8c..aaf480d8e 100644 --- a/frontend/src/app/components/address/address.component.ts +++ b/frontend/src/app/components/address/address.component.ts @@ -319,6 +319,7 @@ export class AddressComponent implements OnInit, OnDestroy { this.transactions = this.transactions.slice(); this.mempoolStats.removeTx(transaction); this.audioService.playSound('magic'); + this.confirmTransaction(tx); } else { if (this.addTransaction(transaction, false)) { this.audioService.playSound('magic'); @@ -345,10 +346,12 @@ export class AddressComponent implements OnInit, OnDestroy { } // update utxos in-place + let utxosChanged = false; for (const vin of transaction.vin) { const utxoIndex = this.utxos.findIndex((utxo) => utxo.txid === vin.txid && utxo.vout === vin.vout); if (utxoIndex !== -1) { this.utxos.splice(utxoIndex, 1); + utxosChanged = true; } } for (const [index, vout] of transaction.vout.entries()) { @@ -359,8 +362,12 @@ export class AddressComponent implements OnInit, OnDestroy { value: vout.value, status: JSON.parse(JSON.stringify(transaction.status)), }); + utxosChanged = true; } } + if (utxosChanged) { + this.utxos = this.utxos.slice(); + } return true; } @@ -374,6 +381,7 @@ export class AddressComponent implements OnInit, OnDestroy { this.transactions = this.transactions.slice(); // update utxos in-place + let utxosChanged = false; for (const vin of transaction.vin) { if (vin.prevout?.scriptpubkey_address === this.address.address) { this.utxos.push({ @@ -382,6 +390,7 @@ export class AddressComponent implements OnInit, OnDestroy { value: vin.prevout.value, status: { confirmed: true }, // Assuming the input was confirmed }); + utxosChanged = true; } } 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); if (utxoIndex !== -1) { this.utxos.splice(utxoIndex, 1); + utxosChanged = true; } } } + if (utxosChanged) { + this.utxos = this.utxos.slice(); + } 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 { if (this.isLoadingTransactions || this.fullyLoaded) { return; diff --git a/frontend/src/app/components/block-overview-graph/utils.ts b/frontend/src/app/components/block-overview-graph/utils.ts index 625029db0..287c4bf34 100644 --- a/frontend/src/app/components/block-overview-graph/utils.ts +++ b/frontend/src/app/components/block-overview-graph/utils.ts @@ -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 { const gray = (color.r + color.g + color.b) / 6; 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 { return { ...color, diff --git a/frontend/src/app/components/time/time.component.ts b/frontend/src/app/components/time/time.component.ts index 3015007b2..f0c73c80b 100644 --- a/frontend/src/app/components/time/time.component.ts +++ b/frontend/src/app/components/time/time.component.ts @@ -3,6 +3,28 @@ import { StateService } from '../../services/state.service'; import { dates } from '../../shared/i18n/dates'; import { DatePipe } from '@angular/common'; +const datePipe = new DatePipe(navigator.language || 'en-US'); + +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 +}; + @Component({ selector: 'app-time', templateUrl: './time.component.html', @@ -12,19 +34,9 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy { interval: number; text: string; tooltip: string; - precisionThresholds = { - year: 100, - month: 18, - week: 12, - day: 31, - hour: 48, - minute: 90, - second: 90 - }; - intervals = {}; @Input() time: number; - @Input() dateString: number; + @Input() dateString: string; @Input() kind: 'plain' | 'since' | 'until' | 'span' | 'before' | 'within' = 'plain'; @Input() fastRender = false; @Input() fixedRender = false; @@ -40,37 +52,25 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy { constructor( private ref: ChangeDetectorRef, private stateService: StateService, - private datePipe: DatePipe, - ) { - this.intervals = { - year: 31536000, - month: 2592000, - week: 604800, - day: 86400, - hour: 3600, - minute: 60, - second: 1 - }; - } + ) {} ngOnInit() { + this.calculateTime(); if(this.fixedRender){ - this.text = this.calculate(); return; } if (!this.stateService.isBrowser) { - this.text = this.calculate(); this.ref.markForCheck(); return; } this.interval = window.setInterval(() => { - this.text = this.calculate(); + this.calculateTime(); this.ref.markForCheck(); }, 1000 * (this.fastRender ? 1 : 60)); } ngOnChanges() { - this.text = this.calculate(); + this.calculateTime(); this.ref.markForCheck(); } @@ -78,40 +78,71 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy { clearInterval(this.interval); } - calculate() { - if (this.time == null) { - return; + calculateTime(): void { + const { text, tooltip } = TimeComponent.calculate( + this.time, + this.kind, + this.relative, + this.precision, + this.minUnit, + this.showTooltip, + this.units, + this.dateString, + this.lowercaseStart, + this.numUnits, + this.fractionDigits, + ); + this.text = text; + this.tooltip = tooltip; + } + + static 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; - switch (this.kind) { + let tooltip: string = ''; + switch (kind) { case 'since': - seconds = Math.floor((+new Date() - +new Date(this.dateString || this.time * 1000)) / 1000); - this.tooltip = this.datePipe.transform(new Date(this.dateString || this.time * 1000), 'yyyy-MM-dd HH:mm'); + seconds = Math.floor((+new Date() - +new Date(dateString || time * 1000)) / 1000); + tooltip = datePipe.transform(new Date(dateString || time * 1000), 'yyyy-MM-dd HH:mm'); break; case 'until': case 'within': - seconds = (+new Date(this.time) - +new Date()) / 1000; - this.tooltip = this.datePipe.transform(new Date(this.time), 'yyyy-MM-dd HH:mm'); + seconds = (+new Date(time) - +new Date()) / 1000; + tooltip = datePipe.transform(new Date(time), 'yyyy-MM-dd HH:mm'); break; default: - seconds = Math.floor(this.time); - this.tooltip = ''; + seconds = Math.floor(time); + tooltip = ''; } - if (!this.showTooltip || this.relative) { - this.tooltip = ''; + if (!showTooltip || relative) { + tooltip = ''; } - if (seconds < 1 && this.kind === 'span') { - return $localize`:@@date-base.immediately:Immediately`; + if (seconds < 1 && kind === 'span') { + return { tooltip, text: $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); + 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 $localize`:@@date-base.just-now:Just now`; - } else if (this.kind === 'until' || this.kind === 'within') { + return { tooltip, text: $localize`:@@date-base.just-now:Just now` }; + } else if (kind === 'until' || kind === 'within') { seconds = 60; } } @@ -119,44 +150,44 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy { 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]) { + 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 (this.units.indexOf(precisionUnit) === this.units.indexOf(this.minUnit)) { + if (units.indexOf(precisionUnit) === units.indexOf(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; + 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 / this.intervals[precisionUnit]) * roundFactor) / roundFactor; + rounded = Math.round((seconds / intervals[precisionUnit]) * roundFactor) / roundFactor; } - if ((this.kind !== 'until' && this.kind !== 'within')|| this.numUnits === 1) { - return this.formatTime(this.kind, precisionUnit, rounded); + if ((kind !== 'until' && kind !== 'within')|| numUnits === 1) { + return { tooltip, text: TimeComponent.formatTime(kind, precisionUnit, rounded) }; } else { if (!usedUnits) { - result.push(this.formatTime(this.kind, precisionUnit, rounded)); + result.push(TimeComponent.formatTime(kind, precisionUnit, rounded)); } else { - result.push(this.formatTime('', precisionUnit, rounded)); + result.push(TimeComponent.formatTime('', precisionUnit, rounded)); } - seconds -= (rounded * this.intervals[precisionUnit]); + seconds -= (rounded * intervals[precisionUnit]); usedUnits++; - if (usedUnits >= this.numUnits) { - return result.join(', '); + if (usedUnits >= numUnits) { + return { tooltip, text: result.join(', ') }; } } } } - return result.join(', '); + return { tooltip, text: result.join(', ') }; } - private formatTime(kind, unit, number): string { + static formatTime(kind, unit, number): string { const dateStrings = dates(number); switch (kind) { case 'since': diff --git a/frontend/src/app/components/utxo-graph/utxo-graph.component.ts b/frontend/src/app/components/utxo-graph/utxo-graph.component.ts index 5e034a700..91dc70240 100644 --- a/frontend/src/app/components/utxo-graph/utxo-graph.component.ts +++ b/frontend/src/app/components/utxo-graph/utxo-graph.component.ts @@ -6,6 +6,14 @@ import { StateService } from '../../services/state.service'; import { Router } from '@angular/router'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { renderSats } from '../../shared/common.utils'; +import { colorToHex, hexToColor, mix } from '../block-overview-graph/utils'; +import { TimeComponent } from '../time/time.component'; + +const newColorHex = '1bd8f4'; +const oldColorHex = '9339f4'; +const pendingColorHex = 'eba814'; +const newColor = hexToColor(newColorHex); +const oldColor = hexToColor(oldColorHex); @Component({ selector: 'app-utxo-graph', @@ -29,7 +37,8 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { @Input() widget: boolean = false; subscription: Subscription; - redraw$: BehaviorSubject = new BehaviorSubject(false); + lastUpdate: number = 0; + updateInterval; chartOptions: EChartsOption = {}; chartInitOptions = { @@ -46,7 +55,14 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { private zone: NgZone, private router: Router, private relativeUrlPipe: RelativeUrlPipe, - ) {} + ) { + // 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 { this.isLoading = true; @@ -82,7 +98,18 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { // Naive algorithm to pack circles as tightly as possible without overlaps const placedCircles: { x: number, y: number, r: number, utxo: Utxo, distances: number[] }[] = []; // 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) => { + if (a.value === b.value) { + 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); let centerOfMass = { x: 0, y: 0 }; let weightOfMass = 0; sortedUtxos.forEach((utxo, index) => { @@ -192,7 +219,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { const x = datum[2] as number; const y = datum[3] as number; const r = datum[4] as number; - if (r * scale < 3) { + if (r * scale < 2) { // skip items too small to render cleanly return; } @@ -207,7 +234,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { r: (r * scale) - 1, }, style: { - fill: '#5470c6', + fill: '#' + this.getColor(utxo), } }, ]; @@ -230,7 +257,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { type: 'group', children: elements, }; - } + }, }], tooltip: { backgroundColor: 'rgba(17, 19, 31, 1)', @@ -247,14 +274,40 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { return ` ${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}
- ${valueStr}`; + ${valueStr} +
+ ${utxo.status.confirmed ? 'Confirmed ' + TimeComponent.calculate(utxo.status.block_time, 'since', true, 1, 'minute').text : 'Pending'} + `; }, } }; + this.lastUpdate = Date.now(); 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 { if (e.data?.[0]?.txid) { this.zone.run(() => { @@ -277,6 +330,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { if (this.subscription) { this.subscription.unsubscribe(); } + clearInterval(this.updateInterval); } isMobile(): boolean { diff --git a/frontend/src/app/shared/common.utils.ts b/frontend/src/app/shared/common.utils.ts index 6bdc3262b..5ccb369f6 100644 --- a/frontend/src/app/shared/common.utils.ts +++ b/frontend/src/app/shared/common.utils.ts @@ -204,12 +204,12 @@ export function renderSats(value: number, network: string, mode: 'sats' | 'btc' break; } if (mode === 'btc' || (mode === 'auto' && value >= 1000000)) { - return `${amountShortenerPipe.transform(value / 100000000)} ${prefix}BTC`; + return `${amountShortenerPipe.transform(value / 100000000, 2)} ${prefix}BTC`; } else { if (prefix.length) { prefix += '-'; } - return `${amountShortenerPipe.transform(value)} ${prefix}sats`; + return `${amountShortenerPipe.transform(value, 2)} ${prefix}sats`; } } From 9984621e5e3f8cede57c8d862d6b3b37122cac91 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 24 Sep 2024 15:33:08 +0000 Subject: [PATCH 2/4] refactor static time formatting into new service --- frontend/src/app/app.module.ts | 2 + .../src/app/components/time/time.component.ts | 262 +----------------- .../utxo-graph/utxo-graph.component.ts | 6 +- 3 files changed, 9 insertions(+), 261 deletions(-) diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 50bbd88b9..d1129a602 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -21,6 +21,7 @@ import { StorageService } from './services/storage.service'; import { HttpCacheInterceptor } from './services/http-cache.interceptor'; import { LanguageService } from './services/language.service'; import { ThemeService } from './services/theme.service'; +import { TimeService } from './services/time.service'; import { FiatShortenerPipe } from './shared/pipes/fiat-shortener.pipe'; import { FiatCurrencyPipe } from './shared/pipes/fiat-currency.pipe'; import { ShortenStringPipe } from './shared/pipes/shorten-string-pipe/shorten-string.pipe'; @@ -42,6 +43,7 @@ const providers = [ EnterpriseService, LanguageService, ThemeService, + TimeService, ShortenStringPipe, FiatShortenerPipe, FiatCurrencyPipe, diff --git a/frontend/src/app/components/time/time.component.ts b/frontend/src/app/components/time/time.component.ts index f0c73c80b..6360bca4a 100644 --- a/frontend/src/app/components/time/time.component.ts +++ b/frontend/src/app/components/time/time.component.ts @@ -1,29 +1,6 @@ import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnChanges } from '@angular/core'; import { StateService } from '../../services/state.service'; -import { dates } from '../../shared/i18n/dates'; -import { DatePipe } from '@angular/common'; - -const datePipe = new DatePipe(navigator.language || 'en-US'); - -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 -}; +import { TimeService } from '../../services/time.service'; @Component({ selector: 'app-time', @@ -52,6 +29,7 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy { constructor( private ref: ChangeDetectorRef, private stateService: StateService, + private timeService: TimeService, ) {} ngOnInit() { @@ -79,7 +57,7 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy { } calculateTime(): void { - const { text, tooltip } = TimeComponent.calculate( + const { text, tooltip } = this.timeService.calculate( this.time, this.kind, this.relative, @@ -95,238 +73,4 @@ export class TimeComponent implements OnInit, OnChanges, OnDestroy { this.text = text; this.tooltip = tooltip; } - - static 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 = 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 = 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 = []; - 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: TimeComponent.formatTime(kind, precisionUnit, rounded) }; - } else { - if (!usedUnits) { - result.push(TimeComponent.formatTime(kind, precisionUnit, rounded)); - } else { - result.push(TimeComponent.formatTime('', precisionUnit, rounded)); - } - seconds -= (rounded * intervals[precisionUnit]); - usedUnits++; - if (usedUnits >= numUnits) { - return { tooltip, text: result.join(', ') }; - } - } - } - } - return { tooltip, text: result.join(', ') }; - } - - static 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; - } - } - } - } } diff --git a/frontend/src/app/components/utxo-graph/utxo-graph.component.ts b/frontend/src/app/components/utxo-graph/utxo-graph.component.ts index 91dc70240..310ff0356 100644 --- a/frontend/src/app/components/utxo-graph/utxo-graph.component.ts +++ b/frontend/src/app/components/utxo-graph/utxo-graph.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; import { EChartsOption } from '../../graphs/echarts'; -import { BehaviorSubject, Subscription } from 'rxjs'; +import { Subscription } from 'rxjs'; import { Utxo } from '../../interfaces/electrs.interface'; import { StateService } from '../../services/state.service'; import { Router } from '@angular/router'; @@ -8,6 +8,7 @@ import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pi import { renderSats } from '../../shared/common.utils'; import { colorToHex, hexToColor, mix } from '../block-overview-graph/utils'; import { TimeComponent } from '../time/time.component'; +import { TimeService } from '../../services/time.service'; const newColorHex = '1bd8f4'; const oldColorHex = '9339f4'; @@ -55,6 +56,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { private zone: NgZone, private router: Router, private relativeUrlPipe: RelativeUrlPipe, + private timeService: TimeService, ) { // re-render the chart every 10 seconds, to keep the age colors up to date this.updateInterval = setInterval(() => { @@ -276,7 +278,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
${valueStr}
- ${utxo.status.confirmed ? 'Confirmed ' + TimeComponent.calculate(utxo.status.block_time, 'since', true, 1, 'minute').text : 'Pending'} + ${utxo.status.confirmed ? 'Confirmed ' + this.timeService.calculate(utxo.status.block_time, 'since', true, 1, 'minute').text : 'Pending'} `; }, } From 9091fc92101ee5393a4ea0f50ae742ac62e5d268 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 24 Sep 2024 15:55:23 +0000 Subject: [PATCH 3/4] add missing time.service.ts file --- frontend/src/app/services/time.service.ts | 266 ++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 frontend/src/app/services/time.service.ts diff --git a/frontend/src/app/services/time.service.ts b/frontend/src/app/services/time.service.ts new file mode 100644 index 000000000..6f7978774 --- /dev/null +++ b/frontend/src/app/services/time.service.ts @@ -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 ''; + } +} From 83b60941743506d38fc9dbe6f318fb6533fce287 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 24 Sep 2024 23:30:24 +0000 Subject: [PATCH 4/4] optimize utxo graph layout algorithm, enable transitions --- .../utxo-graph/utxo-graph.component.ts | 187 ++++++++++-------- 1 file changed, 110 insertions(+), 77 deletions(-) diff --git a/frontend/src/app/components/utxo-graph/utxo-graph.component.ts b/frontend/src/app/components/utxo-graph/utxo-graph.component.ts index 310ff0356..b220ae6ab 100644 --- a/frontend/src/app/components/utxo-graph/utxo-graph.component.ts +++ b/frontend/src/app/components/utxo-graph/utxo-graph.component.ts @@ -7,7 +7,6 @@ import { Router } from '@angular/router'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { renderSats } from '../../shared/common.utils'; import { colorToHex, hexToColor, mix } from '../block-overview-graph/utils'; -import { TimeComponent } from '../time/time.component'; import { TimeService } from '../../services/time.service'; const newColorHex = '1bd8f4'; @@ -16,6 +15,30 @@ 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({ selector: 'app-utxo-graph', templateUrl: './utxo-graph.component.html', @@ -76,7 +99,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { } } - prepareChartOptions(utxos: Utxo[]) { + prepareChartOptions(utxos: Utxo[]): void { if (!utxos || utxos.length === 0) { return; } @@ -85,20 +108,21 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { // Helper functions 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 d = distance(x1, y1, x2, y2); - const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d); - const h = Math.sqrt(r1 * r1 - a * a); - const x3 = x1 + a * (x2 - x1) / d; - const y3 = y1 + a * (y2 - y1) / d; - return [ - [x3 + h * (y2 - y1) / d, y3 - h * (x2 - x1) / d], - [x3 - h * (y2 - y1) / d, y3 + h * (x2 - x1) / d] - ]; + const intersection = (c1: Circle, c2: Circle, d: number, r: number, side: boolean): { x: number, y: number} => { + const d1 = c1.r + r; + const d2 = c2.r + r; + const a = (d1 * d1 - d2 * d2 + d * d) / (2 * d); + const h = Math.sqrt(d1 * d1 - a * a); + const x3 = c1.x + a * (c2.x - c1.x) / d; + const y3 = c1.y + a * (c2.y - c1.y) / d; + return side + ? { 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 - const placedCircles: { x: number, y: number, r: number, utxo: Utxo, distances: number[] }[] = []; + // ~Linear algorithm to pack circles as tightly as possible without overlaps + 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 const sortedUtxos = utxos.sort((a, b) => { if (a.value === b.value) { @@ -112,78 +136,82 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { } return b.value - a.value; }).slice(0, 500); - let centerOfMass = { x: 0, y: 0 }; - let weightOfMass = 0; + const maxR = Math.sqrt(sortedUtxos.reduce((max, utxo) => Math.max(max, utxo.value), 0)); sortedUtxos.forEach((utxo, index) => { // area proportional to value const r = Math.sqrt(utxo.value); // special cases for the first two utxos if (index === 0) { - placedCircles.push({ x: 0, y: 0, r, utxo, distances: [0] }); + placedCircles.push({ x: 0, y: 0, r, utxo, i: index }); return; } if (index === 1) { const c = placedCircles[0]; - placedCircles.push({ x: c.r + r, y: 0, r, utxo, distances: [c.r + r, 0] }); - c.distances.push(c.r + r); + placedCircles.push({ x: c.r + r, y: 0, r, utxo, i: index }); + 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; } // 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 - const candidates: [number, number, number[]][] = []; const numCircles = placedCircles.length; - for (let i = 0; i < numCircles; i++) { - for (let j = i + 1; j < numCircles; j++) { - const c1 = placedCircles[i]; - const c2 = placedCircles[j]; - if (c1.distances[j] > (c1.r + c2.r + r + r)) { - // too far apart for new circle to touch both + let newCircle: UtxoCircle = null; + while (positions.length > 0) { + const position = positions.shift(); + // if the circles are too far apart, skip + if (position.d > (position.c1.r + position.c2.r + r + r)) { + 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; } - const points = intersectionPoints(c1.x, c1.y, c1.r + r, c2.x, c2.y, c2.r + r); - points.forEach(([x, y]) => { - const distances: number[] = []; - let valid = true; - for (let k = 0; k < numCircles; k++) { - const c = placedCircles[k]; - const d = distance(x, y, c.x, c.y); - if (k !== i && k !== j && d < (r + c.r)) { - valid = false; - break; - } else { - distances.push(d); - } + const d = distance(x, y, c.x, c.y); + if (d < (r + c.r)) { + valid = false; + break; + } else { + nearbyCircles.push({ c, d, s: d - c.r - r }); + } + } + if (valid) { + newCircle = { x, y, r, utxo, i: index }; + // add new positions to the candidate list + 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; } } - - // Pick the candidate closest to the center of mass - const [x, y, distances] = candidates.length ? candidates.reduce((closest, candidate) => - distance(candidate[0], candidate[1], centerOfMass[0], centerOfMass[1]) < - distance(closest[0], closest[1], centerOfMass[0], centerOfMass[1]) - ? 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]); + if (newCircle) { + placedCircles.push(newCircle); + } else { + // should never happen + return; } - 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 @@ -194,23 +222,26 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { const width = maxX - minX; const height = maxY - minY; - const data = placedCircles.map((circle, index) => [ + const data = placedCircles.map((circle) => [ + circle.utxo.txid + circle.utxo.vout, circle.utxo, - index, circle.x, circle.y, - circle.r + circle.r, ]); this.chartOptions = { series: [{ type: 'custom', coordinateSystem: undefined, - data, + data: data, + encode: { + itemName: 0, + x: 2, + y: 3, + r: 4, + }, renderItem: (params, api) => { - const idx = params.dataIndex; - const datum = data[idx]; - const utxo = datum[0] as Utxo; const chartWidth = api.getWidth(); const chartHeight = api.getHeight(); const scale = Math.min(chartWidth / width, chartHeight / height); @@ -218,6 +249,9 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { const scaledHeight = height * scale; const offsetX = (chartWidth - scaledWidth) / 2 - minX * 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 y = datum[3] as number; const r = datum[4] as number; @@ -225,14 +259,13 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { // skip items too small to render cleanly return; } + const valueStr = renderSats(utxo.value, this.stateService.network); const elements: any[] = [ { type: 'circle', autoBatch: true, shape: { - cx: (x * scale) + offsetX, - cy: (y * scale) + offsetY, r: (r * scale) - 1, }, style: { @@ -240,12 +273,10 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { } }, ]; - const labelFontSize = Math.min(36, r * scale * 0.25); + const labelFontSize = Math.min(36, r * scale * 0.3); if (labelFontSize > 8) { elements.push({ type: 'text', - x: (x * scale) + offsetX, - y: (y * scale) + offsetY, style: { text: valueStr, fontSize: labelFontSize, @@ -257,6 +288,8 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { } return { type: 'group', + x: (x * scale) + offsetX, + y: (y * scale) + offsetY, children: elements, }; }, @@ -271,7 +304,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { }, borderColor: '#000', 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); return ` ${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}