Improve tx flow diagram drawing algorithm
This commit is contained in:
		
							parent
							
								
									29118dc0e8
								
							
						
					
					
						commit
						409e5a335f
					
				@ -191,12 +191,20 @@
 | 
			
		||||
    <br>
 | 
			
		||||
 | 
			
		||||
    <div class="title">
 | 
			
		||||
      <h2 i18n="transaction.diagram|Transaction diagram">Diagram</h2>
 | 
			
		||||
      <h2 i18n="transaction.flow|Transaction flow">Flow</h2>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="box">
 | 
			
		||||
      <div class="graph-container" #graphContainer>
 | 
			
		||||
        <tx-bowtie-graph [tx]="tx" [width]="graphWidth" [height]="graphExpanded ? (maxInOut * 15) : graphHeight" [maxStrands]="graphExpanded ? maxInOut : 24" [network]="network" [tooltip]="true"></tx-bowtie-graph>
 | 
			
		||||
        <tx-bowtie-graph
 | 
			
		||||
          [tx]="tx"
 | 
			
		||||
          [width]="graphWidth"
 | 
			
		||||
          [height]="graphExpanded ? (maxInOut * 15) : graphHeight"
 | 
			
		||||
          [lineLimit]="inOutLimit"
 | 
			
		||||
          [maxStrands]="graphExpanded ? maxInOut : 24"
 | 
			
		||||
          [network]="network"
 | 
			
		||||
          [tooltip]="true">
 | 
			
		||||
        </tx-bowtie-graph>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="toggle-wrapper" *ngIf="maxInOut > 24">
 | 
			
		||||
        <button class="btn btn-sm btn-primary graph-toggle" (click)="expandGraph();" *ngIf="!graphExpanded; else collapseBtn"><span i18n="show-more">Show more</span></button>
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 {
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user