Implement WebGL projected block visualization
This commit is contained in:
parent
79dae84363
commit
d4c9f6decb
@ -0,0 +1,407 @@
|
|||||||
|
import TxSprite from './tx-sprite'
|
||||||
|
import TxView from './tx-view'
|
||||||
|
import { Square } from './sprite-types'
|
||||||
|
|
||||||
|
export default class BlockScene {
|
||||||
|
scene: { count: number, offset: { x: number, y: number}};
|
||||||
|
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 }: { width: number, height: number, resolution: number, blockLimit: number}) {
|
||||||
|
this.init({ width, height, resolution, blockLimit })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = Math.floor(Math.max(1, width / 1000))
|
||||||
|
this.unitWidth = this.gridSize - (this.unitPadding * 2)
|
||||||
|
|
||||||
|
this.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate new block entering scene
|
||||||
|
enter (txs: TxView[], direction) {
|
||||||
|
this.replace(txs, [], direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate block leaving scene
|
||||||
|
exit (direction: string): TxView[] {
|
||||||
|
const removed = []
|
||||||
|
const startTime = performance.now()
|
||||||
|
Object.values(this.txs).forEach(tx => {
|
||||||
|
this.remove(tx.txid, startTime, direction)
|
||||||
|
removed.push(tx)
|
||||||
|
})
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset layout and replace with new set of transactions
|
||||||
|
replace (txs: TxView[], remove: TxView[], direction: string = 'left'): void {
|
||||||
|
const startTime = performance.now()
|
||||||
|
this.removeBatch(remove.map(tx => tx.txid), startTime, direction)
|
||||||
|
|
||||||
|
// clean up sprites
|
||||||
|
setTimeout(() => {
|
||||||
|
remove.forEach(tx => {
|
||||||
|
tx.destroy()
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
this.layout = new BlockLayout({ width: this.gridWidth, height: this.gridHeight })
|
||||||
|
|
||||||
|
txs.sort((a,b) => { return b.feerate - a.feerate }).forEach(tx => {
|
||||||
|
this.insert(tx, startTime, direction)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private init ({ width, height, resolution, blockLimit }: { width: number, height: number, resolution: number, blockLimit: number}): void {
|
||||||
|
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: 1000,
|
||||||
|
start: startTime,
|
||||||
|
delay: 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 removeBatch (ids: string[], startTime: number, direction: string = 'left'): (TxView | void)[] {
|
||||||
|
if (!startTime) startTime = performance.now()
|
||||||
|
return ids.map(id => {
|
||||||
|
return this.remove(id, startTime, direction)
|
||||||
|
}).filter(tx => !!tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockLayout {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rows: Row[];
|
||||||
|
txPositions: { [key: string]: Square }
|
||||||
|
|
||||||
|
|
||||||
|
constructor ({ width, height } : { width: number, height: number }) {
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
this.rows = [new Row(0, this.width)]
|
||||||
|
this.txPositions = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,103 @@
|
|||||||
|
/*
|
||||||
|
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 (var 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
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,3 @@
|
|||||||
<div class="mempool-block-overview">
|
<div class="mempool-block-overview">
|
||||||
<p *ngIf="mempoolBlock$ | async as mempoolBlock">{{ mempoolBlock.transactions.length }}</p>
|
<canvas class="block-overview" #blockCanvas></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
.mempool-block-overview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 100%;
|
||||||
|
background: #181b2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-overview {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
@ -1,25 +1,48 @@
|
|||||||
import { Component, Input, OnInit, OnDestroy, OnChanges, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, ElementRef, ViewChild, HostListener, Input, OnInit, OnDestroy, OnChanges, ChangeDetectionStrategy, NgZone } from '@angular/core';
|
||||||
import { StateService } from 'src/app/services/state.service';
|
import { StateService } from 'src/app/services/state.service';
|
||||||
import { MempoolBlockWithTransactions } from 'src/app/interfaces/websocket.interface';
|
import { MempoolBlockWithTransactions } from 'src/app/interfaces/websocket.interface';
|
||||||
import { Observable, Subscription } from 'rxjs';
|
import { Observable, Subscription } from 'rxjs';
|
||||||
import { WebsocketService } from 'src/app/services/websocket.service';
|
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({
|
@Component({
|
||||||
selector: 'app-mempool-block-overview',
|
selector: 'app-mempool-block-overview',
|
||||||
templateUrl: './mempool-block-overview.component.html',
|
templateUrl: './mempool-block-overview.component.html',
|
||||||
styleUrls: [],
|
styleUrls: ['./mempool-block-overview.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges {
|
export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
@Input() index: number;
|
@Input() index: number;
|
||||||
|
|
||||||
|
@ViewChild('blockCanvas')
|
||||||
|
canvas: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
gl: WebGLRenderingContext;
|
||||||
|
animationFrameRequest: number;
|
||||||
|
displayWidth: number;
|
||||||
|
displayHeight: number;
|
||||||
|
shaderProgram: WebGLProgram;
|
||||||
|
vertexArray: FastVertexArray;
|
||||||
|
running: boolean;
|
||||||
|
scene: BlockScene;
|
||||||
|
txViews: { [key: string]: TxView };
|
||||||
|
lastBlockHeight: number;
|
||||||
|
blockIndex: number;
|
||||||
|
|
||||||
sub: Subscription;
|
sub: Subscription;
|
||||||
mempoolBlock$: Observable<MempoolBlockWithTransactions>;
|
mempoolBlock$: Observable<MempoolBlockWithTransactions>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public stateService: StateService,
|
public stateService: StateService,
|
||||||
private websocketService: WebsocketService,
|
private websocketService: WebsocketService,
|
||||||
) { }
|
readonly _ngZone: NgZone,
|
||||||
|
) {
|
||||||
|
this.vertexArray = new FastVertexArray(512, TxSprite.dataSize)
|
||||||
|
this.txViews = {}
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.websocketService.startTrackMempoolBlock(this.index);
|
this.websocketService.startTrackMempoolBlock(this.index);
|
||||||
@ -29,8 +52,18 @@ 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 {
|
ngOnChanges(changes): void {
|
||||||
if (changes.index) {
|
if (changes.index) {
|
||||||
|
this.clearBlock(changes.index.currentValue)
|
||||||
this.websocketService.startTrackMempoolBlock(changes.index.currentValue);
|
this.websocketService.startTrackMempoolBlock(changes.index.currentValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -40,7 +73,271 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
|
|||||||
this.websocketService.stopTrackMempoolBlock();
|
this.websocketService.stopTrackMempoolBlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBlock(block: MempoolBlockWithTransactions): void {
|
clearBlock(index: number): void {
|
||||||
|
if (this.scene && index != this.blockIndex) {
|
||||||
|
const direction = (this.blockIndex == null || this.index < this.blockIndex) ? 'left' : 'right'
|
||||||
|
const removed = this.scene.exit(direction)
|
||||||
|
setTimeout(() => {
|
||||||
|
removed.forEach(tx => tx.destroy())
|
||||||
|
}, 1000)
|
||||||
|
this.txViews = {}
|
||||||
|
this.scene = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBlock(block: MempoolBlockWithTransactions): void {
|
||||||
|
if (!this.scene) {
|
||||||
|
this.scene = new BlockScene({ width: this.displayWidth, height: this.displayHeight, resolution: 75, blockLimit: this.stateService.blockVSize })
|
||||||
|
}
|
||||||
|
const blockMined = (this.stateService.latestBlockHeight > this.lastBlockHeight)
|
||||||
|
|
||||||
|
const nextIds = {}
|
||||||
|
let remove = []
|
||||||
|
let add = []
|
||||||
|
block.transactions.forEach(tx => {
|
||||||
|
nextIds[tx.txid] = true
|
||||||
|
})
|
||||||
|
|
||||||
|
// List old transactions to remove
|
||||||
|
Object.keys(this.txViews).forEach(txid => {
|
||||||
|
if (!nextIds[txid]) {
|
||||||
|
remove.push(this.txViews[txid])
|
||||||
|
delete this.txViews[txid]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// List new transactions to add
|
||||||
|
block.transactions.forEach(tx => {
|
||||||
|
if (!this.txViews[tx.txid]) {
|
||||||
|
const txView = new TxView(tx, this.vertexArray)
|
||||||
|
this.txViews[tx.txid] = txView
|
||||||
|
add.push(txView)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.blockIndex != this.index) {
|
||||||
|
const direction = (this.blockIndex == null || this.index < this.blockIndex) ? 'left' : 'right'
|
||||||
|
this.scene.enter(Object.values(this.txViews), direction)
|
||||||
|
} else if (blockMined) {
|
||||||
|
this.scene.replace(Object.values(this.txViews), remove, 'right')
|
||||||
|
} else {
|
||||||
|
this.scene.replace(Object.values(this.txViews), remove, 'left')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastBlockHeight = this.stateService.latestBlockHeight
|
||||||
|
this.blockIndex = this.index
|
||||||
|
}
|
||||||
|
|
||||||
|
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.displayWidth = this.canvas.nativeElement.parentElement.clientWidth
|
||||||
|
this.displayHeight = this.canvas.nativeElement.parentElement.clientHeight
|
||||||
|
this.canvas.nativeElement.width = this.displayWidth
|
||||||
|
this.canvas.nativeElement.height = this.displayHeight
|
||||||
|
if (this.gl) this.gl.viewport(0, 0, this.canvas.nativeElement.width, this.canvas.nativeElement.height)
|
||||||
|
if (this.scene) this.scene.resize({ width: this.displayWidth, height: this.displayHeight })
|
||||||
|
}
|
||||||
|
|
||||||
|
compileShader(src, type): WebGLShader {
|
||||||
|
let 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 {
|
||||||
|
let program = this.gl.createProgram()
|
||||||
|
|
||||||
|
shaderInfo.forEach((desc) => {
|
||||||
|
let 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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++) {
|
||||||
|
let 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;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
@ -0,0 +1,73 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
duration?: number,
|
||||||
|
minDuration?: number,
|
||||||
|
delay?: number,
|
||||||
|
start?: number,
|
||||||
|
jitter?: number,
|
||||||
|
state?: string,
|
||||||
|
adjust?: boolean
|
||||||
|
}
|
211
frontend/src/app/components/mempool-block-overview/tx-sprite.ts
Normal file
211
frontend/src/app/components/mempool-block-overview/tx-sprite.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
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 {
|
||||||
|
vertexArray: FastVertexArray;
|
||||||
|
vertexPointer: number;
|
||||||
|
vertexData: number[];
|
||||||
|
updateMap: Update;
|
||||||
|
attributes: Attributes;
|
||||||
|
tempAttributes: OptionalAttributes;
|
||||||
|
|
||||||
|
static vertexSize: number = 30;
|
||||||
|
static vertexCount: number = 6;
|
||||||
|
static dataSize: number = (30*6);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let 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)
|
||||||
|
let progress = (start - attribute.t)
|
||||||
|
let 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
143
frontend/src/app/components/mempool-block-overview/tx-view.ts
Normal file
143
frontend/src/app/components/mempool-block-overview/tx-view.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
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({display, duration, delay, start, adjust} : ViewUpdateParams): SpriteUpdateParams {
|
||||||
|
return {
|
||||||
|
start: (start || performance.now()) + (delay || 0),
|
||||||
|
duration: duration,
|
||||||
|
...display.position,
|
||||||
|
...display.color,
|
||||||
|
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 ({ display, duration, delay, jitter, start, adjust }: ViewUpdateParams): void {
|
||||||
|
if (jitter) delay += (Math.random() * jitter)
|
||||||
|
|
||||||
|
if (!this.initialised || !this.sprite) {
|
||||||
|
this.initialised = true
|
||||||
|
this.sprite = new TxSprite(
|
||||||
|
toSpriteUpdate({display, duration, delay, start}),
|
||||||
|
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({display, duration, delay, start, adjust})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user