2022-09-17 01:20:08 +00:00
import { Component , OnInit , Input , OnChanges , HostListener } from '@angular/core' ;
2022-08-22 19:12:04 +00:00
import { Transaction } from '../../interfaces/electrs.interface' ;
interface SvgLine {
path : string ;
style : string ;
class ? : string ;
}
2022-09-16 20:48:07 +00:00
interface Xput {
type : 'input' | 'output' | 'fee' ;
value? : number ;
2022-09-17 01:20:08 +00:00
index? : number ;
address? : string ;
rest? : number ;
2022-09-17 17:23:44 +00:00
coinbase? : boolean ;
pegin? : boolean ;
pegout? : string ;
confidential? : boolean ;
2022-09-16 20:48:07 +00:00
}
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 ;
2022-09-23 19:03:21 +00:00
@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
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 ;
2022-09-16 20:48:07 +00:00
midWidth : number ;
2022-09-23 19:03:21 +00:00
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 } ;
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
ngOnInit ( ) : void {
this . initGraph ( ) ;
}
ngOnChanges ( ) : void {
this . initGraph ( ) ;
}
initGraph ( ) : void {
2022-09-23 19:03:21 +00:00
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 ) ;
2022-09-17 01:20:08 +00:00
let voutWithFee = this . tx . vout . map ( v = > {
return {
type : v . scriptpubkey_type === 'fee' ? 'fee' : 'output' ,
value : v?.value ,
address : v?.scriptpubkey_address || v ? . scriptpubkey_type ? . toUpperCase ( ) ,
2022-09-17 17:23:44 +00:00
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
2022-09-17 01:20:08 +00:00
let truncatedInputs = this . tx . vin . map ( v = > {
return {
type : 'input' ,
value : v?.prevout?.value ,
address : v?.prevout?.scriptpubkey_address || v ? . prevout ? . scriptpubkey_type ? . toUpperCase ( ) ,
2022-09-17 17:23:44 +00:00
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 ;
} ) ;
2022-09-16 20:48:07 +00:00
2022-09-23 19:03:21 +00:00
if ( truncatedInputs . length > this . lineLimit ) {
const valueOfRest = truncatedInputs . slice ( this . lineLimit ) . reduce ( ( r , v ) = > {
2022-09-16 20:48:07 +00:00
return r + ( v . value || 0 ) ;
} , 0 ) ;
2022-09-23 19:03:21 +00:00
truncatedInputs = truncatedInputs . slice ( 0 , this . lineLimit ) ;
truncatedInputs . push ( { type : 'input' , value : valueOfRest , rest : this.tx.vin.length - this . lineLimit } ) ;
2022-09-16 20:48:07 +00:00
}
2022-09-23 19:03:21 +00:00
if ( voutWithFee . length > this . lineLimit ) {
const valueOfRest = voutWithFee . slice ( this . lineLimit ) . reduce ( ( r , v ) = > {
2022-09-16 20:48:07 +00:00
return r + ( v . value || 0 ) ;
} , 0 ) ;
2022-09-23 19:03:21 +00:00
voutWithFee = voutWithFee . slice ( 0 , this . lineLimit ) ;
voutWithFee . push ( { type : 'output' , value : valueOfRest , rest : outputCount - this . lineLimit } ) ;
2022-09-16 20:48:07 +00:00
}
2022-09-17 01:20:08 +00:00
this . inputData = truncatedInputs ;
this . outputData = voutWithFee ;
2022-09-16 20:48:07 +00:00
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 = {
2022-09-16 20:48:07 +00:00
path : ` M ${ ( this . width / 2 ) - this . midWidth } ${ ( this . height / 2 ) + 0.5 } L ${ ( this . width / 2 ) + this . midWidth } ${ ( this . height / 2 ) + 0.5 } ` ,
2022-09-23 19:03:21 +00:00
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 ) ;
2022-09-06 17:03:41 +00:00
return Math . max ( adjustedTotalInput , adjustedTotalOutput ) ;
2022-08-22 19:12:04 +00:00
} else {
// otherwise knowing the actual total of one side suffices
2022-09-06 17:03:41 +00:00
return Math . max ( totalInput , totalOutput ) ;
2022-08-22 19:12:04 +00:00
}
}
}
2022-09-16 20:48:07 +00:00
initLines ( side : 'in' | 'out' , xputs : Xput [ ] , total : number , maxVisibleStrands : number ) : SvgLine [ ] {
2022-09-06 17:03:41 +00:00
if ( ! total ) {
2022-09-23 19:03:21 +00:00
const weights = xputs . map ( ( put ) = > this . combinedWeight / xputs . length ) ;
2022-09-06 17:03:41 +00:00
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
2022-09-23 19:03:21 +00:00
const weights = xputs . map ( ( put ) = > this . combinedWeight * ( put . value == null ? unknownShare : put.value as number ) / total ) ;
2022-09-06 17:03:41 +00:00
return this . linesFromWeights ( side , xputs , weights , maxVisibleStrands ) ;
}
}
2022-08-22 19:12:04 +00:00
2022-09-23 19:03:21 +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 ) ;
2022-09-23 19:03:21 +00:00
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 ;
2022-09-23 19:03:21 +00:00
// 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 ;
2022-09-23 19:03:21 +00:00
// 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
2022-09-23 19:03:21 +00:00
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 ) {
2022-09-23 19:03:21 +00:00
line . outerY = ( this . height / 2 ) ;
2022-08-22 19:12:04 +00:00
}
2022-09-23 19:03:21 +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
2022-09-23 19:03:21 +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
}
2022-09-23 19:03:21 +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 ;
}
2022-09-23 19:03:21 +00:00
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 ] ,
index
} ;
}
}
onBlur ( event , side , index ) : void {
this . hoverLine = null ;
}
2022-08-22 19:12:04 +00:00
}