From 83b60941743506d38fc9dbe6f318fb6533fce287 Mon Sep 17 00:00:00 2001 From: Mononaut Date: Tue, 24 Sep 2024 23:30:24 +0000 Subject: [PATCH] optimize utxo graph layout algorithm, enable transitions --- .../utxo-graph/utxo-graph.component.ts | 187 ++++++++++-------- 1 file changed, 110 insertions(+), 77 deletions(-) diff --git a/frontend/src/app/components/utxo-graph/utxo-graph.component.ts b/frontend/src/app/components/utxo-graph/utxo-graph.component.ts index 310ff0356..b220ae6ab 100644 --- a/frontend/src/app/components/utxo-graph/utxo-graph.component.ts +++ b/frontend/src/app/components/utxo-graph/utxo-graph.component.ts @@ -7,7 +7,6 @@ import { Router } from '@angular/router'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; import { renderSats } from '../../shared/common.utils'; import { colorToHex, hexToColor, mix } from '../block-overview-graph/utils'; -import { TimeComponent } from '../time/time.component'; import { TimeService } from '../../services/time.service'; const newColorHex = '1bd8f4'; @@ -16,6 +15,30 @@ const pendingColorHex = 'eba814'; const newColor = hexToColor(newColorHex); const oldColor = hexToColor(oldColorHex); +interface Circle { + x: number, + y: number, + r: number, + i: number, +} + +interface UtxoCircle extends Circle { + utxo: Utxo; +} + +function sortedInsert(positions: { c1: Circle, c2: Circle, d: number, p: number, side?: boolean }[], newPosition: { c1: Circle, c2: Circle, d: number, p: number }): void { + let left = 0; + let right = positions.length; + while (left < right) { + const mid = Math.floor((left + right) / 2); + if (positions[mid].p > newPosition.p) { + right = mid; + } else { + left = mid + 1; + } + } + positions.splice(left, 0, newPosition, {...newPosition, side: true }); +} @Component({ selector: 'app-utxo-graph', templateUrl: './utxo-graph.component.html', @@ -76,7 +99,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { } } - prepareChartOptions(utxos: Utxo[]) { + prepareChartOptions(utxos: Utxo[]): void { if (!utxos || utxos.length === 0) { return; } @@ -85,20 +108,21 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { // Helper functions const distance = (x1: number, y1: number, x2: number, y2: number): number => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); - const intersectionPoints = (x1: number, y1: number, r1: number, x2: number, y2: number, r2: number): [number, number][] => { - const d = distance(x1, y1, x2, y2); - const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d); - const h = Math.sqrt(r1 * r1 - a * a); - const x3 = x1 + a * (x2 - x1) / d; - const y3 = y1 + a * (y2 - y1) / d; - return [ - [x3 + h * (y2 - y1) / d, y3 - h * (x2 - x1) / d], - [x3 - h * (y2 - y1) / d, y3 + h * (x2 - x1) / d] - ]; + const intersection = (c1: Circle, c2: Circle, d: number, r: number, side: boolean): { x: number, y: number} => { + const d1 = c1.r + r; + const d2 = c2.r + r; + const a = (d1 * d1 - d2 * d2 + d * d) / (2 * d); + const h = Math.sqrt(d1 * d1 - a * a); + const x3 = c1.x + a * (c2.x - c1.x) / d; + const y3 = c1.y + a * (c2.y - c1.y) / d; + return side + ? { x: x3 + h * (c2.y - c1.y) / d, y: y3 - h * (c2.x - c1.x) / d } + : { x: x3 - h * (c2.y - c1.y) / d, y: y3 + h * (c2.x - c1.x) / d }; }; - // Naive algorithm to pack circles as tightly as possible without overlaps - const placedCircles: { x: number, y: number, r: number, utxo: Utxo, distances: number[] }[] = []; + // ~Linear algorithm to pack circles as tightly as possible without overlaps + const placedCircles: UtxoCircle[] = []; + const positions: { c1: Circle, c2: Circle, d: number, p: number, side?: boolean }[] = []; // Pack in descending order of value, and limit to the top 500 to preserve performance const sortedUtxos = utxos.sort((a, b) => { if (a.value === b.value) { @@ -112,78 +136,82 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { } return b.value - a.value; }).slice(0, 500); - let centerOfMass = { x: 0, y: 0 }; - let weightOfMass = 0; + const maxR = Math.sqrt(sortedUtxos.reduce((max, utxo) => Math.max(max, utxo.value), 0)); sortedUtxos.forEach((utxo, index) => { // area proportional to value const r = Math.sqrt(utxo.value); // special cases for the first two utxos if (index === 0) { - placedCircles.push({ x: 0, y: 0, r, utxo, distances: [0] }); + placedCircles.push({ x: 0, y: 0, r, utxo, i: index }); return; } if (index === 1) { const c = placedCircles[0]; - placedCircles.push({ x: c.r + r, y: 0, r, utxo, distances: [c.r + r, 0] }); - c.distances.push(c.r + r); + placedCircles.push({ x: c.r + r, y: 0, r, utxo, i: index }); + sortedInsert(positions, { c1: c, c2: placedCircles[1], d: c.r + r, p: 0 }); + return; + } + if (index === 2) { + const c = placedCircles[0]; + placedCircles.push({ x: -c.r - r, y: 0, r, utxo, i: index }); + sortedInsert(positions, { c1: c, c2: placedCircles[2], d: c.r + r, p: 0 }); return; } // The best position will be touching two other circles - // generate a list of candidate points by finding all such positions + // find the closest such position to the center of the graph // where the circle can be placed without overlapping other circles - const candidates: [number, number, number[]][] = []; const numCircles = placedCircles.length; - for (let i = 0; i < numCircles; i++) { - for (let j = i + 1; j < numCircles; j++) { - const c1 = placedCircles[i]; - const c2 = placedCircles[j]; - if (c1.distances[j] > (c1.r + c2.r + r + r)) { - // too far apart for new circle to touch both + let newCircle: UtxoCircle = null; + while (positions.length > 0) { + const position = positions.shift(); + // if the circles are too far apart, skip + if (position.d > (position.c1.r + position.c2.r + r + r)) { + continue; + } + + const { x, y } = intersection(position.c1, position.c2, position.d, r, position.side); + if (isNaN(x) || isNaN(y)) { + // should never happen + continue; + } + + // check if the circle would overlap any other circles here + let valid = true; + const nearbyCircles: { c: UtxoCircle, d: number, s: number }[] = []; + for (let k = 0; k < numCircles; k++) { + const c = placedCircles[k]; + if (k === position.c1.i || k === position.c2.i) { + nearbyCircles.push({ c, d: c.r + r, s: 0 }); continue; } - const points = intersectionPoints(c1.x, c1.y, c1.r + r, c2.x, c2.y, c2.r + r); - points.forEach(([x, y]) => { - const distances: number[] = []; - let valid = true; - for (let k = 0; k < numCircles; k++) { - const c = placedCircles[k]; - const d = distance(x, y, c.x, c.y); - if (k !== i && k !== j && d < (r + c.r)) { - valid = false; - break; - } else { - distances.push(d); - } + const d = distance(x, y, c.x, c.y); + if (d < (r + c.r)) { + valid = false; + break; + } else { + nearbyCircles.push({ c, d, s: d - c.r - r }); + } + } + if (valid) { + newCircle = { x, y, r, utxo, i: index }; + // add new positions to the candidate list + const nearest = nearbyCircles.sort((a, b) => a.s - b.s).slice(0, 5); + for (const n of nearest) { + if (n.d < (n.c.r + r + maxR + maxR)) { + sortedInsert(positions, { c1: newCircle, c2: n.c, d: n.d, p: distance((n.c.x + x) / 2, (n.c.y + y), 0, 0) }); } - if (valid) { - candidates.push([x, y, distances]); - } - }); + } + break; } } - - // Pick the candidate closest to the center of mass - const [x, y, distances] = candidates.length ? candidates.reduce((closest, candidate) => - distance(candidate[0], candidate[1], centerOfMass[0], centerOfMass[1]) < - distance(closest[0], closest[1], centerOfMass[0], centerOfMass[1]) - ? candidate - : closest - ) : [0, 0, []]; - - placedCircles.push({ x, y, r, utxo, distances }); - for (let i = 0; i < distances.length; i++) { - placedCircles[i].distances.push(distances[i]); + if (newCircle) { + placedCircles.push(newCircle); + } else { + // should never happen + return; } - distances.push(0); - - // Update center of mass - centerOfMass = { - x: (centerOfMass.x * weightOfMass + x) / (weightOfMass + r), - y: (centerOfMass.y * weightOfMass + y) / (weightOfMass + r), - }; - weightOfMass += r; }); // Precompute the bounding box of the graph @@ -194,23 +222,26 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { const width = maxX - minX; const height = maxY - minY; - const data = placedCircles.map((circle, index) => [ + const data = placedCircles.map((circle) => [ + circle.utxo.txid + circle.utxo.vout, circle.utxo, - index, circle.x, circle.y, - circle.r + circle.r, ]); this.chartOptions = { series: [{ type: 'custom', coordinateSystem: undefined, - data, + data: data, + encode: { + itemName: 0, + x: 2, + y: 3, + r: 4, + }, renderItem: (params, api) => { - const idx = params.dataIndex; - const datum = data[idx]; - const utxo = datum[0] as Utxo; const chartWidth = api.getWidth(); const chartHeight = api.getHeight(); const scale = Math.min(chartWidth / width, chartHeight / height); @@ -218,6 +249,9 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { const scaledHeight = height * scale; const offsetX = (chartWidth - scaledWidth) / 2 - minX * scale; const offsetY = (chartHeight - scaledHeight) / 2 - minY * scale; + + const datum = data[params.dataIndex]; + const utxo = datum[1] as Utxo; const x = datum[2] as number; const y = datum[3] as number; const r = datum[4] as number; @@ -225,14 +259,13 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { // skip items too small to render cleanly return; } + const valueStr = renderSats(utxo.value, this.stateService.network); const elements: any[] = [ { type: 'circle', autoBatch: true, shape: { - cx: (x * scale) + offsetX, - cy: (y * scale) + offsetY, r: (r * scale) - 1, }, style: { @@ -240,12 +273,10 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { } }, ]; - const labelFontSize = Math.min(36, r * scale * 0.25); + const labelFontSize = Math.min(36, r * scale * 0.3); if (labelFontSize > 8) { elements.push({ type: 'text', - x: (x * scale) + offsetX, - y: (y * scale) + offsetY, style: { text: valueStr, fontSize: labelFontSize, @@ -257,6 +288,8 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { } return { type: 'group', + x: (x * scale) + offsetX, + y: (y * scale) + offsetY, children: elements, }; }, @@ -271,7 +304,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy { }, borderColor: '#000', formatter: (params: any): string => { - const utxo = params.data[0] as Utxo; + const utxo = params.data[1] as Utxo; const valueStr = renderSats(utxo.value, this.stateService.network); return ` ${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}