address utxo chart color by age & updates

This commit is contained in:
Mononaut 2024-09-22 16:49:08 +00:00
parent 1038b4f908
commit 06e699e52b
No known key found for this signature in database
GPG key ID: A3F058E41374C04E
5 changed files with 211 additions and 74 deletions

View file

@ -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;

View file

@ -11,6 +11,10 @@ export function hexToColor(hex: string): Color {
};
}
export function colorToHex(color: Color): string {
return [color.r, color.g, color.b].map(c => Math.round(c * 255).toString(16)).join('');
}
export function desaturate(color: Color, amount: number): Color {
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,

View file

@ -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':

View file

@ -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<boolean> = 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 `
<b style="color: white;">${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}</b>
<br>
${valueStr}`;
${valueStr}
<br>
${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 {

View file

@ -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`;
}
}