From 64f3a597a2949fe024ad2cd46acd57f7787f8483 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Sat, 17 Sep 2022 01:20:08 +0000 Subject: [PATCH] Add interactivity to tx sankey diagram --- .../transaction/transaction.component.html | 2 +- .../transaction/transaction.component.ts | 6 +- .../tx-bowtie-graph-tooltip.component.html | 33 +++++ .../tx-bowtie-graph-tooltip.component.scss | 38 ++++++ .../tx-bowtie-graph-tooltip.component.ts | 36 +++++ .../tx-bowtie-graph.component.html | 124 ++++++++++++------ .../tx-bowtie-graph.component.scss | 14 ++ .../tx-bowtie-graph.component.ts | 60 ++++++++- frontend/src/app/shared/shared.module.ts | 3 + 9 files changed, 264 insertions(+), 52 deletions(-) create mode 100644 frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html create mode 100644 frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.scss create mode 100644 frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.ts diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index 9fceec58e..f25e2a012 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -196,7 +196,7 @@
- +
diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 01090f8fe..be6460167 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -49,7 +49,9 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { outputIndex: number; graphExpanded: boolean = false; graphWidth: number = 1000; + graphHeight: number = 360; maxInOut: number = 0; + tooltipPosition: { x: number, y: number }; @ViewChild('graphContainer') graphContainer: ElementRef; @@ -296,7 +298,8 @@ 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)); + this.maxInOut = Math.min(250, Math.max(this.tx?.vin?.length || 1, this.tx?.vout?.length + 1 || 1)); + this.graphHeight = Math.min(360, this.maxInOut * 80); } expandGraph() { @@ -309,7 +312,6 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { @HostListener('window:resize', ['$event']) setGraphSize(): void { - console.log('resize', this.graphContainer); if (this.graphContainer) { this.graphWidth = this.graphContainer.nativeElement.clientWidth - 24; } diff --git a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html new file mode 100644 index 000000000..d98007ae6 --- /dev/null +++ b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.html @@ -0,0 +1,33 @@ +
+ +

+ + Input + Output + Fee + + #{{ line.index }} +

+

+
+ + + {{ line.rest }} + + other inputs + other outputs + + + +

+ {{ line.address.slice(0, -4) }} + {{ line.address.slice(-4) }} +

+
diff --git a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.scss b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.scss new file mode 100644 index 000000000..d0551f2c8 --- /dev/null +++ b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.scss @@ -0,0 +1,38 @@ +.bowtie-graph-tooltip { + position: absolute; + background: rgba(#11131f, 0.95); + border-radius: 4px; + box-shadow: 1px 1px 10px rgba(0,0,0,0.5); + color: #b1b1b1; + padding: 10px 15px; + text-align: left; + pointer-events: none; + max-width: 300px; + + p { + margin: 0; + white-space: nowrap; + } + + .address { + width: 100%; + max-width: 100%; + display: flex; + flex-direction: row; + align-items: baseline; + justify-content: flex-start; + + .first { + flex-grow: 0; + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + margin-right: -2px; + } + + .last-four { + flex-shrink: 0; + flex-grow: 0; + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.ts b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.ts new file mode 100644 index 000000000..ab964e89a --- /dev/null +++ b/frontend/src/app/components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component.ts @@ -0,0 +1,36 @@ +import { Component, ElementRef, ViewChild, Input, OnChanges, ChangeDetectionStrategy } from '@angular/core'; +import { TransactionStripped } from 'src/app/interfaces/websocket.interface'; + +@Component({ + selector: 'app-tx-bowtie-graph-tooltip', + templateUrl: './tx-bowtie-graph-tooltip.component.html', + styleUrls: ['./tx-bowtie-graph-tooltip.component.scss'], +}) +export class TxBowtieGraphTooltipComponent implements OnChanges { + @Input() line: { type: string, value?: number, index?: number, address?: string, rest?: number } | void; + @Input() cursorPosition: { x: number, y: number }; + + tooltipPosition = { x: 0, y: 0 }; + + @ViewChild('tooltip') tooltipElement: ElementRef; + + constructor() {} + + ngOnChanges(changes): void { + if (changes.cursorPosition && changes.cursorPosition.currentValue) { + let x = Math.max(10, changes.cursorPosition.currentValue.x - 50); + let y = changes.cursorPosition.currentValue.y + 20; + if (this.tooltipElement) { + const elementBounds = this.tooltipElement.nativeElement.getBoundingClientRect(); + const parentBounds = this.tooltipElement.nativeElement.offsetParent.getBoundingClientRect(); + if ((parentBounds.left + x + elementBounds.width) > parentBounds.right) { + x = Math.max(0, parentBounds.width - elementBounds.width - 10); + } + if (y + elementBounds.height > parentBounds.height) { + y = y - elementBounds.height - 20; + } + } + this.tooltipPosition = { x, y }; + } + } +} diff --git a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.html b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.html index c4771c58c..03056cd53 100644 --- a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.html +++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.html @@ -1,44 +1,82 @@ - - - - - - - - - - - +
+ + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.scss b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.scss index 6de41b95f..9cacb7d4b 100644 --- a/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.scss +++ b/frontend/src/app/components/tx-bowtie-graph/tx-bowtie-graph.component.scss @@ -11,5 +11,19 @@ &.fee { stroke: url(#fee-gradient); } + + &:hover { + z-index: 10; + cursor: pointer; + &.input { + stroke: url(#input-hover-gradient); + } + &.output { + stroke: url(#output-hover-gradient); + } + &.fee { + stroke: url(#fee-hover-gradient); + } + } } } 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 bf4ae7a21..0a25a43b1 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 @@ -1,4 +1,4 @@ -import { Component, OnInit, Input, OnChanges } from '@angular/core'; +import { Component, OnInit, Input, OnChanges, HostListener } from '@angular/core'; import { Transaction } from '../../interfaces/electrs.interface'; interface SvgLine { @@ -10,6 +10,9 @@ interface SvgLine { interface Xput { type: 'input' | 'output' | 'fee'; value?: number; + index?: number; + address?: string; + rest?: number; } const lineLimit = 250; @@ -27,12 +30,17 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { @Input() combinedWeight = 100; @Input() minWeight = 2; // @Input() maxStrands = 24; // number of inputs/outputs to keep fully on-screen. + @Input() tooltip = false; + inputData: Xput[]; + outputData: Xput[]; inputs: SvgLine[]; outputs: SvgLine[]; middle: SvgLine; midWidth: number; isLiquid: boolean = false; + hoverLine: Xput | void = null; + tooltipPosition = { x: 0, y: 0 }; gradientColors = { '': ['#9339f4', '#105fb0'], @@ -59,34 +67,51 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { 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 { const totalValue = this.calcTotalValue(this.tx); - let voutWithFee = this.tx.vout.map(v => { return { type: v.scriptpubkey_type === 'fee' ? 'fee' : 'output', value: v?.value } as Xput; }); + 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(), + } as Xput; + }); if (this.tx.fee && !this.isLiquid) { voutWithFee.unshift({ type: 'fee', value: this.tx.fee }); } + const outputCount = voutWithFee.length; - let truncatedInputs = this.tx.vin.map(v => { return {type: 'input', value: v?.prevout?.value } as Xput; }); + let truncatedInputs = this.tx.vin.map(v => { + return { + type: 'input', + value: v?.prevout?.value, + address: v?.prevout?.scriptpubkey_address || v?.prevout?.scriptpubkey_type?.toUpperCase(), + } as Xput; + }); if (truncatedInputs.length > lineLimit) { const valueOfRest = truncatedInputs.slice(lineLimit).reduce((r, v) => { return r + (v.value || 0); }, 0); truncatedInputs = truncatedInputs.slice(0, lineLimit); - truncatedInputs.push({ type: 'input', value: valueOfRest }); + truncatedInputs.push({ type: 'input', value: valueOfRest, rest: this.tx.vin.length - lineLimit }); } if (voutWithFee.length > lineLimit) { const valueOfRest = voutWithFee.slice(lineLimit).reduce((r, v) => { return r + (v.value || 0); }, 0); voutWithFee = voutWithFee.slice(0, lineLimit); - voutWithFee.push({ type: 'output', value: valueOfRest }); + voutWithFee.push({ type: 'output', value: valueOfRest, rest: outputCount - lineLimit }); } + this.inputData = truncatedInputs; + this.outputData = voutWithFee; + this.inputs = this.initLines('in', truncatedInputs, totalValue, this.maxStrands); this.outputs = this.initLines('out', voutWithFee, totalValue, this.maxStrands); @@ -195,9 +220,32 @@ export class TxBowtieGraphComponent implements OnInit, OnChanges { makeStyle(minWeight, type): string { if (type === 'fee') { - return `stroke-width: ${minWeight}; stroke: url(#fee-gradient)`; + return `stroke-width: ${minWeight}`; } else { return `stroke-width: ${minWeight}`; } } + + @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; + } } diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index be4ba2fe0..c4973d75c 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -61,6 +61,7 @@ import { FeesBoxComponent } from '../components/fees-box/fees-box.component'; import { DifficultyComponent } from '../components/difficulty/difficulty.component'; import { TermsOfServiceComponent } from '../components/terms-of-service/terms-of-service.component'; import { TxBowtieGraphComponent } from '../components/tx-bowtie-graph/tx-bowtie-graph.component'; +import { TxBowtieGraphTooltipComponent } from '../components/tx-bowtie-graph-tooltip/tx-bowtie-graph-tooltip.component'; import { PrivacyPolicyComponent } from '../components/privacy-policy/privacy-policy.component'; import { TrademarkPolicyComponent } from '../components/trademark-policy/trademark-policy.component'; import { PushTransactionComponent } from '../components/push-transaction/push-transaction.component'; @@ -134,6 +135,7 @@ import { GeolocationComponent } from '../shared/components/geolocation/geolocati FeesBoxComponent, DifficultyComponent, TxBowtieGraphComponent, + TxBowtieGraphTooltipComponent, TermsOfServiceComponent, PrivacyPolicyComponent, TrademarkPolicyComponent, @@ -236,6 +238,7 @@ import { GeolocationComponent } from '../shared/components/geolocation/geolocati FeesBoxComponent, DifficultyComponent, TxBowtieGraphComponent, + TxBowtieGraphTooltipComponent, TermsOfServiceComponent, PrivacyPolicyComponent, TrademarkPolicyComponent,