Extract canvas/webgl code to separate component

This commit is contained in:
Mononaut
2022-06-14 00:33:48 +00:00
parent c5bcf76353
commit 225decd286
13 changed files with 1128 additions and 1033 deletions

View File

@@ -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>

View File

@@ -0,0 +1,39 @@
.block-overview-graph {
position: relative;
width: 100%;
padding-bottom: 100%;
background: #181b2d;
display: flex;
justify-content: center;
align-items: center;
}
.block-overview-canvas {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.loader-wrapper {
position: absolute;
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;
transition: opacity 500ms;
}
}

View File

@@ -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;
}
`;

View 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;
}

View File

@@ -0,0 +1,105 @@
/*
Utility class for access and management of low-level sprite data
Maintains a single Float32Array of sprite data, keeping track of empty slots
to allow constant-time insertion and deletion
Automatically resizes by copying to a new, larger Float32Array when necessary,
or compacting into a smaller Float32Array when there's space to do so.
*/
import TxSprite from './tx-sprite';
export class FastVertexArray {
length: number;
count: number;
stride: number;
sprites: TxSprite[];
data: Float32Array;
freeSlots: number[];
lastSlot: number;
constructor(length, stride) {
this.length = length;
this.count = 0;
this.stride = stride;
this.sprites = [];
this.data = new Float32Array(this.length * this.stride);
this.freeSlots = [];
this.lastSlot = 0;
}
insert(sprite: TxSprite): number {
this.count++;
let position;
if (this.freeSlots.length) {
position = this.freeSlots.shift();
} else {
position = this.lastSlot;
this.lastSlot++;
if (this.lastSlot > this.length) {
this.expand();
}
}
this.sprites[position] = sprite;
return position;
}
remove(index: number): void {
this.count--;
this.clearData(index);
this.freeSlots.push(index);
this.sprites[index] = null;
if (this.length > 2048 && this.count < (this.length * 0.4)) {
this.compact();
}
}
setData(index: number, dataChunk: number[]): void {
this.data.set(dataChunk, (index * this.stride));
}
clearData(index: number): void {
this.data.fill(0, (index * this.stride), ((index + 1) * this.stride));
}
getData(index: number): Float32Array {
return this.data.subarray(index, this.stride);
}
expand(): void {
this.length *= 2;
const newData = new Float32Array(this.length * this.stride);
newData.set(this.data);
this.data = newData;
}
compact(): void {
// New array length is the smallest power of 2 larger than the sprite count (but no smaller than 512)
const newLength = Math.max(512, Math.pow(2, Math.ceil(Math.log2(this.count))));
if (newLength !== this.length) {
this.length = newLength;
this.data = new Float32Array(this.length * this.stride);
let sprite;
const newSprites = [];
let i = 0;
for (const index in this.sprites) {
sprite = this.sprites[index];
if (sprite) {
newSprites.push(sprite);
sprite.moveVertexPointer(i);
sprite.compile();
i++;
}
}
this.sprites = newSprites;
this.freeSlots = [];
this.lastSlot = i;
}
}
getVertexData(): Float32Array {
return this.data;
}
}

View File

@@ -0,0 +1,74 @@
export type Position = {
x: number,
y: number,
};
export type Square = Position & {
s?: number
};
export type Color = {
r: number,
g: number,
b: number,
a: number,
};
export type InterpolatedAttribute = {
a: number,
b: number,
t: number,
v: number,
d: number
};
export type Update = Position & { s: number } & Color;
export type Attributes = {
x: InterpolatedAttribute,
y: InterpolatedAttribute,
s: InterpolatedAttribute,
r: InterpolatedAttribute,
g: InterpolatedAttribute,
b: InterpolatedAttribute,
a: InterpolatedAttribute
};
export type OptionalAttributes = {
x?: InterpolatedAttribute,
y?: InterpolatedAttribute,
s?: InterpolatedAttribute,
r?: InterpolatedAttribute,
g?: InterpolatedAttribute,
b?: InterpolatedAttribute,
a?: InterpolatedAttribute
};
export type SpriteUpdateParams = {
x?: number,
y?: number,
s?: number,
r?: number,
g?: number,
b?: number,
a?: number
start?: DOMHighResTimeStamp,
duration?: number,
minDuration?: number,
adjust?: boolean,
temp?: boolean
};
export type ViewUpdateParams = {
display: {
position?: Square,
color?: Color,
},
start?: number,
duration?: number,
minDuration?: number,
delay?: number,
jitter?: number,
state?: string,
adjust?: boolean
};

View File

@@ -0,0 +1,215 @@
import { FastVertexArray } from './fast-vertex-array';
import { InterpolatedAttribute, Attributes, OptionalAttributes, SpriteUpdateParams, Update } from './sprite-types';
const attribKeys = ['a', 'b', 't', 'v'];
const updateKeys = ['x', 'y', 's', 'r', 'g', 'b', 'a'];
export default class TxSprite {
static vertexSize = 30;
static vertexCount = 6;
static dataSize: number = (30 * 6);
vertexArray: FastVertexArray;
vertexPointer: number;
vertexData: number[];
updateMap: Update;
attributes: Attributes;
tempAttributes: OptionalAttributes;
constructor(params: SpriteUpdateParams, vertexArray: FastVertexArray) {
const offsetTime = params.start;
this.vertexArray = vertexArray;
this.vertexData = Array(VI.length).fill(0);
this.updateMap = {
x: 0, y: 0, s: 0, r: 0, g: 0, b: 0, a: 0
};
this.attributes = {
x: { a: params.x, b: params.x, t: offsetTime, v: 0, d: 0 },
y: { a: params.y, b: params.y, t: offsetTime, v: 0, d: 0 },
s: { a: params.s, b: params.s, t: offsetTime, v: 0, d: 0 },
r: { a: params.r, b: params.r, t: offsetTime, v: 0, d: 0 },
g: { a: params.g, b: params.g, t: offsetTime, v: 0, d: 0 },
b: { a: params.b, b: params.b, t: offsetTime, v: 0, d: 0 },
a: { a: params.a, b: params.a, t: offsetTime, v: 0, d: 0 },
};
// Used to temporarily modify the sprite, so that the base view can be resumed later
this.tempAttributes = null;
this.vertexPointer = this.vertexArray.insert(this);
this.compile();
}
private interpolateAttributes(updateMap: Update, attributes: OptionalAttributes, offsetTime: DOMHighResTimeStamp, v: number,
duration: number, minDuration: number, adjust: boolean): void {
for (const key of Object.keys(updateMap)) {
// for each non-null attribute:
if (updateMap[key] != null) {
// calculate current interpolated value, and set as 'from'
interpolateAttributeStart(attributes[key], offsetTime);
// update start time
attributes[key].t = offsetTime;
if (!adjust || (duration && attributes[key].d === 0)) {
attributes[key].v = v;
attributes[key].d = duration;
} else if (minDuration > attributes[key].d) {
// enforce minimum transition duration
attributes[key].v = 1 / minDuration;
attributes[key].d = minDuration;
}
// set 'to' to target value
attributes[key].b = updateMap[key];
}
}
}
/*
params:
x, y, s: position & size of the sprite
r, g, b, a: color & opacity
start: performance.now() timestamp, when to start the transition
duration: of the tweening animation
adjust: if true, alter the target value of any conflicting transitions without changing the duration
minDuration: minimum remaining transition duration when adjust = true
temp: if true, this update is only temporary (can be reversed with 'resume')
*/
update(params: SpriteUpdateParams): void {
const offsetTime = params.start || performance.now();
const v = params.duration > 0 ? (1 / params.duration) : 0;
updateKeys.forEach(key => {
this.updateMap[key] = params[key];
});
const isModified = !!this.tempAttributes;
if (!params.temp) {
this.interpolateAttributes(this.updateMap, this.attributes, offsetTime, v, params.duration, params.minDuration, params.adjust);
} else {
if (!isModified) { // set up tempAttributes
this.tempAttributes = {};
for (const key of Object.keys(this.updateMap)) {
if (this.updateMap[key] != null) {
this.tempAttributes[key] = { ...this.attributes[key] };
}
}
}
this.interpolateAttributes(this.updateMap, this.tempAttributes, offsetTime, v, params.duration, params.minDuration, params.adjust);
}
this.compile();
}
// Transition back from modified state back to base attributes
resume(duration: number, start: DOMHighResTimeStamp = performance.now()): void {
// If not in modified state, there's nothing to do
if (!this.tempAttributes) {
return;
}
const offsetTime = start;
const v = duration > 0 ? (1 / duration) : 0;
for (const key of Object.keys(this.tempAttributes)) {
// If this base attribute is static (fixed or post-transition), transition smoothly back
if (this.attributes[key].v === 0 || (this.attributes[key].t + this.attributes[key].d) <= start) {
// calculate current interpolated value, and set as 'from'
interpolateAttributeStart(this.tempAttributes[key], offsetTime);
this.attributes[key].a = this.tempAttributes[key].a;
this.attributes[key].t = offsetTime;
this.attributes[key].v = v;
this.attributes[key].d = duration;
}
}
this.tempAttributes = null;
this.compile();
}
// Write current state into the graphics vertex array for rendering
compile(): void {
let attributes = this.attributes;
if (this.tempAttributes) {
attributes = {
...this.attributes,
...this.tempAttributes
};
}
const size = attributes.s;
// update vertex data in place
// ugly, but avoids overhead of allocating large temporary arrays
const vertexStride = VI.length + 2;
for (let vertex = 0; vertex < 6; vertex++) {
this.vertexData[vertex * vertexStride] = vertexOffsetFactors[vertex][0];
this.vertexData[(vertex * vertexStride) + 1] = vertexOffsetFactors[vertex][1];
for (let step = 0; step < VI.length; step++) {
// components of each field in the vertex array are defined by an entry in VI:
// VI[i].a is the attribute, VI[i].f is the inner field, VI[i].offA and VI[i].offB are offset factors
this.vertexData[(vertex * vertexStride) + step + 2] = attributes[VI[step].a][VI[step].f];
}
}
this.vertexArray.setData(this.vertexPointer, this.vertexData);
}
moveVertexPointer(index: number): void {
this.vertexPointer = index;
}
destroy(): void {
this.vertexArray.remove(this.vertexPointer);
this.vertexPointer = null;
}
}
// expects 0 <= x <= 1
function smootherstep(x: number): number {
const ix = 1 - x;
x = x * x;
return x / (x + ix * ix);
}
function interpolateAttributeStart(attribute: InterpolatedAttribute, start: DOMHighResTimeStamp): void {
if (attribute.v === 0 || (attribute.t + attribute.d) <= start) {
// transition finished, next transition starts from current end state
// (clamp to 1)
attribute.a = attribute.b;
attribute.v = 0;
attribute.d = 0;
} else if (attribute.t > start) {
// transition not started
// (clamp to 0)
} else {
// transition in progress
// (interpolate)
const progress = (start - attribute.t);
const delta = smootherstep(progress / attribute.d);
attribute.a = attribute.a + (delta * (attribute.b - attribute.a));
attribute.d = attribute.d - progress;
attribute.v = 1 / attribute.d;
}
}
const vertexOffsetFactors = [
[0, 0],
[1, 1],
[1, 0],
[0, 0],
[1, 1],
[0, 1]
];
const VI = [];
updateKeys.forEach((attribute, aIndex) => {
attribKeys.forEach(field => {
VI.push({
a: attribute,
f: field
});
});
});

View File

@@ -0,0 +1,150 @@
import TxSprite from './tx-sprite';
import { FastVertexArray } from './fast-vertex-array';
import { TransactionStripped } from 'src/app/interfaces/websocket.interface';
import { SpriteUpdateParams, Square, Color, ViewUpdateParams } from './sprite-types';
import { feeLevels, mempoolFeeColors } from 'src/app/app.constants';
const hoverTransitionTime = 300;
const defaultHoverColor = hexToColor('1bd8f4');
// convert from this class's update format to TxSprite's update format
function toSpriteUpdate(params: ViewUpdateParams): SpriteUpdateParams {
return {
start: (params.start || performance.now()) + (params.delay || 0),
duration: params.duration,
minDuration: params.minDuration,
...params.display.position,
...params.display.color,
adjust: params.adjust
};
}
export default class TxView implements TransactionStripped {
txid: string;
fee: number;
vsize: number;
value: number;
feerate: number;
initialised: boolean;
vertexArray: FastVertexArray;
hover: boolean;
sprite: TxSprite;
hoverColor: Color | void;
screenPosition: Square;
gridPosition: Square | void;
dirty: boolean;
constructor(tx: TransactionStripped, vertexArray: FastVertexArray) {
this.txid = tx.txid;
this.fee = tx.fee;
this.vsize = tx.vsize;
this.value = tx.value;
this.feerate = tx.fee / tx.vsize;
this.initialised = false;
this.vertexArray = vertexArray;
this.hover = false;
this.screenPosition = { x: 0, y: 0, s: 0 };
this.dirty = true;
}
destroy(): void {
if (this.sprite) {
this.sprite.destroy();
this.sprite = null;
this.initialised = false;
}
}
applyGridPosition(position: Square): void {
if (!this.gridPosition) {
this.gridPosition = { x: 0, y: 0, s: 0 };
}
if (this.gridPosition.x !== position.x || this.gridPosition.y !== position.y || this.gridPosition.s !== position.s) {
this.gridPosition.x = position.x;
this.gridPosition.y = position.y;
this.gridPosition.s = position.s;
this.dirty = true;
}
}
/*
display: defines the final appearance of the sprite
position: { x, y, s } (coordinates & size)
color: { r, g, b, a} (color channels & alpha)
duration: of the tweening animation from the previous display state
start: performance.now() timestamp, when to start the transition
delay: additional milliseconds to wait before starting
jitter: if set, adds a random amount to the delay,
adjust: if true, modify an in-progress transition instead of replacing it
*/
update(params: ViewUpdateParams): void {
if (params.jitter) {
params.delay += (Math.random() * params.jitter);
}
if (!this.initialised || !this.sprite) {
this.initialised = true;
this.sprite = new TxSprite(
toSpriteUpdate(params),
this.vertexArray
);
// apply any pending hover event
if (this.hover) {
this.sprite.update({
...this.hoverColor,
duration: hoverTransitionTime,
adjust: false,
temp: true
});
}
} else {
this.sprite.update(
toSpriteUpdate(params)
);
}
this.dirty = false;
}
// Temporarily override the tx color
setHover(hoverOn: boolean, color: Color | void = defaultHoverColor): void {
if (hoverOn) {
this.hover = true;
this.hoverColor = color;
this.sprite.update({
...this.hoverColor,
duration: hoverTransitionTime,
adjust: false,
temp: true
});
} else {
this.hover = false;
this.hoverColor = null;
if (this.sprite) {
this.sprite.resume(hoverTransitionTime);
}
}
this.dirty = false;
}
getColor(): Color {
let feeLevelIndex = feeLevels.slice().reverse().findIndex((feeLvl) => (this.feerate || 1) >= feeLvl);
feeLevelIndex = feeLevelIndex >= 0 ? feeLevels.length - feeLevelIndex : feeLevelIndex;
return hexToColor(mempoolFeeColors[feeLevelIndex - 1] || mempoolFeeColors[mempoolFeeColors.length - 1]);
}
}
function hexToColor(hex: string): Color {
return {
r: parseInt(hex.slice(0, 2), 16) / 255,
g: parseInt(hex.slice(2, 4), 16) / 255,
b: parseInt(hex.slice(4, 6), 16) / 255,
a: 1
};
}