From c2802253b7276c8699e2c7f60bb3889552e3a8ed Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 31 May 2022 18:00:40 +0000 Subject: [PATCH] Smarter update algorithm for projected block viz --- .../mempool-block-overview/block-scene.ts | 217 +++++++++++++++++- .../mempool-block-overview.component.ts | 2 +- .../mempool-block-overview/sprite-types.ts | 2 +- .../mempool-block-overview/tx-view.ts | 21 +- .../src/app/services/websocket.service.ts | 7 +- 5 files changed, 234 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/components/mempool-block-overview/block-scene.ts b/frontend/src/app/components/mempool-block-overview/block-scene.ts index f68901955..fcff47840 100644 --- a/frontend/src/app/components/mempool-block-overview/block-scene.ts +++ b/frontend/src/app/components/mempool-block-overview/block-scene.ts @@ -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) + } + }) + } } diff --git a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts index b33efdb22..9c1a4d7cf 100644 --- a/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts +++ b/frontend/src/app/components/mempool-block-overview/mempool-block-overview.component.ts @@ -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 diff --git a/frontend/src/app/components/mempool-block-overview/sprite-types.ts b/frontend/src/app/components/mempool-block-overview/sprite-types.ts index 149ac1bc0..ef0778582 100644 --- a/frontend/src/app/components/mempool-block-overview/sprite-types.ts +++ b/frontend/src/app/components/mempool-block-overview/sprite-types.ts @@ -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 diff --git a/frontend/src/app/components/mempool-block-overview/tx-view.ts b/frontend/src/app/components/mempool-block-overview/tx-view.ts index 554d29055..464b8987e 100644 --- a/frontend/src/app/components/mempool-block-overview/tx-view.ts +++ b/frontend/src/app/components/mempool-block-overview/tx-view.ts @@ -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 diff --git a/frontend/src/app/services/websocket.service.ts b/frontend/src/app/services/websocket.service.ts index 1545de8de..b40e43530 100644 --- a/frontend/src/app/services/websocket.service.ts +++ b/frontend/src/app/services/websocket.service.ts @@ -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) {