mirror of
https://github.com/mempool/mempool.git
synced 2025-03-12 10:31:03 +01:00
address utxo chart color by age & updates
This commit is contained in:
parent
1038b4f908
commit
06e699e52b
5 changed files with 211 additions and 74 deletions
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue