From 409e5a335f777e30da17ba647da0e64301931ca3 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 23 Sep 2022 19:03:21 +0000 Subject: [PATCH] Improve tx flow diagram drawing algorithm --- .../transaction/transaction.component.html | 12 +- .../transaction/transaction.component.ts | 4 +- .../tx-bowtie-graph.component.ts | 148 ++++++++++++------ 3 files changed, 116 insertions(+), 48 deletions(-) diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index f25e2a012..81d3778f8 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -191,12 +191,20 @@
-

Diagram

+

Flow

- + +
diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 948eecdb1..3c29f210d 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -50,7 +50,9 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { graphExpanded: boolean = false; graphWidth: number = 1000; graphHeight: number = 360; + inOutLimit: number = 150; maxInOut: number = 0; + tooltipPosition: { x: number, y: number }; @ViewChild('graphContainer') @@ -298,7 +300,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { } setupGraph() { - this.maxInOut = Math.min(250, Math.max(this.tx?.vin?.length || 1, this.tx?.vout?.length + 1 || 1)); + this.maxInOut = Math.min(this.inOutLimit, Math.max(this.tx?.vin?.length || 1, this.tx?.vout?.length + 1 || 1)); this.graphHeight = Math.min(360, this.maxInOut * 80); } diff --git a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts index 78a865b89..16e2736f7 100644 --- a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts +++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.ts @@ -19,8 +19,6 @@ interface Xput { confidential?: boolean; } -const lineLimit = 250; - @Component({ selector: 'tx-bowtie-graph', templateUrl: './tx-bowtie-graph.component.html', @@ -31,7 +29,8 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { @Input() network: string; @Input() width = 1200; @Input() height = 600; - @Input() combinedWeight = 100; + @Input() lineLimit = 250; + @Input() maxCombinedWeight = 100; @Input() minWeight = 2; // @Input() maxStrands = 24; // number of inputs/outputs to keep fully on-screen. @Input() tooltip = false; @@ -42,6 +41,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { outputs: SvgLine[]; middle: SvgLine; midWidth: number; + combinedWeight: number; isLiquid: boolean = false; hoverLine: Xput | void = null; tooltipPosition = { x: 0, y: 0 }; @@ -62,20 +62,19 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { gradient: string[] = ['#105fb0', '#105fb0']; ngOnInit(): void { - this.isLiquid = (this.network === 'liquid' || this.network === 'liquidtestnet'); - this.gradient = this.gradientColors[this.network]; - this.midWidth = Math.min(50, Math.ceil(this.width / 20)); this.initGraph(); } ngOnChanges(): void { - this.isLiquid = (this.network === 'liquid' || this.network === 'liquidtestnet'); - this.gradient = this.gradientColors[this.network]; - this.midWidth = Math.min(50, Math.ceil(this.width / 20)); this.initGraph(); } 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)); + const totalValue = this.calcTotalValue(this.tx); let voutWithFee = this.tx.vout.map(v => { return { @@ -103,19 +102,19 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { } as Xput; }); - if (truncatedInputs.length > lineLimit) { - const valueOfRest = truncatedInputs.slice(lineLimit).reduce((r, v) => { + if (truncatedInputs.length > this.lineLimit) { + const valueOfRest = truncatedInputs.slice(this.lineLimit).reduce((r, v) => { return r + (v.value || 0); }, 0); - truncatedInputs = truncatedInputs.slice(0, lineLimit); - truncatedInputs.push({ type: 'input', value: valueOfRest, rest: this.tx.vin.length - lineLimit }); + truncatedInputs = truncatedInputs.slice(0, this.lineLimit); + truncatedInputs.push({ type: 'input', value: valueOfRest, rest: this.tx.vin.length - this.lineLimit }); } - if (voutWithFee.length > lineLimit) { - const valueOfRest = voutWithFee.slice(lineLimit).reduce((r, v) => { + if (voutWithFee.length > this.lineLimit) { + const valueOfRest = voutWithFee.slice(this.lineLimit).reduce((r, v) => { return r + (v.value || 0); }, 0); - voutWithFee = voutWithFee.slice(0, lineLimit); - voutWithFee.push({ type: 'output', value: valueOfRest, rest: outputCount - lineLimit }); + voutWithFee = voutWithFee.slice(0, this.lineLimit); + voutWithFee.push({ type: 'output', value: valueOfRest, rest: outputCount - this.lineLimit }); } this.inputData = truncatedInputs; @@ -126,7 +125,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { 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 + 0.5}; stroke: ${this.gradient[1]}` + style: `stroke-width: ${this.combinedWeight + 1}; stroke: ${this.gradient[1]}` }; } @@ -157,7 +156,7 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { initLines(side: 'in' | 'out', xputs: Xput[], total: number, maxVisibleStrands: number): SvgLine[] { if (!total) { - const weights = xputs.map((put): number => this.combinedWeight / xputs.length); + const weights = xputs.map((put) => this.combinedWeight / xputs.length); return this.linesFromWeights(side, xputs, weights, maxVisibleStrands); } else { let unknownCount = 0; @@ -171,19 +170,26 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { }); const unknownShare = unknownTotal / unknownCount; // conceptual weights - const weights = xputs.map((put): number => this.combinedWeight * (put.value == null ? unknownShare : put.value as number) / total); + const weights = xputs.map((put) => this.combinedWeight * (put.value == null ? unknownShare : put.value as number) / total); return this.linesFromWeights(side, xputs, weights, maxVisibleStrands); } } - linesFromWeights(side: 'in' | 'out', xputs: Xput[], weights: number[], maxVisibleStrands: number) { - const lines = []; - // actual displayed line thicknesses - const minWeights = weights.map((w) => Math.max(this.minWeight - 1, w) + 1); + 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, + }; + }); const visibleStrands = Math.min(maxVisibleStrands, xputs.length); - const visibleWeight = minWeights.slice(0, visibleStrands).reduce((acc, v) => v + acc, 0); + const visibleWeight = lineParams.slice(0, visibleStrands).reduce((acc, v) => v.thickness + acc, 0); const gaps = visibleStrands - 1; + // bounds of the middle segment const innerTop = (this.height / 2) - (this.combinedWeight / 2); const innerBottom = innerTop + this.combinedWeight; // tracks the visual bottom of the endpoints of the previous line @@ -192,39 +198,91 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { // gap between strands const spacing = (this.height - visibleWeight) / gaps; - for (let i = 0; i < xputs.length; i++) { - const weight = weights[i]; - const minWeight = minWeights[i]; + // curve adjustments to prevent overlaps + let offset = 0; + let minOffset = 0; + let maxOffset = 0; + let lastWeight = 0; + let pad = 0; + lineParams.forEach((line, i) => { // set the vertical position of the (center of the) outer side of the line - let outer = lastOuter + (minWeight / 2); - const inner = Math.min(innerBottom + (minWeight / 2), Math.max(innerTop + (minWeight / 2), lastInner + (weight / 2))); + line.outerY = lastOuter + (line.thickness / 2); + line.innerY = Math.min(innerBottom + (line.thickness / 2), Math.max(innerTop + (line.thickness / 2), lastInner + (line.weight / 2))); // special case to center single input/outputs if (xputs.length === 1) { - outer = (this.height / 2); + line.outerY = (this.height / 2); } - lastOuter += minWeight + spacing; - lastInner += weight; - lines.push({ - path: this.makePath(side, outer, inner, minWeight), - style: this.makeStyle(minWeight, xputs[i].type), - class: xputs[i].type - }); - } + lastOuter += line.thickness + spacing; + lastInner += line.weight; - return lines; + // 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 + + // 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 + }; + }); } - makePath(side: 'in' | 'out', outer: number, inner: number, weight: number): string { - const start = side === 'in' ? (weight * 0.5) : this.width - (weight * 0.5); - const center = this.width / 2 + (side === 'in' ? -(this.midWidth * 0.9) : (this.midWidth * 0.9) ); - const midpoint = (start + center) / 2; + 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; + // correct for svg horizontal gradient bug if (Math.round(outer) === Math.round(inner)) { outer -= 1; } - return `M ${start} ${outer} C ${midpoint} ${outer}, ${midpoint} ${inner}, ${center} ${inner}`; + + 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}`; + } } makeStyle(minWeight, type): string {