optimize utxo graph layout algorithm, enable transitions
This commit is contained in:
parent
9091fc9210
commit
83b6094174
@ -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 `
|
||||
<b style="color: white;">${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}</b>
|
||||
|
Loading…
x
Reference in New Issue
Block a user