Extract canvas/webgl code to separate component
This commit is contained in:
parent
c5bcf76353
commit
225decd286
@ -0,0 +1,6 @@
|
||||
<div class="block-overview-graph">
|
||||
<canvas class="block-overview-canvas" #blockCanvas></canvas>
|
||||
<div class="loader-wrapper" [class.hidden]="!isLoading">
|
||||
<div class="spinner-border ml-3 loading" role="status"></div>
|
||||
</div>
|
||||
</div>
|
@ -1,4 +1,4 @@
|
||||
.mempool-block-overview {
|
||||
.block-overview-graph {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 100%;
|
||||
@ -8,12 +8,15 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.block-overview {
|
||||
|
||||
.block-overview-canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@ -27,6 +30,7 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: opacity 500ms 500ms;
|
||||
pointer-events: none;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
@ -0,0 +1,360 @@
|
||||
import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, NgZone, AfterViewInit } from '@angular/core';
|
||||
import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
||||
import { WebsocketService } from 'src/app/services/websocket.service';
|
||||
import { FastVertexArray } from './fast-vertex-array';
|
||||
import BlockScene from './block-scene';
|
||||
import TxSprite from './tx-sprite';
|
||||
import TxView from './tx-view';
|
||||
|
||||
@Component({
|
||||
selector: 'app-block-overview-graph',
|
||||
templateUrl: './block-overview-graph.component.html',
|
||||
styleUrls: ['./block-overview-graph.component.scss'],
|
||||
})
|
||||
export class BlockOverviewGraphComponent implements AfterViewInit {
|
||||
@Input() isLoading: boolean;
|
||||
@Input() resolution: number;
|
||||
@Input() blockLimit: number;
|
||||
@Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>();
|
||||
|
||||
@ViewChild('blockCanvas')
|
||||
canvas: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
gl: WebGLRenderingContext;
|
||||
animationFrameRequest: number;
|
||||
displayWidth: number;
|
||||
displayHeight: number;
|
||||
cssWidth: number;
|
||||
cssHeight: number;
|
||||
shaderProgram: WebGLProgram;
|
||||
vertexArray: FastVertexArray;
|
||||
running: boolean;
|
||||
scene: BlockScene;
|
||||
hoverTx: TxView | void;
|
||||
selectedTx: TxView | void;
|
||||
|
||||
constructor(
|
||||
readonly ngZone: NgZone,
|
||||
) {
|
||||
this.vertexArray = new FastVertexArray(512, TxSprite.dataSize);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.canvas.nativeElement.addEventListener('webglcontextlost', this.handleContextLost, false);
|
||||
this.canvas.nativeElement.addEventListener('webglcontextrestored', this.handleContextRestored, false);
|
||||
this.gl = this.canvas.nativeElement.getContext('webgl');
|
||||
this.initCanvas();
|
||||
|
||||
this.resizeCanvas();
|
||||
}
|
||||
|
||||
clear(direction): void {
|
||||
this.exit(direction);
|
||||
this.hoverTx = null;
|
||||
this.selectedTx = null;
|
||||
this.txPreviewEvent.emit(null);
|
||||
}
|
||||
|
||||
enter(transactions: TransactionStripped[], direction: string): void {
|
||||
if (this.scene) {
|
||||
this.scene.enter(transactions, direction);
|
||||
}
|
||||
}
|
||||
|
||||
exit(direction: string): void {
|
||||
if (this.scene) {
|
||||
this.scene.exit(direction);
|
||||
}
|
||||
}
|
||||
|
||||
replace(transactions: TransactionStripped[], direction: string): void {
|
||||
if (this.scene) {
|
||||
this.scene.replace(transactions, direction);
|
||||
}
|
||||
}
|
||||
|
||||
update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void {
|
||||
if (this.scene) {
|
||||
this.scene.update(add, remove, direction, resetLayout);
|
||||
}
|
||||
}
|
||||
|
||||
initCanvas(): void {
|
||||
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;
|
||||
}
|
||||
|
||||
handleContextRestored(event): void {
|
||||
this.initCanvas();
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
resizeCanvas(): void {
|
||||
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);
|
||||
}
|
||||
if (this.scene) {
|
||||
this.scene.resize({ width: this.displayWidth, height: this.displayHeight });
|
||||
} else {
|
||||
this.scene = this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: this.resolution,
|
||||
blockLimit: this.blockLimit, vertexArray: this.vertexArray });
|
||||
}
|
||||
}
|
||||
|
||||
compileShader(src, type): WebGLShader {
|
||||
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 {
|
||||
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.run());
|
||||
}
|
||||
|
||||
run(now?: DOMHighResTimeStamp): void {
|
||||
if (!now) {
|
||||
now = performance.now();
|
||||
}
|
||||
|
||||
/* 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);
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* LOOP */
|
||||
if (this.running) {
|
||||
if (this.animationFrameRequest) {
|
||||
cancelAnimationFrame(this.animationFrameRequest);
|
||||
this.animationFrameRequest = null;
|
||||
}
|
||||
this.animationFrameRequest = requestAnimationFrame(() => this.run());
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('click', ['$event'])
|
||||
onClick(event) {
|
||||
this.setPreviewTx(event.offsetX, event.offsetY, true);
|
||||
}
|
||||
|
||||
@HostListener('pointermove', ['$event'])
|
||||
onPointerMove(event) {
|
||||
this.setPreviewTx(event.offsetX, event.offsetY, false);
|
||||
}
|
||||
|
||||
@HostListener('pointerleave', ['$event'])
|
||||
onPointerLeave(event) {
|
||||
this.setPreviewTx(-1, -1, false);
|
||||
}
|
||||
|
||||
setPreviewTx(cssX: number, cssY: number, clicked: boolean = false) {
|
||||
const x = cssX * window.devicePixelRatio;
|
||||
const y = cssY * window.devicePixelRatio;
|
||||
if (this.scene && (!this.selectedTx || clicked)) {
|
||||
const selected = this.scene.getTxAt({ x, y });
|
||||
const currentPreview = this.selectedTx || this.hoverTx;
|
||||
|
||||
if (selected !== currentPreview) {
|
||||
if (currentPreview) {
|
||||
currentPreview.setHover(false);
|
||||
}
|
||||
if (selected) {
|
||||
selected.setHover(true);
|
||||
this.txPreviewEvent.emit({
|
||||
txid: selected.txid,
|
||||
fee: selected.fee,
|
||||
vsize: selected.vsize,
|
||||
value: selected.value
|
||||
});
|
||||
if (clicked) {
|
||||
this.selectedTx = selected;
|
||||
} else {
|
||||
this.hoverTx = selected;
|
||||
}
|
||||
} else {
|
||||
if (clicked) {
|
||||
this.selectedTx = null;
|
||||
}
|
||||
this.hoverTx = null;
|
||||
this.txPreviewEvent.emit(null);
|
||||
}
|
||||
} else if (clicked) {
|
||||
if (selected === this.selectedTx) {
|
||||
this.hoverTx = this.selectedTx;
|
||||
this.selectedTx = null;
|
||||
} else {
|
||||
this.selectedTx = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
`;
|
724
frontend/src/app/components/block-overview-graph/block-scene.ts
Normal file
724
frontend/src/app/components/block-overview-graph/block-scene.ts
Normal file
@ -0,0 +1,724 @@
|
||||
import { FastVertexArray } from './fast-vertex-array';
|
||||
import TxView from './tx-view';
|
||||
import { TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
||||
import { Position, Square } from './sprite-types';
|
||||
|
||||
export default class BlockScene {
|
||||
scene: { count: number, offset: { x: number, y: number}};
|
||||
vertexArray: FastVertexArray;
|
||||
txs: { [key: string]: TxView };
|
||||
width: number;
|
||||
height: number;
|
||||
gridWidth: number;
|
||||
gridHeight: number;
|
||||
gridSize: number;
|
||||
vbytesPerUnit: number;
|
||||
unitPadding: number;
|
||||
unitWidth: number;
|
||||
initialised: boolean;
|
||||
layout: BlockLayout;
|
||||
dirty: boolean;
|
||||
|
||||
constructor({ width, height, resolution, blockLimit, vertexArray }:
|
||||
{ width: number, height: number, resolution: number, blockLimit: number, vertexArray: FastVertexArray }
|
||||
) {
|
||||
this.init({ width, height, resolution, blockLimit, vertexArray });
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
Object.values(this.txs).forEach(tx => tx.destroy());
|
||||
}
|
||||
|
||||
resize({ width = this.width, height = this.height }: { width?: number, height?: number}): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.gridSize = this.width / this.gridWidth;
|
||||
this.unitPadding = width / 500;
|
||||
this.unitWidth = this.gridSize - (this.unitPadding * 2);
|
||||
|
||||
this.dirty = true;
|
||||
if (this.initialised && this.scene) {
|
||||
this.updateAll(performance.now());
|
||||
}
|
||||
}
|
||||
|
||||
// Animate new block entering scene
|
||||
enter(txs: TransactionStripped[], direction) {
|
||||
this.replace(txs, direction);
|
||||
}
|
||||
|
||||
// Animate block leaving scene
|
||||
exit(direction: string): void {
|
||||
const startTime = performance.now();
|
||||
const removed = this.removeBatch(Object.keys(this.txs), startTime, direction);
|
||||
|
||||
// clean up sprites
|
||||
setTimeout(() => {
|
||||
removed.forEach(tx => {
|
||||
tx.destroy();
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Reset layout and replace with new set of transactions
|
||||
replace(txs: TransactionStripped[], direction: string = 'left'): void {
|
||||
const startTime = performance.now();
|
||||
const nextIds = {};
|
||||
const remove = [];
|
||||
txs.forEach(tx => {
|
||||
nextIds[tx.txid] = true;
|
||||
});
|
||||
Object.keys(this.txs).forEach(txid => {
|
||||
if (!nextIds[txid]) {
|
||||
remove.push(txid);
|
||||
}
|
||||
});
|
||||
txs.forEach(tx => {
|
||||
if (!this.txs[tx.txid]) {
|
||||
this.txs[tx.txid] = new TxView(tx, this.vertexArray);
|
||||
}
|
||||
});
|
||||
|
||||
const removed = this.removeBatch(remove, startTime, direction);
|
||||
|
||||
// clean up sprites
|
||||
setTimeout(() => {
|
||||
removed.forEach(tx => {
|
||||
tx.destroy();
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight });
|
||||
|
||||
Object.values(this.txs).sort(feeRateDescending).forEach(tx => {
|
||||
this.place(tx);
|
||||
});
|
||||
|
||||
this.updateAll(startTime, direction);
|
||||
}
|
||||
|
||||
update(add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void {
|
||||
const startTime = performance.now();
|
||||
const removed = this.removeBatch(remove, startTime, direction);
|
||||
|
||||
// clean up sprites
|
||||
setTimeout(() => {
|
||||
removed.forEach(tx => {
|
||||
tx.destroy();
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
if (resetLayout) {
|
||||
add.forEach(tx => {
|
||||
if (!this.txs[tx.txid]) {
|
||||
this.txs[tx.txid] = new TxView(tx, this.vertexArray);
|
||||
}
|
||||
});
|
||||
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight });
|
||||
Object.values(this.txs).sort(feeRateDescending).forEach(tx => {
|
||||
this.place(tx);
|
||||
});
|
||||
} else {
|
||||
// try to insert new txs directly
|
||||
const remaining = [];
|
||||
add.map(tx => new TxView(tx, this.vertexArray)).sort(feeRateDescending).forEach(tx => {
|
||||
if (!this.tryInsertByFee(tx)) {
|
||||
remaining.push(tx);
|
||||
}
|
||||
});
|
||||
this.placeBatch(remaining);
|
||||
this.layout.applyGravity();
|
||||
}
|
||||
|
||||
this.updateAll(startTime, direction);
|
||||
}
|
||||
|
||||
// return the tx at this screen position, if any
|
||||
getTxAt(position: Position): TxView | void {
|
||||
if (this.layout) {
|
||||
const gridPosition = this.screenToGrid(position);
|
||||
return this.layout.getTx(gridPosition);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private init({ width, height, resolution, blockLimit, vertexArray }:
|
||||
{ width: number, height: number, resolution: number, blockLimit: number, vertexArray: FastVertexArray }
|
||||
): void {
|
||||
this.vertexArray = vertexArray;
|
||||
|
||||
this.scene = {
|
||||
count: 0,
|
||||
offset: {
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Set the scale of the visualization (with a 5% margin)
|
||||
this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.05, 2);
|
||||
this.gridWidth = resolution;
|
||||
this.gridHeight = resolution;
|
||||
this.resize({ width, height });
|
||||
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight });
|
||||
|
||||
this.txs = {};
|
||||
|
||||
this.initialised = true;
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
private insert(tx: TxView, startTime: number, direction: string = 'left'): void {
|
||||
this.txs[tx.txid] = tx;
|
||||
this.place(tx);
|
||||
this.updateTx(tx, startTime, direction);
|
||||
}
|
||||
|
||||
private updateTx(tx: TxView, startTime: number, direction: string = 'left'): void {
|
||||
if (tx.dirty || this.dirty) {
|
||||
this.saveGridToScreenPosition(tx);
|
||||
this.setTxOnScreen(tx, startTime, direction);
|
||||
}
|
||||
}
|
||||
|
||||
private setTxOnScreen(tx: TxView, startTime: number, direction: string = 'left'): void {
|
||||
if (!tx.initialised) {
|
||||
const txColor = tx.getColor();
|
||||
tx.update({
|
||||
display: {
|
||||
position: {
|
||||
x: tx.screenPosition.x + (direction === 'right' ? -this.width : this.width) * 1.4,
|
||||
y: tx.screenPosition.y,
|
||||
s: tx.screenPosition.s
|
||||
},
|
||||
color: txColor,
|
||||
},
|
||||
start: startTime,
|
||||
delay: 0,
|
||||
});
|
||||
tx.update({
|
||||
display: {
|
||||
position: tx.screenPosition,
|
||||
color: txColor
|
||||
},
|
||||
duration: 1000,
|
||||
start: startTime,
|
||||
delay: 50,
|
||||
});
|
||||
} else {
|
||||
tx.update({
|
||||
display: {
|
||||
position: tx.screenPosition
|
||||
},
|
||||
duration: 1000,
|
||||
minDuration: 500,
|
||||
start: startTime,
|
||||
delay: 50,
|
||||
adjust: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateAll(startTime: number, direction: string = 'left'): void {
|
||||
this.scene.count = 0;
|
||||
const ids = this.getTxList();
|
||||
startTime = startTime || performance.now();
|
||||
for (const id of ids) {
|
||||
this.updateTx(this.txs[id], startTime, direction);
|
||||
}
|
||||
this.dirty = false;
|
||||
}
|
||||
|
||||
private remove(id: string, startTime: number, direction: string = 'left'): TxView | void {
|
||||
const tx = this.txs[id];
|
||||
if (tx) {
|
||||
this.layout.remove(tx);
|
||||
tx.update({
|
||||
display: {
|
||||
position: {
|
||||
x: tx.screenPosition.x + (direction === 'right' ? this.width : -this.width) * 1.4,
|
||||
y: this.txs[id].screenPosition.y,
|
||||
}
|
||||
},
|
||||
duration: 1000,
|
||||
start: startTime,
|
||||
delay: 50
|
||||
});
|
||||
}
|
||||
delete this.txs[id];
|
||||
return tx;
|
||||
}
|
||||
|
||||
private getTxList(): string[] {
|
||||
return Object.keys(this.txs);
|
||||
}
|
||||
|
||||
private saveGridToScreenPosition(tx: TxView): void {
|
||||
tx.screenPosition = this.gridToScreen(tx.gridPosition);
|
||||
}
|
||||
|
||||
// convert grid coordinates to screen coordinates
|
||||
private gridToScreen(position: Square | void): Square {
|
||||
if (position) {
|
||||
const slotSize = (position.s * this.gridSize);
|
||||
const squareSize = slotSize - (this.unitPadding * 2);
|
||||
|
||||
// The grid is laid out notionally left-to-right, bottom-to-top
|
||||
// So we rotate 90deg counterclockwise then flip the y axis
|
||||
//
|
||||
// grid screen
|
||||
// ________ ________ ________
|
||||
// | | | b| | a|
|
||||
// | | rotate | | flip | c |
|
||||
// | c | --> | c | --> | |
|
||||
// |a______b| |_______a| |_______b|
|
||||
return {
|
||||
x: this.width + (this.unitPadding * 2) - (this.gridSize * position.y) - slotSize,
|
||||
y: this.height - ((this.gridSize * position.x) + (slotSize - this.unitPadding)),
|
||||
s: squareSize
|
||||
};
|
||||
} else {
|
||||
return { x: 0, y: 0, s: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
screenToGrid(position: Position): Position {
|
||||
const grid = {
|
||||
x: Math.floor((position.y - this.unitPadding) / this.gridSize),
|
||||
y: Math.floor((this.width + (this.unitPadding * 2) - position.x) / this.gridSize)
|
||||
};
|
||||
return grid;
|
||||
}
|
||||
|
||||
// calculates and returns the size of the tx in multiples of the grid size
|
||||
private txSize(tx: TxView): number {
|
||||
const scale = Math.max(1, Math.round(Math.sqrt(tx.vsize / this.vbytesPerUnit)));
|
||||
return Math.min(this.gridWidth, Math.max(1, scale)); // bound between 1 and the max displayable size (just in case!)
|
||||
}
|
||||
|
||||
private place(tx: TxView): void {
|
||||
const size = this.txSize(tx);
|
||||
this.layout.insert(tx, size);
|
||||
}
|
||||
|
||||
private tryInsertByFee(tx: TxView): boolean {
|
||||
const size = this.txSize(tx);
|
||||
const position = this.layout.tryInsertByFee(tx, size);
|
||||
if (position) {
|
||||
this.txs[tx.txid] = tx;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Add a list of transactions to the layout,
|
||||
// keeping everything approximately sorted by feerate.
|
||||
private placeBatch(txs: TxView[]): void {
|
||||
if (txs.length) {
|
||||
// grab the new tx with the highest fee rate
|
||||
txs = txs.sort(feeRateDescending);
|
||||
const maxSize = 2 * txs.reduce((max, tx) => {
|
||||
return Math.max(this.txSize(tx), max);
|
||||
}, 1);
|
||||
|
||||
// find a reasonable place for it in the layout
|
||||
const root = this.layout.getReplacementRoot(txs[0].feerate, maxSize);
|
||||
|
||||
// extract a sub tree of transactions from the layout, rooted at that point
|
||||
const popped = this.layout.popTree(root.x, root.y, maxSize);
|
||||
// combine those with the new transactions and sort
|
||||
txs = txs.concat(popped);
|
||||
txs = txs.sort(feeRateDescending);
|
||||
|
||||
// insert everything back into the layout
|
||||
txs.forEach(tx => {
|
||||
this.txs[tx.txid] = tx;
|
||||
this.place(tx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private removeBatch(ids: string[], startTime: number, direction: string = 'left'): TxView[] {
|
||||
if (!startTime) {
|
||||
startTime = performance.now();
|
||||
}
|
||||
return ids.map(id => {
|
||||
return this.remove(id, startTime, direction);
|
||||
}).filter(tx => tx != null) as TxView[];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Slot {
|
||||
l: number;
|
||||
r: number;
|
||||
w: number;
|
||||
|
||||
constructor(l: number, r: number) {
|
||||
this.l = l;
|
||||
this.r = r;
|
||||
this.w = r - l;
|
||||
}
|
||||
|
||||
intersects(slot: Slot): boolean {
|
||||
return !((slot.r <= this.l) || (slot.l >= this.r));
|
||||
}
|
||||
|
||||
subtract(slot: Slot): Slot[] | void {
|
||||
if (this.intersects(slot)) {
|
||||
// from middle
|
||||
if (slot.l > this.l && slot.r < this.r) {
|
||||
return [
|
||||
new Slot(this.l, slot.l),
|
||||
new Slot(slot.r, this.r)
|
||||
];
|
||||
} // totally covered
|
||||
else if (slot.l <= this.l && slot.r >= this.r) {
|
||||
return [];
|
||||
} // from left side
|
||||
else if (slot.l <= this.l) {
|
||||
if (slot.r === this.r) {
|
||||
return [];
|
||||
} else {
|
||||
return [new Slot(slot.r, this.r)];
|
||||
}
|
||||
} // from right side
|
||||
else if (slot.r >= this.r) {
|
||||
if (slot.l === this.l) {
|
||||
return [];
|
||||
} else {
|
||||
return [new Slot(this.l, slot.l)];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return [this];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TxSlot extends Slot {
|
||||
tx: TxView;
|
||||
|
||||
constructor(l: number, r: number, tx: TxView) {
|
||||
super(l, r);
|
||||
this.tx = tx;
|
||||
}
|
||||
}
|
||||
|
||||
class Row {
|
||||
y: number;
|
||||
w: number;
|
||||
filled: TxSlot[];
|
||||
slots: Slot[];
|
||||
|
||||
|
||||
constructor(y: number, width: number) {
|
||||
this.y = y;
|
||||
this.w = width;
|
||||
this.filled = [];
|
||||
this.slots = [new Slot(0, this.w)];
|
||||
}
|
||||
|
||||
// insert a transaction w/ given width into row starting at position x
|
||||
insert(x: number, w: number, tx: TxView): void {
|
||||
const newSlot = new TxSlot(x, x + w, tx);
|
||||
// insert into filled list
|
||||
let index = this.filled.findIndex((slot) => (slot.l >= newSlot.r));
|
||||
if (index < 0) {
|
||||
index = this.filled.length;
|
||||
}
|
||||
this.filled.splice(index || 0, 0, newSlot);
|
||||
// subtract from overlapping slots
|
||||
for (let i = 0; i < this.slots.length; i++) {
|
||||
if (newSlot.intersects(this.slots[i])) {
|
||||
const diff = this.slots[i].subtract(newSlot);
|
||||
if (diff) {
|
||||
this.slots.splice(i, 1, ...diff);
|
||||
i += diff.length - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(x: number, w: number): void {
|
||||
const txIndex = this.filled.findIndex((slot) => (slot.l === x) );
|
||||
this.filled.splice(txIndex, 1);
|
||||
|
||||
const newSlot = new Slot(x, x + w);
|
||||
let slotIndex = this.slots.findIndex((slot) => (slot.l >= newSlot.r) );
|
||||
if (slotIndex < 0) {
|
||||
slotIndex = this.slots.length;
|
||||
}
|
||||
this.slots.splice(slotIndex || 0, 0, newSlot);
|
||||
this.normalize();
|
||||
}
|
||||
|
||||
// merge any contiguous empty slots
|
||||
private normalize(): void {
|
||||
for (let i = 0; i < this.slots.length - 1; i++) {
|
||||
if (this.slots[i].r === this.slots[i + 1].l) {
|
||||
this.slots[i].r = this.slots[i + 1].r;
|
||||
this.slots[i].w += this.slots[i + 1].w;
|
||||
this.slots.splice(i + 1, 1);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
txAt(x: number): TxView | void {
|
||||
let i = 0;
|
||||
while (i < this.filled.length && this.filled[i].l <= x) {
|
||||
if (this.filled[i].l <= x && this.filled[i].r > x) {
|
||||
return this.filled[i].tx;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
getSlotsBetween(left: number, right: number): TxSlot[] {
|
||||
const range = new Slot(left, right);
|
||||
return this.filled.filter(slot => {
|
||||
return slot.intersects(range);
|
||||
});
|
||||
}
|
||||
|
||||
slotAt(x: number): Slot | void {
|
||||
let i = 0;
|
||||
while (i < this.slots.length && this.slots[i].l <= x) {
|
||||
if (this.slots[i].l <= x && this.slots[i].r > x) {
|
||||
return this.slots[i];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
getAvgFeerate(): number {
|
||||
let count = 0;
|
||||
let total = 0;
|
||||
this.filled.forEach(slot => {
|
||||
if (slot.tx) {
|
||||
count += slot.w;
|
||||
total += (slot.tx.feerate * slot.w);
|
||||
}
|
||||
});
|
||||
return total / count;
|
||||
}
|
||||
}
|
||||
|
||||
class BlockLayout {
|
||||
width: number;
|
||||
height: number;
|
||||
rows: Row[];
|
||||
txPositions: { [key: string]: Square };
|
||||
txs: { [key: string]: TxView };
|
||||
|
||||
constructor({ width, height }: { width: number, height: number }) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.rows = [new Row(0, this.width)];
|
||||
this.txPositions = {};
|
||||
this.txs = {};
|
||||
}
|
||||
|
||||
getRow(position: Square): Row {
|
||||
return this.rows[position.y];
|
||||
}
|
||||
|
||||
getTx(position: Square): TxView | void {
|
||||
if (this.getRow(position)) {
|
||||
return this.getRow(position).txAt(position.x);
|
||||
}
|
||||
}
|
||||
|
||||
addRow(): void {
|
||||
this.rows.push(new Row(this.rows.length, this.width));
|
||||
}
|
||||
|
||||
remove(tx: TxView) {
|
||||
const position = this.txPositions[tx.txid];
|
||||
if (position) {
|
||||
for (let y = position.y; y < position.y + position.s && y < this.rows.length; y++) {
|
||||
this.rows[y].remove(position.x, position.s);
|
||||
}
|
||||
}
|
||||
delete this.txPositions[tx.txid];
|
||||
delete this.txs[tx.txid];
|
||||
}
|
||||
|
||||
insert(tx: TxView, width: number): Square {
|
||||
const fit = this.fit(tx, width);
|
||||
|
||||
// insert the tx into rows at that position
|
||||
for (let y = fit.y; y < fit.y + width; y++) {
|
||||
if (y >= this.rows.length) {
|
||||
this.addRow();
|
||||
}
|
||||
this.rows[y].insert(fit.x, width, tx);
|
||||
}
|
||||
const position = { x: fit.x, y: fit.y, s: width };
|
||||
this.txPositions[tx.txid] = position;
|
||||
this.txs[tx.txid] = tx;
|
||||
tx.applyGridPosition(position);
|
||||
return position;
|
||||
}
|
||||
|
||||
// Find the first slot large enough to hold a transaction of this size
|
||||
fit(tx: TxView, width: number): Square {
|
||||
let fit;
|
||||
for (let y = 0; y < this.rows.length && !fit; y++) {
|
||||
fit = this.findFit(0, this.width, y, y, width);
|
||||
}
|
||||
// fall back to placing tx in a new row at the top of the layout
|
||||
if (!fit) {
|
||||
fit = { x: 0, y: this.rows.length };
|
||||
}
|
||||
return fit;
|
||||
}
|
||||
|
||||
// recursively check rows to see if there's space for a tx (depth-first)
|
||||
// left/right: initial column boundaries to check
|
||||
// row: current row to check
|
||||
// start: starting row
|
||||
// size: size of space needed
|
||||
findFit(left: number, right: number, row: number, start: number, size: number): Square {
|
||||
if ((row - start) >= size || row >= this.rows.length) {
|
||||
return { x: left, y: start };
|
||||
}
|
||||
for (const slot of this.rows[row].slots) {
|
||||
const l = Math.max(left, slot.l);
|
||||
const r = Math.min(right, slot.r);
|
||||
if (r - l >= size) {
|
||||
const fit = this.findFit(l, r, row + 1, start, size);
|
||||
if (fit) {
|
||||
return fit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// insert only if the tx fits into a fee-appropriate position
|
||||
tryInsertByFee(tx: TxView, size: number): Square | void {
|
||||
const fit = this.fit(tx, size);
|
||||
|
||||
if (this.checkRowFees(fit.y, tx.feerate)) {
|
||||
// insert the tx into rows at that position
|
||||
for (let y = fit.y; y < fit.y + size; y++) {
|
||||
if (y >= this.rows.length) {
|
||||
this.addRow();
|
||||
}
|
||||
this.rows[y].insert(fit.x, size, tx);
|
||||
}
|
||||
const position = { x: fit.x, y: fit.y, s: size };
|
||||
this.txPositions[tx.txid] = position;
|
||||
this.txs[tx.txid] = tx;
|
||||
tx.applyGridPosition(position);
|
||||
return position;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the first slot with a lower feerate
|
||||
getReplacementRoot(feerate: number, width: number): Square {
|
||||
let slot;
|
||||
for (let row = 0; row <= this.rows.length; row++) {
|
||||
if (this.rows[row].slots.length > 0) {
|
||||
return { x: this.rows[row].slots[0].l, y: row };
|
||||
} else {
|
||||
slot = this.rows[row].filled.find(x => {
|
||||
return x.tx.feerate < feerate;
|
||||
});
|
||||
if (slot) {
|
||||
return { x: Math.min(slot.l, this.width - width), y: row };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { x: 0, y: this.rows.length };
|
||||
}
|
||||
|
||||
// remove and return all transactions in a subtree of the layout
|
||||
popTree(x: number, y: number, width: number) {
|
||||
const selected: { [key: string]: TxView } = {};
|
||||
let left = x;
|
||||
let right = x + width;
|
||||
let prevWidth = right - left;
|
||||
let prevFee = Infinity;
|
||||
// scan rows upwards within a channel bounded by 'left' and 'right'
|
||||
for (let row = y; row < this.rows.length; row++) {
|
||||
let rowMax = 0;
|
||||
const slots = this.rows[row].getSlotsBetween(left, right);
|
||||
// check each slot in this row overlapping the search channel
|
||||
slots.forEach(slot => {
|
||||
// select the associated transaction
|
||||
selected[slot.tx.txid] = slot.tx;
|
||||
rowMax = Math.max(rowMax, slot.tx.feerate);
|
||||
// widen the search channel to accommodate this slot if necessary
|
||||
if (slot.w > prevWidth) {
|
||||
left = slot.l;
|
||||
right = slot.r;
|
||||
// if this slot's tx has a higher feerate than the max in the previous row
|
||||
// (i.e. it's out of position)
|
||||
// select all txs overlapping the slot's full width in some rows *below*
|
||||
// to free up space for this tx to sink down to its proper position
|
||||
if (slot.tx.feerate > prevFee) {
|
||||
let count = 0;
|
||||
// keep scanning back down until we find a full row of higher-feerate txs
|
||||
for (let echo = row - 1; echo >= 0 && count < slot.w; echo--) {
|
||||
const echoSlots = this.rows[echo].getSlotsBetween(slot.l, slot.r);
|
||||
count = 0;
|
||||
echoSlots.forEach(echoSlot => {
|
||||
selected[echoSlot.tx.txid] = echoSlot.tx;
|
||||
if (echoSlot.tx.feerate >= slot.tx.feerate) {
|
||||
count += echoSlot.w;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
prevWidth = right - left;
|
||||
prevFee = rowMax;
|
||||
}
|
||||
|
||||
const txList = Object.values(selected);
|
||||
|
||||
txList.forEach(tx => {
|
||||
this.remove(tx);
|
||||
});
|
||||
return txList;
|
||||
}
|
||||
|
||||
// Check if this row has high enough avg fees
|
||||
// for a tx with this feerate to make sense here
|
||||
checkRowFees(row: number, targetFee: number): boolean {
|
||||
// first row is always fine
|
||||
if (row === 0 || !this.rows[row]) {
|
||||
return true;
|
||||
}
|
||||
return (this.rows[row].getAvgFeerate() > (targetFee * 0.9));
|
||||
}
|
||||
|
||||
// drop any free-floating transactions down into empty spaces
|
||||
applyGravity(): void {
|
||||
Object.entries(this.txPositions).sort(([keyA, posA], [keyB, posB]) => {
|
||||
return posA.y - posB.y || posA.x - posB.x;
|
||||
}).forEach(([txid, position]) => {
|
||||
// see how far this transaction can fall
|
||||
let dropTo = position.y;
|
||||
while (dropTo > 0 && !this.rows[dropTo - 1].getSlotsBetween(position.x, position.x + position.s).length) {
|
||||
dropTo--;
|
||||
}
|
||||
// if it can fall at all
|
||||
if (dropTo < position.y) {
|
||||
// remove and reinsert in the row we found
|
||||
const tx = this.txs[txid];
|
||||
this.remove(tx);
|
||||
this.insert(tx, position.s);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function feeRateDescending(a: TxView, b: TxView) {
|
||||
return b.feerate - a.feerate;
|
||||
}
|
@ -1,684 +0,0 @@
|
||||
import { FastVertexArray } from './fast-vertex-array'
|
||||
import TxView from './tx-view'
|
||||
import { TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
||||
import { Position, Square } from './sprite-types'
|
||||
|
||||
export default class BlockScene {
|
||||
scene: { count: number, offset: { x: number, y: number}};
|
||||
vertexArray: FastVertexArray;
|
||||
txs: { [key: string]: TxView };
|
||||
width: number;
|
||||
height: number;
|
||||
gridWidth: number;
|
||||
gridHeight: number;
|
||||
gridSize: number;
|
||||
vbytesPerUnit: number;
|
||||
unitPadding: number;
|
||||
unitWidth: number;
|
||||
initialised: boolean;
|
||||
layout: BlockLayout;
|
||||
dirty: boolean;
|
||||
|
||||
constructor ({ width, height, resolution, blockLimit, vertexArray }: { width: number, height: number, resolution: number, blockLimit: number, vertexArray: FastVertexArray }) {
|
||||
this.init({ width, height, resolution, blockLimit, vertexArray })
|
||||
}
|
||||
|
||||
destroy (): void {
|
||||
Object.values(this.txs).forEach(tx => tx.destroy())
|
||||
}
|
||||
|
||||
resize({ width = this.width, height = this.height }: { width?: number, height?: number}): void {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.gridSize = this.width / this.gridWidth;
|
||||
this.unitPadding = width / 500;
|
||||
this.unitWidth = this.gridSize - (this.unitPadding * 2);
|
||||
|
||||
this.dirty = true;
|
||||
if (this.initialised && this.scene) {
|
||||
this.updateAll(performance.now());
|
||||
}
|
||||
}
|
||||
|
||||
// Animate new block entering scene
|
||||
enter (txs: TransactionStripped[], direction) {
|
||||
this.replace(txs, direction)
|
||||
}
|
||||
|
||||
// Animate block leaving scene
|
||||
exit (direction: string): void {
|
||||
const startTime = performance.now()
|
||||
const removed = this.removeBatch(Object.keys(this.txs), startTime, direction)
|
||||
|
||||
// clean up sprites
|
||||
setTimeout(() => {
|
||||
removed.forEach(tx => {
|
||||
tx.destroy()
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Reset layout and replace with new set of transactions
|
||||
replace (txs: TransactionStripped[], direction: string = 'left'): void {
|
||||
const startTime = performance.now()
|
||||
const nextIds = {}
|
||||
const remove = []
|
||||
txs.forEach(tx => {
|
||||
nextIds[tx.txid] = true
|
||||
})
|
||||
Object.keys(this.txs).forEach(txid => {
|
||||
if (!nextIds[txid]) remove.push(txid)
|
||||
})
|
||||
txs.forEach(tx => {
|
||||
if (!this.txs[tx.txid]) this.txs[tx.txid] = new TxView(tx, this.vertexArray)
|
||||
})
|
||||
|
||||
const removed = this.removeBatch(remove, startTime, direction)
|
||||
|
||||
// clean up sprites
|
||||
setTimeout(() => {
|
||||
removed.forEach(tx => {
|
||||
tx.destroy()
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight })
|
||||
|
||||
Object.values(this.txs).sort((a,b) => { return b.feerate - a.feerate }).forEach(tx => {
|
||||
this.place(tx)
|
||||
})
|
||||
|
||||
this.updateAll(startTime, direction)
|
||||
}
|
||||
|
||||
update (add: TransactionStripped[], remove: string[], direction: string = 'left', resetLayout: boolean = false): void {
|
||||
const startTime = performance.now()
|
||||
const removed = this.removeBatch(remove, startTime, direction)
|
||||
|
||||
// clean up sprites
|
||||
setTimeout(() => {
|
||||
removed.forEach(tx => {
|
||||
tx.destroy()
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
if (resetLayout) {
|
||||
add.forEach(tx => {
|
||||
if (!this.txs[tx.txid]) this.txs[tx.txid] = new TxView(tx, this.vertexArray)
|
||||
})
|
||||
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight })
|
||||
Object.values(this.txs).sort((a,b) => { return b.feerate - a.feerate }).forEach(tx => {
|
||||
this.place(tx)
|
||||
})
|
||||
} else {
|
||||
// try to insert new txs directly
|
||||
const remaining = []
|
||||
add.map(tx => new TxView(tx, this.vertexArray)).sort((a,b) => { return b.feerate - a.feerate }).forEach(tx => {
|
||||
if (!this.tryInsertByFee(tx)) {
|
||||
remaining.push(tx)
|
||||
}
|
||||
})
|
||||
this.placeBatch(remaining)
|
||||
this.layout.applyGravity()
|
||||
}
|
||||
|
||||
this.updateAll(startTime, direction)
|
||||
}
|
||||
|
||||
//return the tx at this screen position, if any
|
||||
getTxAt (position: Position): TxView | void {
|
||||
if (this.layout) {
|
||||
const gridPosition = this.screenToGrid(position)
|
||||
return this.layout.getTx(gridPosition)
|
||||
} else return null
|
||||
}
|
||||
|
||||
private init ({ width, height, resolution, blockLimit, vertexArray }: { width: number, height: number, resolution: number, blockLimit: number, vertexArray: FastVertexArray }): void {
|
||||
this.vertexArray = vertexArray
|
||||
|
||||
this.scene = {
|
||||
count: 0,
|
||||
offset: {
|
||||
x: 0,
|
||||
y: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Set the scale of the visualization (with a 5% margin)
|
||||
this.vbytesPerUnit = blockLimit / Math.pow(resolution / 1.05, 2)
|
||||
this.gridWidth = resolution
|
||||
this.gridHeight = resolution
|
||||
this.resize({ width, height })
|
||||
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight })
|
||||
|
||||
this.txs = {}
|
||||
|
||||
this.initialised = true
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
private insert (tx: TxView, startTime: number, direction: string = 'left'): void {
|
||||
this.txs[tx.txid] = tx
|
||||
this.place(tx)
|
||||
this.updateTx(tx, startTime, direction)
|
||||
}
|
||||
|
||||
private updateTx (tx: TxView, startTime: number, direction: string = 'left'): void {
|
||||
if (tx.dirty || this.dirty) {
|
||||
this.saveGridToScreenPosition(tx)
|
||||
this.setTxOnScreen(tx, startTime, direction)
|
||||
}
|
||||
}
|
||||
|
||||
private setTxOnScreen (tx: TxView, startTime: number, direction: string = 'left'): void {
|
||||
if (!tx.initialised) {
|
||||
const txColor = tx.getColor()
|
||||
tx.update({
|
||||
display: {
|
||||
position: {
|
||||
x: tx.screenPosition.x + (direction == 'right' ? -this.width : this.width) * 1.4,
|
||||
y: tx.screenPosition.y,
|
||||
s: tx.screenPosition.s
|
||||
},
|
||||
color: txColor,
|
||||
},
|
||||
start: startTime,
|
||||
delay: 0,
|
||||
})
|
||||
tx.update({
|
||||
display: {
|
||||
position: tx.screenPosition,
|
||||
color: txColor
|
||||
},
|
||||
duration: 1000,
|
||||
start: startTime,
|
||||
delay: 50,
|
||||
})
|
||||
} else {
|
||||
tx.update({
|
||||
display: {
|
||||
position: tx.screenPosition
|
||||
},
|
||||
duration: 1000,
|
||||
minDuration: 500,
|
||||
start: startTime,
|
||||
delay: 50,
|
||||
adjust: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private updateAll (startTime: number, direction: string = 'left'): void {
|
||||
this.scene.count = 0
|
||||
const ids = this.getTxList()
|
||||
startTime = startTime || performance.now()
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
this.updateTx(this.txs[ids[i]], startTime, direction)
|
||||
}
|
||||
this.dirty = false
|
||||
}
|
||||
|
||||
private remove (id: string, startTime: number, direction: string = 'left'): TxView | void {
|
||||
const tx = this.txs[id]
|
||||
if (tx) {
|
||||
this.layout.remove(tx)
|
||||
tx.update({
|
||||
display: {
|
||||
position: {
|
||||
x: tx.screenPosition.x + (direction == 'right' ? this.width : -this.width) * 1.4,
|
||||
y: this.txs[id].screenPosition.y,
|
||||
}
|
||||
},
|
||||
duration: 1000,
|
||||
start: startTime,
|
||||
delay: 50
|
||||
})
|
||||
}
|
||||
delete this.txs[id]
|
||||
return tx
|
||||
}
|
||||
|
||||
private getTxList (): string[] {
|
||||
return Object.keys(this.txs)
|
||||
}
|
||||
|
||||
private saveGridToScreenPosition (tx: TxView): void {
|
||||
tx.screenPosition = this.gridToScreen(tx.gridPosition)
|
||||
}
|
||||
|
||||
// convert grid coordinates to screen coordinates
|
||||
private gridToScreen (position: Square | void): Square {
|
||||
if (position) {
|
||||
const slotSize = (position.s * this.gridSize)
|
||||
const squareSize = slotSize - (this.unitPadding * 2)
|
||||
|
||||
// The grid is laid out notionally left-to-right, bottom-to-top
|
||||
// So we rotate 90deg counterclockwise then flip the y axis
|
||||
//
|
||||
// grid screen
|
||||
// ________ ________ ________
|
||||
// | | | b| | a|
|
||||
// | | rotate | | flip | c |
|
||||
// | c | --> | c | --> | |
|
||||
// |a______b| |_______a| |_______b|
|
||||
return {
|
||||
x: this.width + (this.unitPadding * 2) - (this.gridSize * position.y) - slotSize,
|
||||
y: this.height - ((this.gridSize * position.x) + (slotSize - this.unitPadding)),
|
||||
s: squareSize
|
||||
}
|
||||
} else {
|
||||
return { x: 0, y: 0, s: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
screenToGrid (position: Position): Position {
|
||||
const grid = {
|
||||
x: Math.floor((position.y - this.unitPadding) / this.gridSize),
|
||||
y: Math.floor((this.width + (this.unitPadding * 2) - position.x) / this.gridSize)
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
// calculates and returns the size of the tx in multiples of the grid size
|
||||
private txSize (tx: TxView): number {
|
||||
let scale = Math.max(1,Math.round(Math.sqrt(tx.vsize / this.vbytesPerUnit)))
|
||||
return Math.min(this.gridWidth, Math.max(1, scale)) // bound between 1 and the max displayable size (just in case!)
|
||||
}
|
||||
|
||||
private place (tx: TxView): void {
|
||||
const size = this.txSize(tx)
|
||||
this.layout.insert(tx, size)
|
||||
}
|
||||
|
||||
private tryInsertByFee (tx: TxView): boolean {
|
||||
const size = this.txSize(tx)
|
||||
const position = this.layout.tryInsertByFee(tx, size)
|
||||
if (position) {
|
||||
this.txs[tx.txid] = tx
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Add a list of transactions to the layout,
|
||||
// keeping everything approximately sorted by feerate.
|
||||
private placeBatch (txs: TxView[]): void {
|
||||
if (txs.length) {
|
||||
// grab the new tx with the highest fee rate
|
||||
txs = txs.sort((a,b) => { return b.feerate - a.feerate })
|
||||
let i = 0
|
||||
let maxSize = txs.reduce((max, tx) => {
|
||||
return Math.max(this.txSize(tx), max)
|
||||
}, 1) * 2
|
||||
|
||||
// find a reasonable place for it in the layout
|
||||
const root = this.layout.getReplacementRoot(txs[0].feerate, maxSize)
|
||||
|
||||
// extract a sub tree of transactions from the layout, rooted at that point
|
||||
const popped = this.layout.popTree(root.x, root.y, maxSize)
|
||||
// combine those with the new transactions and sort
|
||||
txs = txs.concat(popped)
|
||||
txs = txs.sort((a,b) => { return b.feerate - a.feerate })
|
||||
|
||||
// insert everything back into the layout
|
||||
txs.forEach(tx => {
|
||||
this.txs[tx.txid] = tx
|
||||
this.place(tx)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private removeBatch (ids: string[], startTime: number, direction: string = 'left'): TxView[] {
|
||||
if (!startTime) startTime = performance.now()
|
||||
return ids.map(id => {
|
||||
return this.remove(id, startTime, direction)
|
||||
}).filter(tx => tx != null) as TxView[]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Slot {
|
||||
l: number
|
||||
r: number
|
||||
w: number
|
||||
|
||||
constructor (l: number, r: number) {
|
||||
this.l = l
|
||||
this.r = r
|
||||
this.w = r - l
|
||||
}
|
||||
|
||||
intersects (slot: Slot): boolean {
|
||||
return !((slot.r <= this.l) || (slot.l >= this.r))
|
||||
}
|
||||
|
||||
subtract (slot: Slot): Slot[] | void {
|
||||
if (this.intersects(slot)) {
|
||||
// from middle
|
||||
if (slot.l > this.l && slot.r < this.r) {
|
||||
return [
|
||||
new Slot(this.l, slot.l),
|
||||
new Slot(slot.r, this.r)
|
||||
]
|
||||
} // totally covered
|
||||
else if (slot.l <= this.l && slot.r >= this.r) {
|
||||
return []
|
||||
} // from left side
|
||||
else if (slot.l <= this.l) {
|
||||
if (slot.r == this.r) return []
|
||||
else return [new Slot(slot.r, this.r)]
|
||||
} // from right side
|
||||
else if (slot.r >= this.r) {
|
||||
if (slot.l == this.l) return []
|
||||
else return [new Slot(this.l, slot.l)]
|
||||
}
|
||||
} else return [this]
|
||||
}
|
||||
}
|
||||
|
||||
class TxSlot extends Slot {
|
||||
tx: TxView
|
||||
|
||||
constructor (l: number, r: number, tx: TxView) {
|
||||
super(l, r)
|
||||
this.tx = tx
|
||||
}
|
||||
}
|
||||
|
||||
class Row {
|
||||
y: number
|
||||
w: number
|
||||
filled: TxSlot[]
|
||||
slots: Slot[]
|
||||
|
||||
|
||||
constructor (y: number, width: number) {
|
||||
this.y = y
|
||||
this.w = width
|
||||
this.filled = []
|
||||
this.slots = [new Slot(0, this.w)]
|
||||
}
|
||||
|
||||
// insert a transaction w/ given width into row starting at position x
|
||||
insert (x: number, w: number, tx: TxView): void {
|
||||
const newSlot = new TxSlot(x, x + w, tx)
|
||||
// insert into filled list
|
||||
let index = this.filled.findIndex((slot) => { return slot.l >= newSlot.r })
|
||||
if (index < 0) index = this.filled.length
|
||||
this.filled.splice(index || 0, 0, newSlot)
|
||||
// subtract from overlapping slots
|
||||
for (let i = 0; i < this.slots.length; i++) {
|
||||
if (newSlot.intersects(this.slots[i])) {
|
||||
const diff = this.slots[i].subtract(newSlot)
|
||||
if (diff) {
|
||||
this.slots.splice(i, 1, ...diff)
|
||||
i += diff.length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove (x: number, w: number): void {
|
||||
const txIndex = this.filled.findIndex((slot) => { return slot.l == x })
|
||||
this.filled.splice(txIndex, 1)
|
||||
|
||||
const newSlot = new Slot(x, x + w)
|
||||
let slotIndex = this.slots.findIndex((slot) => { return slot.l >= newSlot.r })
|
||||
if (slotIndex < 0) slotIndex = this.slots.length
|
||||
this.slots.splice(slotIndex || 0, 0, newSlot)
|
||||
this.normalize()
|
||||
}
|
||||
|
||||
// merge any contiguous empty slots
|
||||
private normalize (): void {
|
||||
for (let i = 0; i < this.slots.length - 1; i++) {
|
||||
if (this.slots[i].r == this.slots[i+1].l) {
|
||||
this.slots[i].r = this.slots[i+1].r
|
||||
this.slots[i].w += this.slots[i+1].w
|
||||
this.slots.splice(i+1, 1)
|
||||
i--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
txAt (x: number): TxView | void {
|
||||
let i = 0
|
||||
while (i < this.filled.length && this.filled[i].l <= x) {
|
||||
if (this.filled[i].l <= x && this.filled[i].r > x) return this.filled[i].tx
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
getSlotsBetween (left: number, right: number): TxSlot[] {
|
||||
const range = new Slot(left, right)
|
||||
return this.filled.filter(slot => {
|
||||
return slot.intersects(range)
|
||||
})
|
||||
}
|
||||
|
||||
slotAt (x: number): Slot | void {
|
||||
let i = 0
|
||||
while (i < this.slots.length && this.slots[i].l <= x) {
|
||||
if (this.slots[i].l <= x && this.slots[i].r > x) return this.slots[i]
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
getAvgFeerate (): number {
|
||||
let count = 0
|
||||
let total = 0
|
||||
this.filled.forEach(slot => {
|
||||
if (slot.tx) {
|
||||
count += slot.w
|
||||
total += (slot.tx.feerate * slot.w)
|
||||
}
|
||||
})
|
||||
return total / count
|
||||
}
|
||||
}
|
||||
|
||||
class BlockLayout {
|
||||
width: number;
|
||||
height: number;
|
||||
rows: Row[];
|
||||
txPositions: { [key: string]: Square }
|
||||
txs: { [key: string]: TxView }
|
||||
|
||||
constructor ({ width, height } : { width: number, height: number }) {
|
||||
this.width = width
|
||||
this.height = height
|
||||
this.rows = [new Row(0, this.width)]
|
||||
this.txPositions = {}
|
||||
this.txs = {}
|
||||
}
|
||||
|
||||
getRow (position: Square): Row {
|
||||
return this.rows[position.y]
|
||||
}
|
||||
|
||||
getTx (position: Square): TxView | void {
|
||||
if (this.getRow(position)) {
|
||||
return this.getRow(position).txAt(position.x)
|
||||
}
|
||||
}
|
||||
|
||||
addRow (): void {
|
||||
this.rows.push(new Row(this.rows.length, this.width))
|
||||
}
|
||||
|
||||
remove (tx: TxView) {
|
||||
const position = this.txPositions[tx.txid]
|
||||
if (position) {
|
||||
for (let y = position.y; y < position.y + position.s && y < this.rows.length; y++) {
|
||||
this.rows[y].remove(position.x, position.s)
|
||||
}
|
||||
}
|
||||
delete this.txPositions[tx.txid]
|
||||
delete this.txs[tx.txid]
|
||||
}
|
||||
|
||||
insert (tx: TxView, width: number): Square {
|
||||
const fit = this.fit(tx, width)
|
||||
|
||||
// insert the tx into rows at that position
|
||||
for (let y = fit.y; y < fit.y + width; y++) {
|
||||
if (y >= this.rows.length) this.addRow()
|
||||
this.rows[y].insert(fit.x, width, tx)
|
||||
}
|
||||
const position = { x: fit.x, y: fit.y, s: width }
|
||||
this.txPositions[tx.txid] = position
|
||||
this.txs[tx.txid] = tx
|
||||
tx.applyGridPosition(position)
|
||||
return position
|
||||
}
|
||||
|
||||
// Find the first slot large enough to hold a transaction of this size
|
||||
fit (tx: TxView, width: number): Square {
|
||||
let fit
|
||||
for (let y = 0; y < this.rows.length && !fit; y++) {
|
||||
fit = this.findFit(0, this.width, y, y, width)
|
||||
}
|
||||
// fall back to placing tx in a new row at the top of the layout
|
||||
if (!fit) {
|
||||
fit = { x: 0, y: this.rows.length }
|
||||
}
|
||||
return fit
|
||||
}
|
||||
|
||||
// recursively check rows to see if there's space for a tx (depth-first)
|
||||
// left/right: initial column boundaries to check
|
||||
// row: current row to check
|
||||
// start: starting row
|
||||
// size: size of space needed
|
||||
findFit (left: number, right: number, row: number, start: number, size: number) : Square {
|
||||
if ((row - start) >= size || row >= this.rows.length) {
|
||||
return { x: left, y: start }
|
||||
}
|
||||
for (let i = 0; i < this.rows[row].slots.length; i++) {
|
||||
const slot = this.rows[row].slots[i]
|
||||
const l = Math.max(left, slot.l)
|
||||
const r = Math.min(right, slot.r)
|
||||
if (r - l >= size) {
|
||||
const fit = this.findFit(l, r, row + 1, start, size)
|
||||
if (fit) return fit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// insert only if the tx fits into a fee-appropriate position
|
||||
tryInsertByFee (tx: TxView, size: number): Square | void {
|
||||
const fit = this.fit(tx, size)
|
||||
|
||||
if (this.checkRowFees(fit.y, tx.feerate)) {
|
||||
// insert the tx into rows at that position
|
||||
for (let y = fit.y; y < fit.y + size; y++) {
|
||||
if (y >= this.rows.length) this.addRow()
|
||||
this.rows[y].insert(fit.x, size, tx)
|
||||
}
|
||||
const position = { x: fit.x, y: fit.y, s: size }
|
||||
this.txPositions[tx.txid] = position
|
||||
this.txs[tx.txid] = tx
|
||||
tx.applyGridPosition(position)
|
||||
return position
|
||||
}
|
||||
}
|
||||
|
||||
// Return the first slot with a lower feerate
|
||||
getReplacementRoot (feerate: number, width: number): Square {
|
||||
let slot
|
||||
for (let row = 0; row <= this.rows.length; row++) {
|
||||
if (this.rows[row].slots.length > 0) {
|
||||
return { x: this.rows[row].slots[0].l, y: row }
|
||||
} else {
|
||||
slot = this.rows[row].filled.find(x => {
|
||||
return x.tx.feerate < feerate
|
||||
})
|
||||
if (slot) {
|
||||
return { x: Math.min(slot.l, this.width - width), y: row }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { x: 0, y: this.rows.length }
|
||||
}
|
||||
|
||||
// remove and return all transactions in a subtree of the layout
|
||||
popTree (x: number, y: number, width: number) {
|
||||
const selected: { [key: string]: TxView } = {}
|
||||
let left = x
|
||||
let right = x + width
|
||||
let prevWidth = right - left
|
||||
let prevFee = Infinity
|
||||
// scan rows upwards within a channel bounded by 'left' and 'right'
|
||||
for (let row = y; row < this.rows.length; row++) {
|
||||
let rowMax = 0
|
||||
let slots = this.rows[row].getSlotsBetween(left, right)
|
||||
// check each slot in this row overlapping the search channel
|
||||
slots.forEach(slot => {
|
||||
// select the associated transaction
|
||||
selected[slot.tx.txid] = slot.tx
|
||||
rowMax = Math.max(rowMax, slot.tx.feerate)
|
||||
// widen the search channel to accommodate this slot if necessary
|
||||
if (slot.w > prevWidth) {
|
||||
left = slot.l
|
||||
right = slot.r
|
||||
// if this slot's tx has a higher feerate than the max in the previous row
|
||||
// (i.e. it's out of position)
|
||||
// select all txs overlapping the slot's full width in some rows *below*
|
||||
// to free up space for this tx to sink down to its proper position
|
||||
if (slot.tx.feerate > prevFee) {
|
||||
let count = 0
|
||||
// keep scanning back down until we find a full row of higher-feerate txs
|
||||
for (let echo = row - 1; echo >= 0 && count < slot.w; echo--) {
|
||||
let echoSlots = this.rows[echo].getSlotsBetween(slot.l, slot.r)
|
||||
count = 0
|
||||
echoSlots.forEach(echoSlot => {
|
||||
selected[echoSlot.tx.txid] = echoSlot.tx
|
||||
if (echoSlot.tx.feerate >= slot.tx.feerate) {
|
||||
count += echoSlot.w
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
prevWidth = right - left
|
||||
prevFee = rowMax
|
||||
}
|
||||
|
||||
const txList = Object.values(selected)
|
||||
|
||||
txList.forEach(tx => {
|
||||
this.remove(tx)
|
||||
})
|
||||
return txList
|
||||
}
|
||||
|
||||
// Check if this row has high enough avg fees
|
||||
// for a tx with this feerate to make sense here
|
||||
checkRowFees (row: number, targetFee: number): boolean {
|
||||
// first row is always fine
|
||||
if (row == 0 || !this.rows[row]) return true
|
||||
return (this.rows[row].getAvgFeerate() > (targetFee * 0.9))
|
||||
}
|
||||
|
||||
// drop any free-floating transactions down into empty spaces
|
||||
applyGravity (): void {
|
||||
Object.entries(this.txPositions).sort(([keyA, posA], [keyB, posB]) => {
|
||||
return posA.y - posB.y || posA.x - posB.x
|
||||
}).forEach(([txid, position]) => {
|
||||
// see how far this transaction can fall
|
||||
let dropTo = position.y
|
||||
while (dropTo > 0 && !this.rows[dropTo - 1].getSlotsBetween(position.x, position.x + position.s).length) {
|
||||
dropTo--;
|
||||
}
|
||||
// if it can fall at all
|
||||
if (dropTo < position.y) {
|
||||
// remove and reinsert in the row we found
|
||||
const tx = this.txs[txid]
|
||||
this.remove(tx)
|
||||
this.insert(tx, position.s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
<div class="mempool-block-overview">
|
||||
<canvas class="block-overview" [style.width]="cssWidth + 'px'" [style.height]="cssHeight + 'px'" #blockCanvas></canvas>
|
||||
<div class="loader-wrapper" [class.hidden]="!(isLoading$ | async)">
|
||||
<div class="spinner-border ml-3 loading" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
<app-block-overview-graph
|
||||
#blockGraph
|
||||
[isLoading]="isLoading$ | async"
|
||||
[resolution]="75"
|
||||
[blockLimit]="stateService.blockVSize"
|
||||
(txPreviewEvent)="onTxPreview($event)">
|
||||
</app-block-overview-graph>
|
||||
|
@ -1,40 +1,23 @@
|
||||
import { Component, ElementRef, ViewChild, HostListener, Input, Output, EventEmitter, OnInit,
|
||||
OnDestroy, OnChanges, ChangeDetectionStrategy, NgZone, AfterViewInit } from '@angular/core';
|
||||
import { Component, ComponentRef, ViewChild, HostListener, Input, Output, EventEmitter,
|
||||
OnDestroy, OnChanges, ChangeDetectionStrategy, AfterViewInit } from '@angular/core';
|
||||
import { StateService } from 'src/app/services/state.service';
|
||||
import { MempoolBlockDelta, TransactionStripped } from 'src/app/interfaces/websocket.interface';
|
||||
import { BlockOverviewGraphComponent } from 'src/app/components/block-overview-graph/block-overview-graph.component';
|
||||
import { Subscription, BehaviorSubject, merge, of } from 'rxjs';
|
||||
import { switchMap, filter } from 'rxjs/operators';
|
||||
import { WebsocketService } from 'src/app/services/websocket.service';
|
||||
import { FastVertexArray } from './fast-vertex-array';
|
||||
import BlockScene from './block-scene';
|
||||
import TxSprite from './tx-sprite';
|
||||
import TxView from './tx-view';
|
||||
|
||||
@Component({
|
||||
selector: 'app-mempool-block-overview',
|
||||
templateUrl: './mempool-block-overview.component.html',
|
||||
styleUrls: ['./mempool-block-overview.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit {
|
||||
export class MempoolBlockOverviewComponent implements OnDestroy, OnChanges, AfterViewInit {
|
||||
@Input() index: number;
|
||||
@Output() txPreviewEvent = new EventEmitter<TransactionStripped | void>();
|
||||
|
||||
@ViewChild('blockCanvas')
|
||||
canvas: ElementRef<HTMLCanvasElement>;
|
||||
@ViewChild('blockGraph') blockGraph: BlockOverviewGraphComponent;
|
||||
|
||||
gl: WebGLRenderingContext;
|
||||
animationFrameRequest: number;
|
||||
displayWidth: number;
|
||||
displayHeight: number;
|
||||
cssWidth: number;
|
||||
cssHeight: number;
|
||||
shaderProgram: WebGLProgram;
|
||||
vertexArray: FastVertexArray;
|
||||
running: boolean;
|
||||
scene: BlockScene;
|
||||
hoverTx: TxView | void;
|
||||
selectedTx: TxView | void;
|
||||
lastBlockHeight: number;
|
||||
blockIndex: number;
|
||||
isLoading$ = new BehaviorSubject<boolean>(true);
|
||||
@ -44,13 +27,10 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
||||
|
||||
constructor(
|
||||
public stateService: StateService,
|
||||
private websocketService: WebsocketService,
|
||||
readonly ngZone: NgZone,
|
||||
) {
|
||||
this.vertexArray = new FastVertexArray(512, TxSprite.dataSize);
|
||||
}
|
||||
private websocketService: WebsocketService
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
ngAfterViewInit(): void {
|
||||
this.blockSub = merge(
|
||||
of(true),
|
||||
this.stateService.connectionState$.pipe(filter((state) => state === 2))
|
||||
@ -64,18 +44,11 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.canvas.nativeElement.addEventListener('webglcontextlost', this.handleContextLost, false);
|
||||
this.canvas.nativeElement.addEventListener('webglcontextrestored', this.handleContextRestored, false);
|
||||
this.gl = this.canvas.nativeElement.getContext('webgl');
|
||||
this.initCanvas();
|
||||
|
||||
this.resizeCanvas();
|
||||
}
|
||||
|
||||
ngOnChanges(changes): void {
|
||||
if (changes.index) {
|
||||
this.clearBlock(changes.index.currentValue > changes.index.previousValue ? 'right' : 'left');
|
||||
if (this.blockGraph) {
|
||||
this.blockGraph.clear(changes.index.currentValue > changes.index.previousValue ? 'right' : 'left');
|
||||
}
|
||||
this.isLoading$.next(true);
|
||||
this.websocketService.startTrackMempoolBlock(changes.index.currentValue);
|
||||
}
|
||||
@ -87,26 +60,13 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
||||
this.websocketService.stopTrackMempoolBlock();
|
||||
}
|
||||
|
||||
clearBlock(direction): void {
|
||||
if (this.scene) {
|
||||
this.scene.exit(direction);
|
||||
}
|
||||
this.hoverTx = null;
|
||||
this.selectedTx = null;
|
||||
this.txPreviewEvent.emit(null);
|
||||
}
|
||||
|
||||
replaceBlock(transactionsStripped: TransactionStripped[]): void {
|
||||
if (!this.scene) {
|
||||
this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: 75,
|
||||
blockLimit: this.stateService.blockVSize, vertexArray: this.vertexArray });
|
||||
}
|
||||
const blockMined = (this.stateService.latestBlockHeight > this.lastBlockHeight);
|
||||
if (this.blockIndex !== this.index) {
|
||||
const direction = (this.blockIndex == null || this.index < this.blockIndex) ? 'left' : 'right';
|
||||
this.scene.enter(transactionsStripped, direction);
|
||||
this.blockGraph.enter(transactionsStripped, direction);
|
||||
} else {
|
||||
this.scene.replace(transactionsStripped, blockMined ? 'right' : 'left');
|
||||
this.blockGraph.replace(transactionsStripped, blockMined ? 'right' : 'left');
|
||||
}
|
||||
|
||||
this.lastBlockHeight = this.stateService.latestBlockHeight;
|
||||
@ -115,20 +75,13 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
||||
}
|
||||
|
||||
updateBlock(delta: MempoolBlockDelta): void {
|
||||
if (!this.scene) {
|
||||
this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: 75,
|
||||
blockLimit: this.stateService.blockVSize, vertexArray: this.vertexArray });
|
||||
}
|
||||
const blockMined = (this.stateService.latestBlockHeight > this.lastBlockHeight);
|
||||
|
||||
if (this.blockIndex !== this.index) {
|
||||
const direction = (this.blockIndex == null || this.index < this.blockIndex) ? 'left' : 'right';
|
||||
this.scene.exit(direction);
|
||||
this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: 75,
|
||||
blockLimit: this.stateService.blockVSize, vertexArray: this.vertexArray });
|
||||
this.scene.enter(delta.added, direction);
|
||||
this.blockGraph.replace(delta.added, direction);
|
||||
} else {
|
||||
this.scene.update(delta.added, delta.removed, blockMined ? 'right' : 'left', blockMined);
|
||||
this.blockGraph.update(delta.added, delta.removed, blockMined ? 'right' : 'left', blockMined);
|
||||
}
|
||||
|
||||
this.lastBlockHeight = this.stateService.latestBlockHeight;
|
||||
@ -136,279 +89,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
||||
this.isLoading$.next(false);
|
||||
}
|
||||
|
||||
initCanvas(): void {
|
||||
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;
|
||||
}
|
||||
|
||||
handleContextRestored(event): void {
|
||||
this.initCanvas();
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
resizeCanvas(): void {
|
||||
this.cssWidth = this.canvas.nativeElement.parentElement.clientWidth;
|
||||
this.cssHeight = this.canvas.nativeElement.parentElement.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);
|
||||
}
|
||||
if (this.scene) {
|
||||
this.scene.resize({ width: this.displayWidth, height: this.displayHeight });
|
||||
}
|
||||
}
|
||||
|
||||
compileShader(src, type): WebGLShader {
|
||||
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 {
|
||||
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.run());
|
||||
}
|
||||
|
||||
run(now?: DOMHighResTimeStamp): void {
|
||||
if (!now) {
|
||||
now = performance.now();
|
||||
}
|
||||
|
||||
/* 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);
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* LOOP */
|
||||
if (this.running) {
|
||||
if (this.animationFrameRequest) {
|
||||
cancelAnimationFrame(this.animationFrameRequest);
|
||||
this.animationFrameRequest = null;
|
||||
}
|
||||
this.animationFrameRequest = requestAnimationFrame(() => this.run());
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('click', ['$event'])
|
||||
onClick(event) {
|
||||
this.setPreviewTx(event.offsetX, event.offsetY, true);
|
||||
}
|
||||
|
||||
@HostListener('pointermove', ['$event'])
|
||||
onPointerMove(event) {
|
||||
this.setPreviewTx(event.offsetX, event.offsetY, false);
|
||||
}
|
||||
|
||||
@HostListener('pointerleave', ['$event'])
|
||||
onPointerLeave(event) {
|
||||
this.setPreviewTx(-1, -1, false);
|
||||
}
|
||||
|
||||
setPreviewTx(cssX: number, cssY: number, clicked: boolean = false) {
|
||||
const x = cssX * window.devicePixelRatio;
|
||||
const y = cssY * window.devicePixelRatio;
|
||||
if (this.scene && (!this.selectedTx || clicked)) {
|
||||
const selected = this.scene.getTxAt({ x, y });
|
||||
const currentPreview = this.selectedTx || this.hoverTx;
|
||||
|
||||
if (selected !== currentPreview) {
|
||||
if (currentPreview) {
|
||||
currentPreview.setHover(false);
|
||||
}
|
||||
if (selected) {
|
||||
selected.setHover(true);
|
||||
this.txPreviewEvent.emit({
|
||||
txid: selected.txid,
|
||||
fee: selected.fee,
|
||||
vsize: selected.vsize,
|
||||
value: selected.value
|
||||
});
|
||||
if (clicked) {
|
||||
this.selectedTx = selected;
|
||||
} else {
|
||||
this.hoverTx = selected;
|
||||
}
|
||||
} else {
|
||||
if (clicked) {
|
||||
this.selectedTx = null;
|
||||
}
|
||||
this.hoverTx = null;
|
||||
this.txPreviewEvent.emit(null);
|
||||
}
|
||||
} else if (clicked) {
|
||||
if (selected === this.selectedTx) {
|
||||
this.hoverTx = this.selectedTx;
|
||||
this.selectedTx = null;
|
||||
} else {
|
||||
this.selectedTx = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
onTxPreview(event: TransactionStripped | void): void {
|
||||
this.txPreviewEvent.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
`;
|
||||
|
@ -81,12 +81,12 @@ export class MempoolBlockComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
setTxPreview(event: TransactionStripped | void): void {
|
||||
this.previewTx = event
|
||||
this.previewTx = event;
|
||||
}
|
||||
}
|
||||
|
||||
function detectWebGL () {
|
||||
const canvas = document.createElement("canvas");
|
||||
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
||||
return (gl && gl instanceof WebGLRenderingContext)
|
||||
function detectWebGL() {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
return (gl && gl instanceof WebGLRenderingContext);
|
||||
}
|
||||
|
@ -45,6 +45,7 @@ import { StartComponent } from '../components/start/start.component';
|
||||
import { TransactionComponent } from '../components/transaction/transaction.component';
|
||||
import { TransactionsListComponent } from '../components/transactions-list/transactions-list.component';
|
||||
import { BlockComponent } from '../components/block/block.component';
|
||||
import { BlockOverviewGraphComponent } from '../components/block-overview-graph/block-overview-graph.component';
|
||||
import { AddressComponent } from '../components/address/address.component';
|
||||
import { SearchFormComponent } from '../components/search-form/search-form.component';
|
||||
import { AddressLabelsComponent } from '../components/address-labels/address-labels.component';
|
||||
@ -110,6 +111,7 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
|
||||
StartComponent,
|
||||
TransactionComponent,
|
||||
BlockComponent,
|
||||
BlockOverviewGraphComponent,
|
||||
TransactionsListComponent,
|
||||
AddressComponent,
|
||||
SearchFormComponent,
|
||||
@ -203,6 +205,7 @@ import { SvgImagesComponent } from '../components/svg-images/svg-images.componen
|
||||
StartComponent,
|
||||
TransactionComponent,
|
||||
BlockComponent,
|
||||
BlockOverviewGraphComponent,
|
||||
TransactionsListComponent,
|
||||
AddressComponent,
|
||||
SearchFormComponent,
|
||||
|
Loading…
x
Reference in New Issue
Block a user