mempool/frontend/src/app/components/utxo-graph/utxo-graph.component.ts

375 lines
12 KiB
TypeScript

import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, NgZone, OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
import { EChartsOption } from '@app/graphs/echarts';
import { Subscription } from 'rxjs';
import { Utxo } from '@interfaces/electrs.interface';
import { StateService } from '@app/services/state.service';
import { Router } from '@angular/router';
import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe';
import { renderSats } from '@app/shared/common.utils';
import { colorToHex, hexToColor, mix } from '@components/block-overview-graph/utils';
import { TimeService } from '@app/services/time.service';
const newColorHex = '1bd8f4';
const oldColorHex = '9339f4';
const pendingColorHex = 'eba814';
const newColor = hexToColor(newColorHex);
const oldColor = hexToColor(oldColorHex);
interface Circle {
x: number,
y: number,
r: number,
i: number,
}
interface UtxoCircle extends Circle {
utxo: Utxo;
}
function sortedInsert(positions: { c1: Circle, c2: Circle, d: number, p: number, side?: boolean }[], newPosition: { c1: Circle, c2: Circle, d: number, p: number }): void {
let left = 0;
let right = positions.length;
while (left < right) {
const mid = Math.floor((left + right) / 2);
if (positions[mid].p > newPosition.p) {
right = mid;
} else {
left = mid + 1;
}
}
positions.splice(left, 0, newPosition, {...newPosition, side: true });
}
@Component({
selector: 'app-utxo-graph',
templateUrl: './utxo-graph.component.html',
styleUrls: ['./utxo-graph.component.scss'],
styles: [`
.loadingGraphs {
position: absolute;
top: 50%;
left: calc(50% - 15px);
z-index: 99;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UtxoGraphComponent implements OnChanges, OnDestroy {
@Input() utxos: Utxo[];
@Input() height: number = 200;
@Input() right: number | string = 10;
@Input() left: number | string = 70;
@Input() widget: boolean = false;
subscription: Subscription;
lastUpdate: number = 0;
updateInterval;
chartOptions: EChartsOption = {};
chartInitOptions = {
renderer: 'svg',
};
error: any;
isLoading = true;
chartInstance: any = undefined;
constructor(
public stateService: StateService,
private cd: ChangeDetectorRef,
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(() => {
if (this.lastUpdate < Date.now() - 10000 && this.utxos) {
this.prepareChartOptions(this.utxos);
}
}, 10000);
}
ngOnChanges(changes: SimpleChanges): void {
this.isLoading = true;
if (!this.utxos) {
return;
}
if (changes.utxos) {
this.prepareChartOptions(this.utxos);
}
}
prepareChartOptions(utxos: Utxo[]): void {
if (!utxos || utxos.length === 0) {
return;
}
this.isLoading = false;
// Helper functions
const distance = (x1: number, y1: number, x2: number, y2: number): number => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
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 };
};
// ~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) {
if (a.status.confirmed && !b.status.confirmed) {
return -1;
} else if (!a.status.confirmed && b.status.confirmed) {
return 1;
} else {
return a.status.block_height - b.status.block_height;
}
}
return b.value - a.value;
}).slice(0, 500);
const maxR = Math.sqrt(sortedUtxos.reduce((max, utxo) => Math.max(max, utxo.value), 0));
sortedUtxos.forEach((utxo, index) => {
// 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, i: index });
return;
}
if (index === 1) {
const c = placedCircles[0];
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
// find the closest such position to the center of the graph
// where the circle can be placed without overlapping other circles
const numCircles = placedCircles.length;
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 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) });
}
}
break;
}
}
if (newCircle) {
placedCircles.push(newCircle);
} else {
// should never happen
return;
}
});
// Precompute the bounding box of the graph
const minX = Math.min(...placedCircles.map(d => d.x - d.r));
const maxX = Math.max(...placedCircles.map(d => d.x + d.r));
const minY = Math.min(...placedCircles.map(d => d.y - d.r));
const maxY = Math.max(...placedCircles.map(d => d.y + d.r));
const width = maxX - minX;
const height = maxY - minY;
const data = placedCircles.map((circle) => [
circle.utxo.txid + circle.utxo.vout,
circle.utxo,
circle.x,
circle.y,
circle.r,
]);
this.chartOptions = {
series: [{
type: 'custom',
coordinateSystem: undefined,
data: data,
encode: {
itemName: 0,
x: 2,
y: 3,
r: 4,
},
renderItem: (params, api) => {
const chartWidth = api.getWidth();
const chartHeight = api.getHeight();
const scale = Math.min(chartWidth / width, chartHeight / height);
const scaledWidth = width * scale;
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;
if (r * scale < 2) {
// skip items too small to render cleanly
return;
}
const valueStr = renderSats(utxo.value, this.stateService.network);
const elements: any[] = [
{
type: 'circle',
autoBatch: true,
shape: {
r: (r * scale) - 1,
},
style: {
fill: '#' + this.getColor(utxo),
}
},
];
const labelFontSize = Math.min(36, r * scale * 0.3);
if (labelFontSize > 8) {
elements.push({
type: 'text',
style: {
text: valueStr,
fontSize: labelFontSize,
fill: '#fff',
align: 'center',
verticalAlign: 'middle',
},
});
}
return {
type: 'group',
x: (x * scale) + offsetX,
y: (y * scale) + offsetY,
children: elements,
};
},
}],
tooltip: {
backgroundColor: 'rgba(17, 19, 31, 1)',
borderRadius: 4,
shadowColor: 'rgba(0, 0, 0, 0.5)',
textStyle: {
color: 'var(--tooltip-grey)',
align: 'left',
},
borderColor: '#000',
formatter: (params: any): string => {
const utxo = params.data[1] as Utxo;
const valueStr = renderSats(utxo.value, this.stateService.network);
return `
<b style="color: white;">${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}</b>
<br>
${valueStr}
<br>
${utxo.status.confirmed ? 'Confirmed ' + this.timeService.calculate(utxo.status.block_time, 'since', true, 1, 'minute').text : 'Pending'}
`;
},
}
};
this.lastUpdate = Date.now();
this.cd.markForCheck();
}
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?.[1]?.txid) {
this.zone.run(() => {
const url = this.relativeUrlPipe.transform(`/tx/${e.data[1].txid}`);
if (e.event.event.shiftKey || e.event.event.ctrlKey || e.event.event.metaKey) {
window.open(url + '?mode=details#vout=' + e.data[1].vout);
} else {
this.router.navigate([url], { fragment: `vout=${e.data[1].vout}` });
}
});
}
}
onChartInit(ec): void {
this.chartInstance = ec;
this.chartInstance.on('click', 'series', this.onChartClick.bind(this));
}
ngOnDestroy(): void {
if (this.subscription) {
this.subscription.unsubscribe();
}
clearInterval(this.updateInterval);
}
isMobile(): boolean {
return (window.innerWidth <= 767.98);
}
}