Smarter update algorithm for projected block viz

This commit is contained in:
Mononaut 2022-05-31 18:00:40 +00:00
parent 1aac96a6f6
commit c2802253b7
5 changed files with 234 additions and 15 deletions

View File

@ -71,6 +71,33 @@ export default class BlockScene {
})
}
update (add: 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)
// try to insert new txs directly
const remaining = []
add = add.sort((a,b) => { return b.feerate - a.feerate })
add.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) {
@ -144,9 +171,10 @@ export default class BlockScene {
position: tx.screenPosition
},
duration: 1000,
minDuration: 1000,
minDuration: 500,
start: startTime,
delay: 50,
adjust: true
})
}
}
@ -233,6 +261,45 @@ export default class BlockScene {
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 | void)[] {
if (!startTime) startTime = performance.now()
return ids.map(id => {
@ -353,6 +420,33 @@ class Row {
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 {
@ -360,13 +454,14 @@ class BlockLayout {
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 {
@ -391,6 +486,7 @@ class BlockLayout {
}
}
delete this.txPositions[tx.txid]
delete this.txs[tx.txid]
}
insert (tx: TxView, width: number): Square {
@ -403,6 +499,7 @@ class BlockLayout {
}
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
}
@ -439,4 +536,120 @@ class BlockLayout {
}
}
}
// 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)
}
})
}
}

View File

@ -124,7 +124,7 @@ export class MempoolBlockOverviewComponent implements OnInit, OnDestroy, OnChang
} else if (blockMined) {
this.scene.replace(Object.values(this.txViews), remove, 'right')
} else {
this.scene.replace(Object.values(this.txViews), remove, 'left')
this.scene.update(add, remove, 'left')
}
this.lastBlockHeight = this.stateService.latestBlockHeight

View File

@ -63,10 +63,10 @@ export type ViewUpdateParams = {
position?: Square,
color?: Color,
},
start?: number,
duration?: number,
minDuration?: number,
delay?: number,
start?: number,
jitter?: number,
state?: string,
adjust?: boolean

View File

@ -8,13 +8,14 @@ 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 {
function toSpriteUpdate(params : ViewUpdateParams): SpriteUpdateParams {
return {
start: (start || performance.now()) + (delay || 0),
duration: duration,
...display.position,
...display.color,
adjust
start: (params.start || performance.now()) + (params.delay || 0),
duration: params.duration,
minDuration: params.minDuration,
...params.display.position,
...params.display.color,
adjust: params.adjust
}
}
@ -80,13 +81,13 @@ export default class TxView implements TransactionStripped {
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)
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({display, duration, delay, start}),
toSpriteUpdate(params),
this.vertexArray
)
// apply any pending hover event
@ -100,7 +101,7 @@ export default class TxView implements TransactionStripped {
}
} else {
this.sprite.update(
toSpriteUpdate({display, duration, delay, start, adjust})
toSpriteUpdate(params)
)
}
this.dirty = false

View File

@ -27,6 +27,7 @@ export class WebsocketService {
private lastWant: string | null = null;
private isTrackingTx = false;
private trackingTxId: string;
private isTrackingMempoolBlock = false;
private trackingMempoolBlock: number;
private latestGitCommit = '';
private onlineCheckTimeout: number;
@ -103,6 +104,9 @@ export class WebsocketService {
if (this.isTrackingTx) {
this.startMultiTrackTransaction(this.trackingTxId);
}
if (this.isTrackingMempoolBlock) {
this.startTrackMempoolBlock(this.trackingMempoolBlock);
}
this.stateService.connectionState$.next(2);
}
@ -160,12 +164,13 @@ export class WebsocketService {
startTrackMempoolBlock(block: number) {
this.websocketSubject.next({ 'track-mempool-block': block });
this.isTrackingMempoolBlock = true
this.trackingMempoolBlock = block
}
stopTrackMempoolBlock() {
this.websocketSubject.next({ 'track-mempool-block': -1 });
this.trackingMempoolBlock = -1
this.isTrackingMempoolBlock = false
}
startTrackBisqMarket(market: string) {