From ec8fc53dcb35aef62ac620665cceb98275ea3a2d Mon Sep 17 00:00:00 2001 From: Mononaut Date: Thu, 28 Sep 2023 15:48:37 +0100 Subject: [PATCH] eight blocks --- frontend/src/app/app-routing.module.ts | 5 + .../block-overview-graph.component.ts | 8 +- .../block-overview-graph/block-scene.ts | 44 ++-- .../eight-blocks/eight-blocks.component.html | 27 ++ .../eight-blocks/eight-blocks.component.scss | 50 ++++ .../eight-blocks/eight-blocks.component.ts | 244 ++++++++++++++++++ .../app/dashboard/dashboard.component.html | 2 +- .../app/shared/pipes/bytes-pipe/bytes.pipe.ts | 14 +- frontend/src/app/shared/shared.module.ts | 4 + 9 files changed, 370 insertions(+), 28 deletions(-) create mode 100644 frontend/src/app/components/eight-blocks/eight-blocks.component.html create mode 100644 frontend/src/app/components/eight-blocks/eight-blocks.component.scss create mode 100644 frontend/src/app/components/eight-blocks/eight-blocks.component.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index ce91019ff..6365ec873 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { AppPreloadingStrategy } from './app.preloading-strategy' import { BlockViewComponent } from './components/block-view/block-view.component'; +import { EightBlocksComponent } from './components/eight-blocks/eight-blocks.component'; import { MempoolBlockViewComponent } from './components/mempool-block-view/mempool-block-view.component'; import { ClockComponent } from './components/clock/clock.component'; import { StatusViewComponent } from './components/status-view/status-view.component'; @@ -124,6 +125,10 @@ let routes: Routes = [ path: 'view/mempool-block/:index', component: MempoolBlockViewComponent, }, + { + path: 'eight-blocks', + component: EightBlocksComponent, + }, { path: 'status', data: { networks: ['bitcoin', 'liquid'] }, diff --git a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts index c32216db9..68d2a1bf3 100644 --- a/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts +++ b/frontend/src/app/components/block-overview-graph/block-overview-graph.component.ts @@ -20,6 +20,8 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On @Input() blockLimit: number; @Input() orientation = 'left'; @Input() flip = true; + @Input() animationDuration: number = 1000; + @Input() animationOffset: number | null = null; @Input() disableSpinner = false; @Input() mirrorTxid: string | void; @Input() unavailable: boolean = false; @@ -141,9 +143,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } } - replace(transactions: TransactionStripped[], direction: string, sort: boolean = true): void { + replace(transactions: TransactionStripped[], direction: string, sort: boolean = true, startTime?: number): void { if (this.scene) { - this.scene.replace(transactions || [], direction, sort); + this.scene.replace(transactions || [], direction, sort, startTime); this.start(); this.updateSearchHighlight(); } @@ -226,7 +228,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On } else { this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution, blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, - highlighting: this.auditHighlighting }); + highlighting: this.auditHighlighting, animationDuration: this.animationDuration, animationOffset: this.animationOffset }); this.start(); } } diff --git a/frontend/src/app/components/block-overview-graph/block-scene.ts b/frontend/src/app/components/block-overview-graph/block-scene.ts index cb0537e2a..2569a3bb2 100644 --- a/frontend/src/app/components/block-overview-graph/block-scene.ts +++ b/frontend/src/app/components/block-overview-graph/block-scene.ts @@ -9,6 +9,9 @@ export default class BlockScene { txs: { [key: string]: TxView }; orientation: string; flip: boolean; + animationDuration: number = 1000; + configAnimationOffset: number | null; + animationOffset: number; highlightingEnabled: boolean; width: number; height: number; @@ -23,11 +26,11 @@ export default class BlockScene { animateUntil = 0; dirty: boolean; - constructor({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }: - { width: number, height: number, resolution: number, blockLimit: number, + constructor({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting }: + { width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number, orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean } ) { - this.init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }); + this.init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting }); } resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void { @@ -36,6 +39,7 @@ export default class BlockScene { this.gridSize = this.width / this.gridWidth; this.unitPadding = Math.max(1, Math.floor(this.gridSize / 5)); this.unitWidth = this.gridSize - (this.unitPadding * 2); + this.animationOffset = this.configAnimationOffset == null ? (this.width * 1.4) : this.configAnimationOffset; this.dirty = true; if (this.initialised && this.scene) { @@ -90,8 +94,8 @@ export default class BlockScene { } // Animate new block entering scene - enter(txs: TransactionStripped[], direction) { - this.replace(txs, direction); + enter(txs: TransactionStripped[], direction, startTime?: number) { + this.replace(txs, direction, false, startTime); } // Animate block leaving scene @@ -108,8 +112,7 @@ export default class BlockScene { } // Reset layout and replace with new set of transactions - replace(txs: TransactionStripped[], direction: string = 'left', sort: boolean = true): void { - const startTime = performance.now(); + replace(txs: TransactionStripped[], direction: string = 'left', sort: boolean = true, startTime: number = performance.now()): void { const nextIds = {}; const remove = []; txs.forEach(tx => { @@ -133,7 +136,7 @@ export default class BlockScene { removed.forEach(tx => { tx.destroy(); }); - }, 1000); + }, (startTime - performance.now()) + this.animationDuration + 1000); this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight }); @@ -147,7 +150,7 @@ export default class BlockScene { }); } - this.updateAll(startTime, 200, direction); + this.updateAll(startTime, 50, direction); } update(add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void { @@ -214,10 +217,13 @@ export default class BlockScene { this.animateUntil = Math.max(this.animateUntil, tx.setHighlight(value)); } - private init({ width, height, resolution, blockLimit, orientation, flip, vertexArray, highlighting }: - { width: number, height: number, resolution: number, blockLimit: number, + private init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, highlighting }: + { width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number, orientation: string, flip: boolean, vertexArray: FastVertexArray, highlighting: boolean } ): void { + this.animationDuration = animationDuration || 1000; + this.configAnimationOffset = animationOffset; + this.animationOffset = this.configAnimationOffset == null ? (this.width * 1.4) : this.configAnimationOffset; this.orientation = orientation; this.flip = flip; this.vertexArray = vertexArray; @@ -261,8 +267,8 @@ export default class BlockScene { this.applyTxUpdate(tx, { display: { position: { - x: tx.screenPosition.x + (direction === 'right' ? -this.width : (direction === 'left' ? this.width : 0)) * 1.4, - y: tx.screenPosition.y + (direction === 'up' ? -this.height : (direction === 'down' ? this.height : 0)) * 1.4, + x: tx.screenPosition.x + (direction === 'right' ? -this.width - this.animationOffset : (direction === 'left' ? this.width + this.animationOffset : 0)), + y: tx.screenPosition.y + (direction === 'up' ? -this.height - this.animationOffset : (direction === 'down' ? this.height + this.animationOffset : 0)), s: tx.screenPosition.s }, color: txColor, @@ -275,7 +281,7 @@ export default class BlockScene { position: tx.screenPosition, color: txColor }, - duration: animate ? 1000 : 1, + duration: animate ? this.animationDuration : 1, start: startTime, delay: animate ? delay : 0, }); @@ -284,8 +290,8 @@ export default class BlockScene { display: { position: tx.screenPosition }, - duration: animate ? 1000 : 0, - minDuration: animate ? 500 : 0, + duration: animate ? this.animationDuration : 0, + minDuration: animate ? (this.animationDuration / 2) : 0, start: startTime, delay: animate ? delay : 0, adjust: animate @@ -322,11 +328,11 @@ export default class BlockScene { this.applyTxUpdate(tx, { display: { position: { - x: tx.screenPosition.x + (direction === 'right' ? this.width : (direction === 'left' ? -this.width : 0)) * 1.4, - y: tx.screenPosition.y + (direction === 'up' ? this.height : (direction === 'down' ? -this.height : 0)) * 1.4, + x: tx.screenPosition.x + (direction === 'right' ? this.width + this.animationOffset : (direction === 'left' ? -this.width - this.animationOffset : 0)), + y: tx.screenPosition.y + (direction === 'up' ? this.height + this.animationOffset : (direction === 'down' ? -this.height - this.animationOffset : 0)), } }, - duration: 1000, + duration: this.animationDuration, start: startTime, delay: 50 }); diff --git a/frontend/src/app/components/eight-blocks/eight-blocks.component.html b/frontend/src/app/components/eight-blocks/eight-blocks.component.html new file mode 100644 index 000000000..382d6056b --- /dev/null +++ b/frontend/src/app/components/eight-blocks/eight-blocks.component.html @@ -0,0 +1,27 @@ +
+ +
+
+ +
+

{{ blockInfo[i].height }}

+

{{ blockInfo[i].hash }}

+

{{ blockInfo[i].time }}

+

{{ blockInfo[i].count }}

+

{{ blockInfo[i].size }}

+
+
+
+
+
\ No newline at end of file diff --git a/frontend/src/app/components/eight-blocks/eight-blocks.component.scss b/frontend/src/app/components/eight-blocks/eight-blocks.component.scss new file mode 100644 index 000000000..bee7d7151 --- /dev/null +++ b/frontend/src/app/components/eight-blocks/eight-blocks.component.scss @@ -0,0 +1,50 @@ +.blocks { + width: 100%; + height: 100%; + min-width: 100vw; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: start; + align-items: start; + align-content: start; + + &.wrap { + flex-wrap: wrap; + } + + .block-wrapper { + flex-grow: 0; + flex-shrink: 0; + position: relative; + --block-width: 1080px; + + .info { + position: absolute; + left: 10%; + top: 10%; + right: 10%; + bottom: 10%; + height: 80%; + width: 80%; + overflow: hidden; + font-size: calc(var(--block-width) * 0.04); + + h1 { + font-size: 4em; + margin-bottom: 0.1em; + } + h2 { + font-size: 2.5em; + margin-bottom: 0.1em; + } + + .hash { + font-family: monospace; + word-wrap: break-word; + font-size: 1em; + margin-bottom: 0.1em; + } + } + } +} \ No newline at end of file diff --git a/frontend/src/app/components/eight-blocks/eight-blocks.component.ts b/frontend/src/app/components/eight-blocks/eight-blocks.component.ts new file mode 100644 index 000000000..0213ed5f3 --- /dev/null +++ b/frontend/src/app/components/eight-blocks/eight-blocks.component.ts @@ -0,0 +1,244 @@ +import { Component, OnInit, OnDestroy, ViewChildren, QueryList } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { catchError, startWith } from 'rxjs/operators'; +import { Subject, Subscription, of } from 'rxjs'; +import { StateService } from '../../services/state.service'; +import { WebsocketService } from '../../services/websocket.service'; +import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; +import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; +import { ApiService } from '../../services/api.service'; +import { BlockOverviewGraphComponent } from '../block-overview-graph/block-overview-graph.component'; +import { detectWebGL } from '../../shared/graphs.utils'; +import { animate, style, transition, trigger } from '@angular/animations'; +import { BytesPipe } from '../../shared/pipes/bytes-pipe/bytes.pipe'; + +function bestFitResolution(min, max, n): number { + const target = (min + max) / 2; + let bestScore = Infinity; + let best = null; + for (let i = min; i <= max; i++) { + const remainder = (n % i); + if (remainder < bestScore || (remainder === bestScore && (Math.abs(i - target) < Math.abs(best - target)))) { + bestScore = remainder; + best = i; + } + } + return best; +} + +@Component({ + selector: 'app-eight-blocks', + templateUrl: './eight-blocks.component.html', + styleUrls: ['./eight-blocks.component.scss'], + animations: [ + trigger('infoChange', [ + transition(':enter', [ + style({ opacity: 0 }), + animate('1000ms', style({ opacity: 1 })), + ]), + transition(':leave', [ + animate('1000ms 500ms', style({ opacity: 0 })) + ]) + ]), + ], +}) +export class EightBlocksComponent implements OnInit, OnDestroy { + network = ''; + latestBlocks: BlockExtended[] = []; + isLoadingTransactions = true; + strippedTransactions: { [height: number]: TransactionStripped[] } = {}; + webGlEnabled = true; + hoverTx: string | null = null; + + blocksSubscription: Subscription; + cacheBlocksSubscription: Subscription; + networkChangedSubscription: Subscription; + queryParamsSubscription: Subscription; + graphChangeSubscription: Subscription; + + autofit: boolean = false; + padding: number = 0; + wrapBlocks: boolean = false; + blockWidth: number = 1080; + animationDuration: number = 2000; + animationOffset: number = 0; + stagger: number = 0; + testing: boolean = true; + testHeight: number = 800000; + testShiftTimeout: number; + + showInfo: boolean = true; + blockInfo: { [key: string]: string}[] = []; + + wrapperStyle = { + '--block-width': '1080px', + width: '1080px', + maxWidth: '1080px', + padding: '', + }; + containerStyle = {}; + resolution: number = 86; + + @ViewChildren('blockGraph') blockGraphs: QueryList; + + constructor( + private route: ActivatedRoute, + private router: Router, + public stateService: StateService, + private websocketService: WebsocketService, + private apiService: ApiService, + private bytesPipe: BytesPipe, + ) { + this.webGlEnabled = detectWebGL(); + } + + ngOnInit(): void { + this.websocketService.want(['blocks']); + this.network = this.stateService.network; + + this.blocksSubscription = this.stateService.blocks$ + .subscribe((blocks) => { + this.handleNewBlock(blocks); + }); + + this.queryParamsSubscription = this.route.queryParams.subscribe((params) => { + this.autofit = params.autofit === 'true'; + this.padding = Number.isInteger(Number(params.padding)) ? Number(params.padding) : 0; + this.blockWidth = Number.isInteger(Number(params.blockWidth)) ? Number(params.blockWidth) : 1080; + this.wrapBlocks = params.wrap === 'true'; + this.stagger = Number.isInteger(Number(params.stagger)) ? Number(params.stagger) : 0; + this.animationDuration = Number.isInteger(Number(params.animationDuration)) ? Number(params.animationDuration) : 2000; + this.animationOffset = this.padding * 2; + + if (this.autofit) { + this.resolution = bestFitResolution(76, 96, this.blockWidth - this.padding * 2); + } else { + this.resolution = 86; + } + + this.wrapperStyle = { + '--block-width': this.blockWidth + 'px', + width: this.blockWidth + 'px', + maxWidth: this.blockWidth + 'px', + padding: (this.padding || 0) +'px 0px', + }; + + if (params.test === 'true') { + this.blocksSubscription.unsubscribe(); + this.blocksSubscription = (new Subject()).subscribe((blocks) => { + this.handleNewBlock(blocks); + }); + this.shiftTestBlocks(); + } + }); + + this.setupBlockGraphs(); + + this.networkChangedSubscription = this.stateService.networkChanged$ + .subscribe((network) => this.network = network); + } + + ngAfterViewInit(): void { + this.graphChangeSubscription = this.blockGraphs.changes.pipe(startWith(null)).subscribe(() => { + this.setupBlockGraphs(); + }); + } + + ngOnDestroy(): void { + this.stateService.markBlock$.next({}); + this.blocksSubscription?.unsubscribe(); + this.cacheBlocksSubscription?.unsubscribe(); + this.networkChangedSubscription?.unsubscribe(); + this.queryParamsSubscription?.unsubscribe(); + } + + shiftTestBlocks(): void { + const sub = this.apiService.getBlocks$(this.testHeight).subscribe(result => { + sub.unsubscribe(); + this.handleNewBlock(result); + this.testHeight++; + clearTimeout(this.testShiftTimeout); + this.testShiftTimeout = window.setTimeout(() => { this.shiftTestBlocks(); }, 10000); + }); + } + + async handleNewBlock(blocks: BlockExtended[]): Promise { + const readyPromises: Promise[] = []; + const previousBlocks = this.latestBlocks; + const newHeights = {}; + this.latestBlocks = blocks; + for (const block of blocks) { + newHeights[block.height] = true; + if (!this.strippedTransactions[block.height]) { + readyPromises.push(new Promise((resolve) => { + const subscription = this.apiService.getStrippedBlockTransactions$(block.id).pipe( + catchError(() => { + return of([]); + }), + ).subscribe((transactions) => { + this.strippedTransactions[block.height] = transactions; + subscription.unsubscribe(); + resolve(transactions); + }); + })); + } + } + await Promise.allSettled(readyPromises); + this.updateBlockGraphs(blocks); + + // free up old transactions + previousBlocks.forEach(block => { + if (!newHeights[block.height]) { + delete this.strippedTransactions[block.height]; + } + }); + } + + updateBlockGraphs(blocks): void { + const startTime = performance.now() + 1000 - (this.stagger < 0 ? this.stagger * 8 : 0); + if (this.blockGraphs) { + this.blockGraphs.forEach((graph, index) => { + graph.replace(this.strippedTransactions[blocks?.[index]?.height] || [], 'right', false, startTime + (this.stagger * index)); + }); + } + this.showInfo = false; + setTimeout(() => { + this.blockInfo = blocks.map(block => { + return { + height: `${block.height}`, + hash: block.id, + time: (new Date(block.timestamp * 1000)).toLocaleTimeString(), + count: `${block.tx_count} txs`, + size: `${this.bytesPipe.transform(block.size, 2, 'B', 'MB', true)}`, + }; + }); + this.showInfo = true; + }, 1600); // Should match the animation time. + } + + setupBlockGraphs(): void { + if (this.blockGraphs) { + this.blockGraphs.forEach((graph, index) => { + graph.destroy(); + graph.setup(this.strippedTransactions[this.latestBlocks?.[index]?.height] || []); + }); + } + } + + onTxClick(event: { tx: TransactionStripped, keyModifier: boolean }): void { + const url = new RelativeUrlPipe(this.stateService).transform(`/tx/${event.tx.txid}`); + if (!event.keyModifier) { + this.router.navigate([url]); + } else { + window.open(url, '_blank'); + } + } + + onTxHover(txid: string): void { + if (txid && txid.length) { + this.hoverTx = txid; + } else { + this.hoverTx = null; + } + } +} diff --git a/frontend/src/app/dashboard/dashboard.component.html b/frontend/src/app/dashboard/dashboard.component.html index 2e2e4bccc..9e9d48b27 100644 --- a/frontend/src/app/dashboard/dashboard.component.html +++ b/frontend/src/app/dashboard/dashboard.component.html @@ -237,7 +237,7 @@
 
-
/
+
/
diff --git a/frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts b/frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts index 83fc2433d..b2140f0dc 100644 --- a/frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts +++ b/frontend/src/app/shared/pipes/bytes-pipe/bytes.pipe.ts @@ -17,7 +17,7 @@ export class BytesPipe implements PipeTransform { 'TB': {max: Number.MAX_SAFE_INTEGER, prev: 'GB'} }; - transform(input: any, decimal: number = 0, from: ByteUnit = 'B', to?: ByteUnit, sigfigs?: number): any { + transform(input: any, decimal: number = 0, from: ByteUnit = 'B', to?: ByteUnit, plaintext = false, sigfigs?: number): any { if (!(isNumberFinite(input) && isNumberFinite(decimal) && @@ -42,7 +42,7 @@ export class BytesPipe implements PipeTransform { const result = numberFormat(BytesPipe.calculateResult(format, bytes)); - return BytesPipe.formatResult(result, to); + return BytesPipe.formatResult(result, to, plaintext); } for (const key in BytesPipe.formats) { @@ -51,13 +51,17 @@ export class BytesPipe implements PipeTransform { const result = numberFormat(BytesPipe.calculateResult(format, bytes)); - return BytesPipe.formatResult(result, key); + return BytesPipe.formatResult(result, key, plaintext); } } } - static formatResult(result: string, unit: string): string { - return `${result} ${unit}`; + static formatResult(result: string, unit: string, plaintext): string { + if (plaintext) { + return `${result} ${unit}`; + } else { + return `${result} ${unit}`; + } } static calculateResult(format: { max: number, prev?: ByteUnit }, bytes: number) { diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 0b4464727..82327c561 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -87,6 +87,7 @@ import { AccelerateFeeGraphComponent } from '../components/accelerate-preview/ac import { MempoolErrorComponent } from './components/mempool-error/mempool-error.component'; import { BlockViewComponent } from '../components/block-view/block-view.component'; +import { EightBlocksComponent } from '../components/eight-blocks/eight-blocks.component'; import { MempoolBlockViewComponent } from '../components/mempool-block-view/mempool-block-view.component'; import { MempoolBlockOverviewComponent } from '../components/mempool-block-overview/mempool-block-overview.component'; import { ClockchainComponent } from '../components/clockchain/clockchain.component'; @@ -126,6 +127,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir ColoredPriceDirective, BlockchainComponent, BlockViewComponent, + EightBlocksComponent, MempoolBlockViewComponent, MempoolBlocksComponent, BlockchainBlocksComponent, @@ -179,6 +181,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir CalculatorComponent, BitcoinsatoshisPipe, BlockViewComponent, + EightBlocksComponent, MempoolBlockViewComponent, MempoolBlockOverviewComponent, ClockchainComponent, @@ -202,6 +205,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir FontAwesomeModule, ], providers: [ + BytesPipe, VbytesPipe, WuBytesPipe, RelativeUrlPipe,