From a33de8bc8c7e5901737d49072ea9471487bb9799 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 17 Sep 2024 23:48:25 +0000 Subject: [PATCH] Test rendering multiple blocks on one canvas --- .../block-overview-graph/block-scene.ts | 28 +- .../block-overview-multi.component.html | 24 + .../block-overview-multi.component.scss | 67 ++ .../block-overview-multi.component.ts | 647 ++++++++++++++++++ .../eight-blocks/eight-blocks.component.html | 16 +- .../eight-blocks/eight-blocks.component.ts | 50 +- frontend/src/app/shared/shared.module.ts | 3 + 7 files changed, 780 insertions(+), 55 deletions(-) create mode 100644 frontend/src/app/components/block-overview-multi/block-overview-multi.component.html create mode 100644 frontend/src/app/components/block-overview-multi/block-overview-multi.component.scss create mode 100644 frontend/src/app/components/block-overview-multi/block-overview-multi.component.ts 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) { +
+
+
not available
+
+ } + + +
+ 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,