mempool/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts

391 lines
14 KiB
TypeScript
Raw Normal View History

import { Component, OnInit, Input, Output, EventEmitter, OnChanges, HostListener } from '@angular/core';
import { StateService } from '../../services/state.service';
import { Outspend, Transaction } from '../../interfaces/electrs.interface';
import { Router } from '@angular/router';
import { ReplaySubject, merge, Subscription } from 'rxjs';
import { tap, switchMap } from 'rxjs/operators';
import { ApiService } from '../../services/api.service';
import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
2022-08-22 19:12:04 +00:00
interface SvgLine {
path: string;
style: string;
class?: string;
}
interface Xput {
type: 'input' | 'output' | 'fee';
value?: number;
2022-09-17 01:20:08 +00:00
index?: number;
address?: string;
rest?: number;
coinbase?: boolean;
pegin?: boolean;
pegout?: string;
confidential?: boolean;
}
2022-08-22 19:12:04 +00:00
@Component({
selector: 'tx-bowtie-graph',
templateUrl: './tx-bowtie-graph.component.html',
styleUrls: ['./tx-bowtie-graph.component.scss'],
})
export class TxBowtieGraphComponent implements OnInit, OnChanges {
@Input() tx: Transaction;
2022-08-24 18:54:11 +00:00
@Input() network: string;
2022-08-22 19:12:04 +00:00
@Input() width = 1200;
@Input() height = 600;
@Input() lineLimit = 250;
@Input() maxCombinedWeight = 100;
2022-08-22 19:12:04 +00:00
@Input() minWeight = 2; //
@Input() maxStrands = 24; // number of inputs/outputs to keep fully on-screen.
2022-09-17 01:20:08 +00:00
@Input() tooltip = false;
2022-08-22 19:12:04 +00:00
@Output() selectInput = new EventEmitter<number>();
@Output() selectOutput = new EventEmitter<number>();
2022-09-17 01:20:08 +00:00
inputData: Xput[];
outputData: Xput[];
2022-08-22 19:12:04 +00:00
inputs: SvgLine[];
outputs: SvgLine[];
middle: SvgLine;
midWidth: number;
combinedWeight: number;
2022-08-24 18:54:11 +00:00
isLiquid: boolean = false;
2022-09-17 01:20:08 +00:00
hoverLine: Xput | void = null;
tooltipPosition = { x: 0, y: 0 };
outspends: Outspend[] = [];
outspendsSubscription: Subscription;
refreshOutspends$: ReplaySubject<string> = new ReplaySubject();
2022-08-24 18:54:11 +00:00
gradientColors = {
'': ['#9339f4', '#105fb0'],
bisq: ['#9339f4', '#105fb0'],
// liquid: ['#116761', '#183550'],
liquid: ['#09a197', '#0f62af'],
// 'liquidtestnet': ['#494a4a', '#272e46'],
'liquidtestnet': ['#d2d2d2', '#979797'],
// testnet: ['#1d486f', '#183550'],
testnet: ['#4edf77', '#10a0af'],
// signet: ['#6f1d5d', '#471850'],
signet: ['#d24fc8', '#a84fd2'],
};
gradient: string[] = ['#105fb0', '#105fb0'];
2022-08-22 19:12:04 +00:00
constructor(
private router: Router,
private relativeUrlPipe: RelativeUrlPipe,
private stateService: StateService,
private apiService: ApiService,
) { }
2022-08-22 19:12:04 +00:00
ngOnInit(): void {
this.initGraph();
this.outspendsSubscription = merge(
this.refreshOutspends$
.pipe(
switchMap((txid) => this.apiService.getOutspendsBatched$([txid])),
tap((outspends: Outspend[][]) => {
if (!this.tx || !outspends || !outspends.length) {
return;
}
this.outspends = outspends[0];
}),
),
this.stateService.utxoSpent$
.pipe(
tap((utxoSpent) => {
for (const i in utxoSpent) {
this.outspends[i] = {
spent: true,
txid: utxoSpent[i].txid,
vin: utxoSpent[i].vin,
};
}
}),
),
).subscribe(() => {});
2022-08-22 19:12:04 +00:00
}
ngOnChanges(): void {
this.initGraph();
this.refreshOutspends$.next(this.tx.txid);
2022-08-22 19:12:04 +00:00
}
initGraph(): void {
this.isLiquid = (this.network === 'liquid' || this.network === 'liquidtestnet');
this.gradient = this.gradientColors[this.network];
this.midWidth = Math.min(10, Math.ceil(this.width / 100));
this.combinedWeight = Math.min(this.maxCombinedWeight, Math.floor((this.width - (2 * this.midWidth)) / 6));
2022-08-22 19:12:04 +00:00
const totalValue = this.calcTotalValue(this.tx);
let voutWithFee = this.tx.vout.map((v, i) => {
2022-09-17 01:20:08 +00:00
return {
type: v.scriptpubkey_type === 'fee' ? 'fee' : 'output',
value: v?.value,
address: v?.scriptpubkey_address || v?.scriptpubkey_type?.toUpperCase(),
index: i,
pegout: v?.pegout?.scriptpubkey_address,
confidential: (this.isLiquid && v?.value === undefined),
2022-09-17 01:20:08 +00:00
} as Xput;
});
2022-08-22 19:12:04 +00:00
if (this.tx.fee && !this.isLiquid) {
voutWithFee.unshift({ type: 'fee', value: this.tx.fee });
}
2022-09-17 01:20:08 +00:00
const outputCount = voutWithFee.length;
2022-08-22 19:12:04 +00:00
let truncatedInputs = this.tx.vin.map((v, i) => {
2022-09-17 01:20:08 +00:00
return {
type: 'input',
value: v?.prevout?.value,
address: v?.prevout?.scriptpubkey_address || v?.prevout?.scriptpubkey_type?.toUpperCase(),
index: i,
coinbase: v?.is_coinbase,
pegin: v?.is_pegin,
confidential: (this.isLiquid && v?.prevout?.value === undefined),
2022-09-17 01:20:08 +00:00
} as Xput;
});
if (truncatedInputs.length > this.lineLimit) {
const valueOfRest = truncatedInputs.slice(this.lineLimit).reduce((r, v) => {
return r + (v.value || 0);
}, 0);
truncatedInputs = truncatedInputs.slice(0, this.lineLimit);
truncatedInputs.push({ type: 'input', value: valueOfRest, rest: this.tx.vin.length - this.lineLimit });
}
if (voutWithFee.length > this.lineLimit) {
const valueOfRest = voutWithFee.slice(this.lineLimit).reduce((r, v) => {
return r + (v.value || 0);
}, 0);
voutWithFee = voutWithFee.slice(0, this.lineLimit);
voutWithFee.push({ type: 'output', value: valueOfRest, rest: outputCount - this.lineLimit });
}
2022-09-17 01:20:08 +00:00
this.inputData = truncatedInputs;
this.outputData = voutWithFee;
this.inputs = this.initLines('in', truncatedInputs, totalValue, this.maxStrands);
2022-08-22 19:12:04 +00:00
this.outputs = this.initLines('out', voutWithFee, totalValue, this.maxStrands);
this.middle = {
path: `M ${(this.width / 2) - this.midWidth} ${(this.height / 2) + 0.5} L ${(this.width / 2) + this.midWidth} ${(this.height / 2) + 0.5}`,
style: `stroke-width: ${this.combinedWeight + 1}; stroke: ${this.gradient[1]}`
2022-08-22 19:12:04 +00:00
};
}
calcTotalValue(tx: Transaction): number {
const totalOutput = this.tx.vout.reduce((acc, v) => (v.value == null ? 0 : v.value) + acc, 0);
// simple sum of outputs + fee for bitcoin
if (!this.isLiquid) {
return this.tx.fee ? totalOutput + this.tx.fee : totalOutput;
} else {
const totalInput = this.tx.vin.reduce((acc, v) => (v?.prevout?.value == null ? 0 : v.prevout.value) + acc, 0);
const confidentialInputCount = this.tx.vin.reduce((acc, v) => acc + (v?.prevout?.value == null ? 1 : 0), 0);
const confidentialOutputCount = this.tx.vout.reduce((acc, v) => acc + (v.value == null ? 1 : 0), 0);
// if there are unknowns on both sides, the total is indeterminate, so we'll just fudge it
if (confidentialInputCount && confidentialOutputCount) {
const knownInputCount = (tx.vin.length - confidentialInputCount) || 1;
const knownOutputCount = (tx.vout.length - confidentialOutputCount) || 1;
// assume confidential inputs/outputs have the same average value as the known ones
const adjustedTotalInput = totalInput + ((totalInput / knownInputCount) * confidentialInputCount);
const adjustedTotalOutput = totalOutput + ((totalOutput / knownOutputCount) * confidentialOutputCount);
return Math.max(adjustedTotalInput, adjustedTotalOutput);
2022-08-22 19:12:04 +00:00
} else {
// otherwise knowing the actual total of one side suffices
return Math.max(totalInput, totalOutput);
2022-08-22 19:12:04 +00:00
}
}
}
initLines(side: 'in' | 'out', xputs: Xput[], total: number, maxVisibleStrands: number): SvgLine[] {
if (!total) {
const weights = xputs.map((put) => this.combinedWeight / xputs.length);
return this.linesFromWeights(side, xputs, weights, maxVisibleStrands);
} else {
let unknownCount = 0;
let unknownTotal = total;
xputs.forEach(put => {
if (put.value == null) {
unknownCount++;
} else {
unknownTotal -= put.value as number;
}
});
const unknownShare = unknownTotal / unknownCount;
// conceptual weights
const weights = xputs.map((put) => this.combinedWeight * (put.value == null ? unknownShare : put.value as number) / total);
return this.linesFromWeights(side, xputs, weights, maxVisibleStrands);
}
}
2022-08-22 19:12:04 +00:00
linesFromWeights(side: 'in' | 'out', xputs: Xput[], weights: number[], maxVisibleStrands: number): SvgLine[] {
const lineParams = weights.map((w) => {
return {
weight: w,
thickness: Math.max(this.minWeight - 1, w) + 1,
offset: 0,
innerY: 0,
outerY: 0,
};
});
2022-08-22 19:12:04 +00:00
const visibleStrands = Math.min(maxVisibleStrands, xputs.length);
const visibleWeight = lineParams.slice(0, visibleStrands).reduce((acc, v) => v.thickness + acc, 0);
2022-08-22 19:12:04 +00:00
const gaps = visibleStrands - 1;
// bounds of the middle segment
2022-08-22 19:12:04 +00:00
const innerTop = (this.height / 2) - (this.combinedWeight / 2);
const innerBottom = innerTop + this.combinedWeight;
// tracks the visual bottom of the endpoints of the previous line
let lastOuter = 0;
let lastInner = innerTop;
// gap between strands
const spacing = (this.height - visibleWeight) / gaps;
// curve adjustments to prevent overlaps
let offset = 0;
let minOffset = 0;
let maxOffset = 0;
let lastWeight = 0;
let pad = 0;
lineParams.forEach((line, i) => {
2022-08-22 19:12:04 +00:00
// set the vertical position of the (center of the) outer side of the line
line.outerY = lastOuter + (line.thickness / 2);
line.innerY = Math.min(innerBottom + (line.thickness / 2), Math.max(innerTop + (line.thickness / 2), lastInner + (line.weight / 2)));
2022-08-22 19:12:04 +00:00
// special case to center single input/outputs
if (xputs.length === 1) {
line.outerY = (this.height / 2);
2022-08-22 19:12:04 +00:00
}
lastOuter += line.thickness + spacing;
lastInner += line.weight;
// calculate conservative lower bound of the amount of horizontal offset
// required to prevent this line overlapping its neighbor
if (this.tooltip || !xputs[i].rest) {
const w = (this.width - Math.max(lastWeight, line.weight)) / 2; // approximate horizontal width of the curved section of the line
const y1 = line.outerY;
const y2 = line.innerY;
const t = (lastWeight + line.weight) / 2; // distance between center of this line and center of previous line
2022-08-22 19:12:04 +00:00
// slope of the inflection point of the bezier curve
const dx = 0.75 * w;
const dy = 1.5 * (y2 - y1);
const a = Math.atan2(dy, dx);
// parallel curves should be separated by >=t at the inflection point to prevent overlap
// vertical offset is always = t, contributing tCos(a)
// horizontal offset h will contribute hSin(a)
// tCos(a) + hSin(a) >= t
// h >= t(1 - cos(a)) / sin(a)
if (Math.sin(a) !== 0) {
// (absolute value clamped to t for sanity)
offset += Math.max(Math.min(t * (1 - Math.cos(a)) / Math.sin(a), t), -t);
}
line.offset = offset;
minOffset = Math.min(minOffset, offset);
maxOffset = Math.max(maxOffset, offset);
pad = Math.max(pad, line.thickness / 2);
lastWeight = line.weight;
} else {
// skip the offsets for consolidated lines in unfurls, since these *should* overlap a little
}
});
// normalize offsets
lineParams.forEach((line) => {
line.offset -= minOffset;
});
maxOffset -= minOffset;
return lineParams.map((line, i) => {
return {
path: this.makePath(side, line.outerY, line.innerY, line.thickness, line.offset, pad + maxOffset),
style: this.makeStyle(line.thickness, xputs[i].type),
class: xputs[i].type
};
});
2022-08-22 19:12:04 +00:00
}
makePath(side: 'in' | 'out', outer: number, inner: number, weight: number, offset: number, pad: number): string {
const start = (weight * 0.5);
const curveStart = Math.max(start + 1, pad - offset);
const end = this.width / 2 - (this.midWidth * 0.9) + 1;
const curveEnd = end - offset - 10;
const midpoint = (curveStart + curveEnd) / 2;
2022-08-24 18:54:11 +00:00
// correct for svg horizontal gradient bug
if (Math.round(outer) === Math.round(inner)) {
outer -= 1;
}
if (side === 'in') {
return `M ${start} ${outer} L ${curveStart} ${outer} C ${midpoint} ${outer}, ${midpoint} ${inner}, ${curveEnd} ${inner} L ${end} ${inner}`;
} else { // mirrored in y-axis for the right hand side
return `M ${this.width - start} ${outer} L ${this.width - curveStart} ${outer} C ${this.width - midpoint} ${outer}, ${this.width - midpoint} ${inner}, ${this.width - curveEnd} ${inner} L ${this.width - end} ${inner}`;
}
2022-08-22 19:12:04 +00:00
}
makeStyle(minWeight, type): string {
if (type === 'fee') {
2022-09-17 01:20:08 +00:00
return `stroke-width: ${minWeight}`;
2022-08-22 19:12:04 +00:00
} else {
return `stroke-width: ${minWeight}`;
}
}
2022-09-17 01:20:08 +00:00
@HostListener('pointermove', ['$event'])
onPointerMove(event) {
this.tooltipPosition = { x: event.offsetX, y: event.offsetY };
}
onHover(event, side, index): void {
if (side === 'input') {
this.hoverLine = {
...this.inputData[index],
index
};
} else {
this.hoverLine = {
...this.outputData[index]
2022-09-17 01:20:08 +00:00
};
}
}
onBlur(event, side, index): void {
this.hoverLine = null;
}
onClick(event, side, index): void {
if (side === 'input') {
const input = this.tx.vin[index];
if (input && input.txid && input.vout != null) {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), input.txid + ':' + input.vout], {
queryParamsHandling: 'merge',
fragment: 'flow'
});
} else {
this.selectInput.emit(index);
}
} else {
const output = this.tx.vout[index];
const outspend = this.outspends[index];
if (output && outspend && outspend.spent && outspend.txid) {
this.router.navigate([this.relativeUrlPipe.transform('/tx'), outspend.txid], {
queryParamsHandling: 'merge',
fragment: 'flow'
});
} else {
this.selectOutput.emit(index);
}
}
}
2022-08-22 19:12:04 +00:00
}