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 4f07818a5..2096866eb 100644
--- a/frontend/src/app/components/block-overview-graph/block-scene.ts
+++ b/frontend/src/app/components/block-overview-graph/block-scene.ts
@@ -18,6 +18,8 @@ export default class BlockScene {
animationOffset: number;
highlightingEnabled: boolean;
filterFlags: bigint | null = 0b00000100_00000000_00000000_00000000n;
+ x: number;
+ y: number;
width: number;
height: number;
gridWidth: number;
@@ -31,14 +33,16 @@ export default class BlockScene {
animateUntil = 0;
dirty: boolean;
- constructor({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }:
- { width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number,
+ constructor({ x = 0, y = 0, width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }:
+ { x?: number, y?: number, width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number,
orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null }
) {
- this.init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction });
+ this.init({ x, y,width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction });
}
- resize({ width = this.width, height = this.height, animate = true }: { width?: number, height?: number, animate: boolean }): void {
+ resize({ x = 0, y = 0, width = this.width, height = this.height, animate = true }: { x?: number, y?: number, width?: number, height?: number, animate: boolean }): void {
+ this.x = x;
+ this.y = y;
this.width = width;
this.height = height;
this.gridSize = this.width / this.gridWidth;
@@ -238,8 +242,8 @@ export default class BlockScene {
this.animateUntil = Math.max(this.animateUntil, tx.setHighlight(value));
}
- private init({ width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }:
- { width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number,
+ private init({ x, y, width, height, resolution, blockLimit, animationDuration, animationOffset, orientation, flip, vertexArray, theme, highlighting, colorFunction }:
+ { x: number, y: number, width: number, height: number, resolution: number, blockLimit: number, animationDuration: number, animationOffset: number,
orientation: string, flip: boolean, vertexArray: FastVertexArray, theme: ThemeService, highlighting: boolean, colorFunction: ((tx: TxView) => Color) | null }
): void {
this.animationDuration = animationDuration || this.animationDuration || 1000;
@@ -264,7 +268,7 @@ export default class BlockScene {
this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.02, 2);
this.gridWidth = resolution;
this.gridHeight = resolution;
- this.resize({ width, height, animate: true });
+ this.resize({ x, y, width, height, animate: true });
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight });
this.txs = {};
@@ -449,18 +453,18 @@ export default class BlockScene {
break;
}
return {
- x: x + this.unitPadding - (slotSize / 2),
- y: y + this.unitPadding - (slotSize / 2),
+ x: this.x + x + this.unitPadding - (slotSize / 2),
+ y: this.y + y + this.unitPadding - (slotSize / 2),
s: squareSize
};
} else {
- return { x: 0, y: 0, s: 0 };
+ return { x: this.x, y: this.y, s: 0 };
}
}
private screenToGrid(position: Position): Position {
- let x = position.x;
- let y = this.height - position.y;
+ let x = position.x - this.x;
+ let y = this.height - (position.y - this.y);
let t;
switch (this.orientation) {
diff --git a/frontend/src/app/components/block-overview-multi/block-overview-multi.component.html b/frontend/src/app/components/block-overview-multi/block-overview-multi.component.html
new file mode 100644
index 000000000..89f7ce513
--- /dev/null
+++ b/frontend/src/app/components/block-overview-multi/block-overview-multi.component.html
@@ -0,0 +1,24 @@
+
+
+
+ @if (!disableSpinner) {
+
+ }
+
+
+
+ Your browser does not support this feature.
+
+
diff --git a/frontend/src/app/components/block-overview-multi/block-overview-multi.component.scss b/frontend/src/app/components/block-overview-multi/block-overview-multi.component.scss
new file mode 100644
index 000000000..2d5d0518e
--- /dev/null
+++ b/frontend/src/app/components/block-overview-multi/block-overview-multi.component.scss
@@ -0,0 +1,67 @@
+.block-overview-graph {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ background: var(--stat-box-bg);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ grid-column: 1/-1;
+
+ .placeholder {
+ display: flex;
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ height: 100%;
+ width: 100%;
+ align-items: center;
+ justify-content: center;
+ }
+}
+
+.graph-alignment {
+ position: relative;
+ width: 100%;
+}
+
+.grid-align {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, 75px);
+ justify-content: center;
+}
+
+.block-overview-canvas {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+
+ &.clickable {
+ cursor: pointer;
+ }
+}
+
+.loader-wrapper {
+ position: absolute;
+ background: #181b2d7f;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: opacity 500ms 500ms;
+ pointer-events: none;
+
+ &.hidden {
+ opacity: 0;
+ }
+}
diff --git a/frontend/src/app/components/block-overview-multi/block-overview-multi.component.ts b/frontend/src/app/components/block-overview-multi/block-overview-multi.component.ts
new file mode 100644
index 000000000..214ea4e79
--- /dev/null
+++ b/frontend/src/app/components/block-overview-multi/block-overview-multi.component.ts
@@ -0,0 +1,647 @@
+import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit, OnDestroy, OnChanges } from '@angular/core';
+import { TransactionStripped } from '../../interfaces/node-api.interface';
+import { FastVertexArray } from '../block-overview-graph/fast-vertex-array';
+import BlockScene from '../block-overview-graph/block-scene';
+import TxSprite from '../block-overview-graph/tx-sprite';
+import TxView from '../block-overview-graph/tx-view';
+import { Color, Position } from '../block-overview-graph/sprite-types';
+import { Price } from '../../services/price.service';
+import { StateService } from '../../services/state.service';
+import { ThemeService } from '../../services/theme.service';
+import { Subscription } from 'rxjs';
+import { defaultColorFunction, setOpacity, defaultAuditColors, defaultColors, ageColorFunction, contrastColorFunction, contrastAuditColors, contrastColors } from '../block-overview-graph/utils';
+import { ActiveFilter, FilterMode, toFlags } from '../../shared/filters.utils';
+import { detectWebGL } from '../../shared/graphs.utils';
+
+const unmatchedOpacity = 0.2;
+const unmatchedAuditColors = {
+ censored: setOpacity(defaultAuditColors.censored, unmatchedOpacity),
+ missing: setOpacity(defaultAuditColors.missing, unmatchedOpacity),
+ added: setOpacity(defaultAuditColors.added, unmatchedOpacity),
+ added_prioritized: setOpacity(defaultAuditColors.added_prioritized, unmatchedOpacity),
+ prioritized: setOpacity(defaultAuditColors.prioritized, unmatchedOpacity),
+ accelerated: setOpacity(defaultAuditColors.accelerated, unmatchedOpacity),
+};
+const unmatchedContrastAuditColors = {
+ censored: setOpacity(contrastAuditColors.censored, unmatchedOpacity),
+ missing: setOpacity(contrastAuditColors.missing, unmatchedOpacity),
+ added: setOpacity(contrastAuditColors.added, unmatchedOpacity),
+ added_prioritized: setOpacity(contrastAuditColors.added_prioritized, unmatchedOpacity),
+ prioritized: setOpacity(contrastAuditColors.prioritized, unmatchedOpacity),
+ accelerated: setOpacity(contrastAuditColors.accelerated, unmatchedOpacity),
+};
+
+@Component({
+ selector: 'app-block-overview-multi',
+ templateUrl: './block-overview-multi.component.html',
+ styleUrls: ['./block-overview-multi.component.scss'],
+})
+export class BlockOverviewMultiComponent implements AfterViewInit, OnDestroy, OnChanges {
+ @Input() isLoading: boolean;
+ @Input() resolution: number;
+ @Input() numBlocks: number;
+ @Input() blockWidth: number = 360;
+ @Input() autofit: boolean = false;
+ @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;
+ @Input() auditHighlighting: boolean = false;
+ @Input() showFilters: boolean = false;
+ @Input() excludeFilters: string[] = [];
+ @Input() filterFlags: bigint | null = null;
+ @Input() filterMode: FilterMode = 'and';
+ @Input() gradientMode: 'fee' | 'age' = 'fee';
+ @Input() relativeTime: number | null;
+ @Input() blockConversion: Price;
+ @Input() overrideColors: ((tx: TxView) => Color) | null = null;
+ @Output() txClickEvent = new EventEmitter<{ tx: TransactionStripped, keyModifier: boolean}>();
+ @Output() txHoverEvent = new EventEmitter();
+ @Output() readyEvent = new EventEmitter();
+
+ @ViewChild('blockCanvas')
+ canvas: ElementRef;
+ themeChangedSubscription: Subscription;
+
+ gl: WebGLRenderingContext;
+ animationFrameRequest: number;
+ animationHeartBeat: number;
+ displayWidth: number;
+ displayHeight: number;
+ cssWidth: number;
+ cssHeight: number;
+ shaderProgram: WebGLProgram;
+ vertexArray: FastVertexArray;
+ running: boolean;
+ scenes: BlockScene[] = [];
+ hoverTx: TxView | void;
+ selectedTx: TxView | void;
+ highlightTx: TxView | void;
+ mirrorTx: TxView | void;
+ tooltipPosition: Position;
+
+ readyNextFrame = false;
+ lastUpdate: number = 0;
+ pendingUpdates: {
+ count: number,
+ add: { [txid: string]: TransactionStripped },
+ remove: { [txid: string]: string },
+ change: { [txid: string]: { txid: string, rate: number | undefined, acc: boolean | undefined } },
+ direction?: string,
+ }[] = [];
+
+ searchText: string;
+ searchSubscription: Subscription;
+ filtersAvailable: boolean = true;
+ activeFilterFlags: bigint | null = null;
+
+ webGlEnabled = true;
+
+ constructor(
+ readonly ngZone: NgZone,
+ readonly elRef: ElementRef,
+ public stateService: StateService,
+ private themeService: ThemeService,
+ ) {
+ this.webGlEnabled = this.stateService.isBrowser && detectWebGL();
+ this.vertexArray = new FastVertexArray(512, TxSprite.dataSize);
+ }
+
+ ngAfterViewInit(): void {
+ if (this.canvas) {
+ this.canvas.nativeElement.addEventListener('webglcontextlost', this.handleContextLost, false);
+ this.canvas.nativeElement.addEventListener('webglcontextrestored', this.handleContextRestored, false);
+ this.gl = this.canvas.nativeElement.getContext('webgl');
+ this.initScenes();
+
+ if (this.gl) {
+ this.initCanvas();
+ this.resizeCanvas();
+ this.themeChangedSubscription = this.themeService.themeChanged$.subscribe(() => {
+ for (const scene of this.scenes) {
+ scene.setColorFunction(this.getColorFunction());
+ }
+ });
+ }
+ }
+ }
+
+ initScenes(): void {
+ for (const scene of this.scenes) {
+ if (scene) {
+ scene.destroy();
+ }
+ }
+ this.scenes = [];
+ this.pendingUpdates = [];
+ for (let i = 0; i < this.numBlocks; i++) {
+ this.scenes.push(null);
+ this.pendingUpdates.push({
+ count: 0,
+ add: {},
+ remove: {},
+ change: {},
+ direction: 'left',
+ });
+ }
+ this.resizeCanvas();
+ this.start();
+ }
+
+ ngOnChanges(changes): void {
+ if (changes.numBlocks) {
+ this.initScenes();
+ }
+ if (changes.orientation || changes.flip) {
+ for (const scene of this.scenes) {
+ scene?.setOrientation(this.orientation, this.flip);
+ }
+ }
+ if (changes.auditHighlighting) {
+ this.setHighlightingEnabled(this.auditHighlighting);
+ }
+ if (changes.overrideColor) {
+ for (const scene of this.scenes) {
+ scene?.setColorFunction(this.getFilterColorFunction(0n, this.gradientMode));
+ }
+ }
+ if ((changes.filterFlags || changes.showFilters || changes.filterMode || changes.gradientMode)) {
+ this.setFilterFlags();
+ }
+ }
+
+ setFilterFlags(goggle?: ActiveFilter): void {
+ this.filterMode = goggle?.mode || this.filterMode;
+ this.gradientMode = goggle?.gradient || this.gradientMode;
+ this.activeFilterFlags = goggle?.filters ? toFlags(goggle.filters) : this.filterFlags;
+ for (const scene of this.scenes) {
+ if (this.activeFilterFlags != null && this.filtersAvailable) {
+ scene.setColorFunction(this.getFilterColorFunction(this.activeFilterFlags, this.gradientMode));
+ } else {
+ scene.setColorFunction(this.getFilterColorFunction(0n, this.gradientMode));
+ }
+ }
+ this.start();
+ }
+
+ ngOnDestroy(): void {
+ if (this.animationFrameRequest) {
+ cancelAnimationFrame(this.animationFrameRequest);
+ clearTimeout(this.animationHeartBeat);
+ }
+ if (this.canvas) {
+ this.canvas.nativeElement.removeEventListener('webglcontextlost', this.handleContextLost);
+ this.canvas.nativeElement.removeEventListener('webglcontextrestored', this.handleContextRestored);
+ this.themeChangedSubscription?.unsubscribe();
+ }
+ }
+
+ clear(block: number, direction): void {
+ this.exit(block, direction);
+ this.start();
+ }
+
+ destroy(block: number): void {
+ if (this.scenes[block]) {
+ this.scenes[block].destroy();
+ this.clearUpdateQueue(block);
+ this.start();
+ }
+ }
+
+ // initialize the scene without any entry transition
+ setup(block: number, transactions: TransactionStripped[], sort: boolean = false): void {
+ const filtersAvailable = transactions.reduce((flagSet, tx) => flagSet || tx.flags > 0, false);
+ if (filtersAvailable !== this.filtersAvailable) {
+ this.setFilterFlags();
+ }
+ this.filtersAvailable = filtersAvailable;
+ if (this.scenes[block]) {
+ this.clearUpdateQueue(block);
+ this.scenes[block].setup(transactions, sort);
+ this.readyNextFrame = true;
+ this.start();
+ }
+ }
+
+ enter(block: number, transactions: TransactionStripped[], direction: string): void {
+ if (this.scenes[block]) {
+ this.clearUpdateQueue(block);
+ this.scenes[block].enter(transactions, direction);
+ this.start();
+ }
+ }
+
+ exit(block: number, direction: string): void {
+ if (this.scenes[block]) {
+ this.clearUpdateQueue(block);
+ this.scenes[block].exit(direction);
+ this.start();
+ }
+ }
+
+ replace(block: number, transactions: TransactionStripped[], direction: string, sort: boolean = true, startTime?: number): void {
+ if (this.scenes[block]) {
+ this.clearUpdateQueue(block);
+ this.scenes[block].replace(transactions || [], direction, sort, startTime);
+ this.start();
+ }
+ }
+
+ // collates deferred updates into a set of consistent pending changes
+ queueUpdate(block: number, add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left'): void {
+ for (const tx of add) {
+ this.pendingUpdates[block].add[tx.txid] = tx;
+ delete this.pendingUpdates[block].remove[tx.txid];
+ delete this.pendingUpdates[block].change[tx.txid];
+ }
+ for (const txid of remove) {
+ delete this.pendingUpdates[block].add[txid];
+ this.pendingUpdates[block].remove[txid] = txid;
+ delete this.pendingUpdates[block].change[txid];
+ }
+ for (const tx of change) {
+ if (this.pendingUpdates[block].add[tx.txid]) {
+ this.pendingUpdates[block].add[tx.txid].rate = tx.rate;
+ this.pendingUpdates[block].add[tx.txid].acc = tx.acc;
+ } else {
+ this.pendingUpdates[block].change[tx.txid] = tx;
+ }
+ }
+ this.pendingUpdates[block].direction = direction;
+ this.pendingUpdates[block].count++;
+ }
+
+ deferredUpdate(block: number, add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left'): void {
+ this.queueUpdate(block, add, remove, change, direction);
+ this.applyQueuedUpdates();
+ }
+
+ applyQueuedUpdates(): void {
+ for (const [index, pendingUpdate] of this.pendingUpdates.entries()) {
+ if (pendingUpdate.count && performance.now() > (this.lastUpdate + this.animationDuration)) {
+ this.applyUpdate(index, Object.values(pendingUpdate.add), Object.values(pendingUpdate.remove), Object.values(pendingUpdate.change), pendingUpdate.direction);
+ }
+ this.clearUpdateQueue(index);
+ }
+ }
+
+ clearUpdateQueue(block: number): void {
+ this.pendingUpdates[block] = {
+ count: 0,
+ add: {},
+ remove: {},
+ change: {},
+ };
+ this.lastUpdate = performance.now();
+ }
+
+ update(block: number, add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void {
+ // merge any pending changes into this update
+ this.queueUpdate(block, add, remove, change, direction);
+ this.applyUpdate(block,Object.values(this.pendingUpdates[block].add), Object.values(this.pendingUpdates[block].remove), Object.values(this.pendingUpdates[block].change), direction, resetLayout);
+ this.clearUpdateQueue(block);
+ }
+
+ applyUpdate(block: number, add: TransactionStripped[], remove: string[], change: { txid: string, rate: number | undefined, acc: boolean | undefined }[], direction: string = 'left', resetLayout: boolean = false): void {
+ if (this.scenes[block]) {
+ add = add.filter(tx => !this.scenes[block].txs[tx.txid]);
+ remove = remove.filter(txid => this.scenes[block].txs[txid]);
+ change = change.filter(tx => this.scenes[block].txs[tx.txid]);
+
+ if (this.gradientMode === 'age') {
+ this.scenes[block].updateAllColors();
+ }
+ this.scenes[block].update(add, remove, change, direction, resetLayout);
+ this.start();
+ this.lastUpdate = performance.now();
+ }
+ }
+
+ initCanvas(): void {
+ if (!this.canvas || !this.gl) {
+ return;
+ }
+
+ this.gl.clearColor(0.0, 0.0, 0.0, 0.0);
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT);
+
+ const shaderSet = [
+ {
+ type: this.gl.VERTEX_SHADER,
+ src: vertShaderSrc
+ },
+ {
+ type: this.gl.FRAGMENT_SHADER,
+ src: fragShaderSrc
+ }
+ ];
+
+ this.shaderProgram = this.buildShaderProgram(shaderSet);
+
+ this.gl.useProgram(this.shaderProgram);
+
+ // Set up alpha blending
+ this.gl.enable(this.gl.BLEND);
+ this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA);
+
+ const glBuffer = this.gl.createBuffer();
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, glBuffer);
+
+ /* SET UP SHADER ATTRIBUTES */
+ Object.keys(attribs).forEach((key, i) => {
+ attribs[key].pointer = this.gl.getAttribLocation(this.shaderProgram, key);
+ this.gl.enableVertexAttribArray(attribs[key].pointer);
+ });
+
+ this.start();
+ }
+
+ handleContextLost(event): void {
+ event.preventDefault();
+ cancelAnimationFrame(this.animationFrameRequest);
+ this.animationFrameRequest = null;
+ this.running = false;
+ this.gl = null;
+ }
+
+ handleContextRestored(event): void {
+ if (this.canvas?.nativeElement) {
+ this.gl = this.canvas.nativeElement.getContext('webgl');
+ if (this.gl) {
+ this.initCanvas();
+ }
+ }
+ }
+
+ @HostListener('window:resize', ['$event'])
+ resizeCanvas(): void {
+ if (this.canvas) {
+ this.cssWidth = this.canvas.nativeElement.offsetParent.clientWidth;
+ this.cssHeight = this.canvas.nativeElement.offsetParent.clientHeight;
+ this.displayWidth = window.devicePixelRatio * this.cssWidth;
+ this.displayHeight = window.devicePixelRatio * this.cssHeight;
+ this.canvas.nativeElement.width = this.displayWidth;
+ this.canvas.nativeElement.height = this.displayHeight;
+ if (this.gl) {
+ this.gl.viewport(0, 0, this.displayWidth, this.displayHeight);
+ }
+ for (let i = 0; i < this.scenes.length; i++) {
+ const blocksPerRow = Math.floor(this.displayWidth / this.blockWidth);
+ const x = (i % blocksPerRow) * this.blockWidth;
+ const row = Math.floor(i / blocksPerRow);
+ const y = this.displayHeight - ((row + 1) * this.blockWidth);
+ if (this.scenes[i]) {
+ this.scenes[i].resize({ x, y, width: this.blockWidth, height: this.blockWidth, animate: false });
+ this.start();
+ } else {
+ this.scenes[i] = new BlockScene({ x, y, width: this.blockWidth, height: this.blockWidth, resolution: this.resolution,
+ blockLimit: this.blockLimit, orientation: this.orientation, flip: this.flip, vertexArray: this.vertexArray, theme: this.themeService,
+ highlighting: this.auditHighlighting, animationDuration: this.animationDuration, animationOffset: 0,
+ colorFunction: this.getColorFunction() });
+ this.start();
+ }
+ }
+ }
+ }
+
+ compileShader(src, type): WebGLShader {
+ if (!this.gl) {
+ return;
+ }
+ const shader = this.gl.createShader(type);
+
+ this.gl.shaderSource(shader, src);
+ this.gl.compileShader(shader);
+
+ if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
+ console.log(`Error compiling ${type === this.gl.VERTEX_SHADER ? 'vertex' : 'fragment'} shader:`);
+ console.log(this.gl.getShaderInfoLog(shader));
+ }
+ return shader;
+ }
+
+ buildShaderProgram(shaderInfo): WebGLProgram {
+ if (!this.gl) {
+ return;
+ }
+ const program = this.gl.createProgram();
+
+ shaderInfo.forEach((desc) => {
+ const shader = this.compileShader(desc.src, desc.type);
+ if (shader) {
+ this.gl.attachShader(program, shader);
+ }
+ });
+
+ this.gl.linkProgram(program);
+
+ if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) {
+ console.log('Error linking shader program:');
+ console.log(this.gl.getProgramInfoLog(program));
+ }
+
+ return program;
+ }
+
+ start(): void {
+ this.running = true;
+ this.ngZone.runOutsideAngular(() => this.doRun());
+ }
+
+ doRun(): void {
+ if (this.animationFrameRequest) {
+ cancelAnimationFrame(this.animationFrameRequest);
+ }
+ this.animationFrameRequest = requestAnimationFrame(() => this.run());
+ }
+
+ run(now?: DOMHighResTimeStamp): void {
+ if (!now) {
+ now = performance.now();
+ }
+ this.applyQueuedUpdates();
+ // skip re-render if there's no change to the scene
+ if (this.scenes.length && this.gl) {
+ /* SET UP SHADER UNIFORMS */
+ // screen dimensions
+ this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight);
+ // frame timestamp
+ this.gl.uniform1f(this.gl.getUniformLocation(this.shaderProgram, 'now'), now);
+
+ if (this.vertexArray.dirty) {
+ /* SET UP SHADER ATTRIBUTES */
+ Object.keys(attribs).forEach((key, i) => {
+ this.gl.vertexAttribPointer(attribs[key].pointer,
+ attribs[key].count, // number of primitives in this attribute
+ this.gl[attribs[key].type], // type of primitive in this attribute (e.g. gl.FLOAT)
+ false, // never normalised
+ stride, // distance between values of the same attribute
+ attribs[key].offset); // offset of the first value
+ });
+
+ const pointArray = this.vertexArray.getVertexData();
+
+ if (pointArray.length) {
+ this.gl.bufferData(this.gl.ARRAY_BUFFER, pointArray, this.gl.DYNAMIC_DRAW);
+ this.gl.drawArrays(this.gl.TRIANGLES, 0, pointArray.length / TxSprite.vertexSize);
+ }
+ this.vertexArray.dirty = false;
+ } else {
+ const pointArray = this.vertexArray.getVertexData();
+ if (pointArray.length) {
+ this.gl.drawArrays(this.gl.TRIANGLES, 0, pointArray.length / TxSprite.vertexSize);
+ }
+ }
+
+ if (this.readyNextFrame) {
+ this.readyNextFrame = false;
+ this.readyEvent.emit();
+ }
+ }
+
+ /* LOOP */
+ if (this.running && this.scenes.length && now <= (this.scenes.reduce((max, scene) => scene.animateUntil > max ? scene.animateUntil : max, 0) + 500)) {
+ this.doRun();
+ } else {
+ if (this.animationHeartBeat) {
+ clearTimeout(this.animationHeartBeat);
+ }
+ this.animationHeartBeat = window.setTimeout(() => {
+ this.start();
+ }, 1000);
+ }
+ }
+
+ setHighlightingEnabled(enabled: boolean): void {
+ for (const scene of this.scenes) {
+ scene.setHighlighting(enabled);
+ }
+ this.start();
+ }
+
+ getColorFunction(): ((tx: TxView) => Color) {
+ if (this.overrideColors) {
+ return this.overrideColors;
+ } else if (this.filterFlags) {
+ return this.getFilterColorFunction(this.filterFlags, this.gradientMode);
+ } else if (this.activeFilterFlags) {
+ return this.getFilterColorFunction(this.activeFilterFlags, this.gradientMode);
+ } else {
+ return this.getFilterColorFunction(0n, this.gradientMode);
+ }
+ }
+
+ getFilterColorFunction(flags: bigint, gradient: 'fee' | 'age'): ((tx: TxView) => Color) {
+ return (tx: TxView) => {
+ if ((this.filterMode === 'and' && (tx.bigintFlags & flags) === flags) || (this.filterMode === 'or' && (flags === 0n || (tx.bigintFlags & flags) > 0n))) {
+ if (this.themeService.theme !== 'contrast' && this.themeService.theme !== 'bukele') {
+ return (gradient === 'age') ? ageColorFunction(tx, defaultColors.fee, defaultAuditColors, this.relativeTime || (Date.now() / 1000)) : defaultColorFunction(tx, defaultColors.fee, defaultAuditColors, this.relativeTime || (Date.now() / 1000));
+ } else {
+ return (gradient === 'age') ? ageColorFunction(tx, contrastColors.fee, contrastAuditColors, this.relativeTime || (Date.now() / 1000)) : contrastColorFunction(tx, contrastColors.fee, contrastAuditColors, this.relativeTime || (Date.now() / 1000));
+ }
+ } else {
+ if (this.themeService.theme !== 'contrast' && this.themeService.theme !== 'bukele') {
+ return (gradient === 'age') ? { r: 1, g: 1, b: 1, a: 0.05 } : defaultColorFunction(
+ tx,
+ defaultColors.unmatchedfee,
+ unmatchedAuditColors,
+ this.relativeTime || (Date.now() / 1000)
+ );
+ } else {
+ return (gradient === 'age') ? { r: 1, g: 1, b: 1, a: 0.05 } : contrastColorFunction(
+ tx,
+ contrastColors.unmatchedfee,
+ unmatchedContrastAuditColors,
+ this.relativeTime || (Date.now() / 1000)
+ );
+ }
+ }
+ };
+ }
+}
+
+// WebGL shader attributes
+const attribs = {
+ offset: { type: 'FLOAT', count: 2, pointer: null, offset: 0 },
+ posX: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
+ posY: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
+ posR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
+ colR: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
+ colG: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
+ colB: { type: 'FLOAT', count: 4, pointer: null, offset: 0 },
+ colA: { type: 'FLOAT', count: 4, pointer: null, offset: 0 }
+};
+// Calculate the number of bytes per vertex based on specified attributes
+const stride = Object.values(attribs).reduce((total, attrib) => {
+ return total + (attrib.count * 4);
+}, 0);
+// Calculate vertex attribute offsets
+for (let i = 0, offset = 0; i < Object.keys(attribs).length; i++) {
+ const attrib = Object.values(attribs)[i];
+ attrib.offset = offset;
+ offset += (attrib.count * 4);
+}
+
+const vertShaderSrc = `
+varying lowp vec4 vColor;
+
+// each attribute contains [x: startValue, y: endValue, z: startTime, w: rate]
+// shader interpolates between start and end values at the given rate, from the given time
+
+attribute vec2 offset;
+attribute vec4 posX;
+attribute vec4 posY;
+attribute vec4 posR;
+attribute vec4 colR;
+attribute vec4 colG;
+attribute vec4 colB;
+attribute vec4 colA;
+
+uniform vec2 screenSize;
+uniform float now;
+
+float smootherstep(float x) {
+ x = clamp(x, 0.0, 1.0);
+ float ix = 1.0 - x;
+ x = x * x;
+ return x / (x + ix * ix);
+}
+
+float interpolateAttribute(vec4 attr) {
+ float d = (now - attr.z) * attr.w;
+ float delta = smootherstep(d);
+ return mix(attr.x, attr.y, delta);
+}
+
+void main() {
+ vec4 screenTransform = vec4(2.0 / screenSize.x, 2.0 / screenSize.y, -1.0, -1.0);
+ // vec4 screenTransform = vec4(1.0 / screenSize.x, 1.0 / screenSize.y, -0.5, -0.5);
+
+ float radius = interpolateAttribute(posR);
+ vec2 position = vec2(interpolateAttribute(posX), interpolateAttribute(posY)) + (radius * offset);
+
+ gl_Position = vec4(position * screenTransform.xy + screenTransform.zw, 1.0, 1.0);
+
+ float red = interpolateAttribute(colR);
+ float green = interpolateAttribute(colG);
+ float blue = interpolateAttribute(colB);
+ float alpha = interpolateAttribute(colA);
+
+ vColor = vec4(red, green, blue, alpha);
+}
+`;
+
+const fragShaderSrc = `
+varying lowp vec4 vColor;
+
+void main() {
+ gl_FragColor = vColor;
+ // premultiply alpha
+ gl_FragColor.rgb *= gl_FragColor.a;
+}
+`;
diff --git a/frontend/src/app/components/eight-blocks/eight-blocks.component.html b/frontend/src/app/components/eight-blocks/eight-blocks.component.html
index 414a693d3..d43a2a07d 100644
--- a/frontend/src/app/components/eight-blocks/eight-blocks.component.html
+++ b/frontend/src/app/components/eight-blocks/eight-blocks.component.html
@@ -1,10 +1,12 @@
-
+
+
-
+ >
+
\ 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
index 81dcc4c5b..2e3c70352 100644
--- a/frontend/src/app/components/eight-blocks/eight-blocks.component.ts
+++ b/frontend/src/app/components/eight-blocks/eight-blocks.component.ts
@@ -1,16 +1,15 @@
-import { Component, OnInit, OnDestroy, ViewChildren, QueryList } from '@angular/core';
+import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
-import { catchError, startWith } from 'rxjs/operators';
+import { catchError } 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';
+import { BlockOverviewMultiComponent } from '../block-overview-multi/block-overview-multi.component';
function bestFitResolution(min, max, n): number {
const target = (min + max) / 2;
@@ -65,7 +64,7 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
autofit: boolean = false;
padding: number = 0;
wrapBlocks: boolean = false;
- blockWidth: number = 1080;
+ blockWidth: number = 360;
animationDuration: number = 2000;
animationOffset: number = 0;
stagger: number = 0;
@@ -85,7 +84,7 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
containerStyle = {};
resolution: number = 86;
- @ViewChildren('blockGraph') blockGraphs: QueryList;
+ @ViewChild('blockGraph') blockGraph: BlockOverviewMultiComponent;
constructor(
private route: ActivatedRoute,
@@ -149,9 +148,7 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
}
ngAfterViewInit(): void {
- this.graphChangeSubscription = this.blockGraphs.changes.pipe(startWith(null)).subscribe(() => {
- this.setupBlockGraphs();
- });
+ this.setupBlockGraphs();
}
ngOnDestroy(): void {
@@ -208,10 +205,10 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
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));
- });
+ if (this.blockGraph) {
+ for (let i = 0; i < this.numBlocks; i++) {
+ this.blockGraph.replace(i, this.strippedTransactions[blocks?.[i]?.height] || [], 'right', false, startTime + (this.stagger * i));
+ }
}
this.showInfo = false;
setTimeout(() => {
@@ -226,28 +223,11 @@ export class EightBlocksComponent implements OnInit, OnDestroy {
}
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;
+ if (this.blockGraph) {
+ for (let i = 0; i < this.numBlocks; i++) {
+ this.blockGraph.destroy(i);
+ this.blockGraph.setup(i, this.strippedTransactions[this.latestBlocks?.[i]?.height] || []);
+ }
}
}
}
diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts
index 0e37bc9d5..2b3129239 100644
--- a/frontend/src/app/shared/shared.module.ts
+++ b/frontend/src/app/shared/shared.module.ts
@@ -48,6 +48,7 @@ import { CapAddressPipe } from './pipes/cap-address-pipe/cap-address-pipe';
import { StartComponent } from '../components/start/start.component';
import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component';
import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component';
+import { BlockOverviewMultiComponent } from '../components/block-overview-multi/block-overview-multi.component';
import { BlockOverviewTooltipComponent } from '../components/block-overview-tooltip/block-overview-tooltip.component';
import { BlockFiltersComponent } from '../components/block-filters/block-filters.component';
import { AddressGroupComponent } from '../components/address-group/address-group.component';
@@ -163,6 +164,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
PreviewTitleComponent,
StartComponent,
BlockOverviewGraphComponent,
+ BlockOverviewMultiComponent,
BlockOverviewTooltipComponent,
BlockFiltersComponent,
TransactionsListComponent,
@@ -306,6 +308,7 @@ import { OnlyVsizeDirective, OnlyWeightDirective } from './components/weight-dir
AmountComponent,
StartComponent,
BlockOverviewGraphComponent,
+ BlockOverviewMultiComponent,
BlockOverviewTooltipComponent,
BlockFiltersComponent,
TransactionsListComponent,