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,