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 { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe';
|
||||||
import { renderSats } from '../../shared/common.utils';
|
import { renderSats } from '../../shared/common.utils';
|
||||||
import { colorToHex, hexToColor, mix } from '../block-overview-graph/utils';
|
import { colorToHex, hexToColor, mix } from '../block-overview-graph/utils';
|
||||||
import { TimeComponent } from '../time/time.component';
|
|
||||||
import { TimeService } from '../../services/time.service';
|
import { TimeService } from '../../services/time.service';
|
||||||
|
|
||||||
const newColorHex = '1bd8f4';
|
const newColorHex = '1bd8f4';
|
||||||
@ -16,6 +15,30 @@ const pendingColorHex = 'eba814';
|
|||||||
const newColor = hexToColor(newColorHex);
|
const newColor = hexToColor(newColorHex);
|
||||||
const oldColor = hexToColor(oldColorHex);
|
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({
|
@Component({
|
||||||
selector: 'app-utxo-graph',
|
selector: 'app-utxo-graph',
|
||||||
templateUrl: './utxo-graph.component.html',
|
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) {
|
if (!utxos || utxos.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -85,20 +108,21 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
const distance = (x1: number, y1: number, x2: number, y2: number): number => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
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 intersection = (c1: Circle, c2: Circle, d: number, r: number, side: boolean): { x: number, y: number} => {
|
||||||
const d = distance(x1, y1, x2, y2);
|
const d1 = c1.r + r;
|
||||||
const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d);
|
const d2 = c2.r + r;
|
||||||
const h = Math.sqrt(r1 * r1 - a * a);
|
const a = (d1 * d1 - d2 * d2 + d * d) / (2 * d);
|
||||||
const x3 = x1 + a * (x2 - x1) / d;
|
const h = Math.sqrt(d1 * d1 - a * a);
|
||||||
const y3 = y1 + a * (y2 - y1) / d;
|
const x3 = c1.x + a * (c2.x - c1.x) / d;
|
||||||
return [
|
const y3 = c1.y + a * (c2.y - c1.y) / d;
|
||||||
[x3 + h * (y2 - y1) / d, y3 - h * (x2 - x1) / d],
|
return side
|
||||||
[x3 - h * (y2 - y1) / d, y3 + h * (x2 - x1) / d]
|
? { 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
|
// ~Linear algorithm to pack circles as tightly as possible without overlaps
|
||||||
const placedCircles: { x: number, y: number, r: number, utxo: Utxo, distances: number[] }[] = [];
|
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
|
// Pack in descending order of value, and limit to the top 500 to preserve performance
|
||||||
const sortedUtxos = utxos.sort((a, b) => {
|
const sortedUtxos = utxos.sort((a, b) => {
|
||||||
if (a.value === b.value) {
|
if (a.value === b.value) {
|
||||||
@ -112,78 +136,82 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
return b.value - a.value;
|
return b.value - a.value;
|
||||||
}).slice(0, 500);
|
}).slice(0, 500);
|
||||||
let centerOfMass = { x: 0, y: 0 };
|
const maxR = Math.sqrt(sortedUtxos.reduce((max, utxo) => Math.max(max, utxo.value), 0));
|
||||||
let weightOfMass = 0;
|
|
||||||
sortedUtxos.forEach((utxo, index) => {
|
sortedUtxos.forEach((utxo, index) => {
|
||||||
// area proportional to value
|
// area proportional to value
|
||||||
const r = Math.sqrt(utxo.value);
|
const r = Math.sqrt(utxo.value);
|
||||||
|
|
||||||
// special cases for the first two utxos
|
// special cases for the first two utxos
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
placedCircles.push({ x: 0, y: 0, r, utxo, distances: [0] });
|
placedCircles.push({ x: 0, y: 0, r, utxo, i: index });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (index === 1) {
|
if (index === 1) {
|
||||||
const c = placedCircles[0];
|
const c = placedCircles[0];
|
||||||
placedCircles.push({ x: c.r + r, y: 0, r, utxo, distances: [c.r + r, 0] });
|
placedCircles.push({ x: c.r + r, y: 0, r, utxo, i: index });
|
||||||
c.distances.push(c.r + r);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The best position will be touching two other circles
|
// 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
|
// where the circle can be placed without overlapping other circles
|
||||||
const candidates: [number, number, number[]][] = [];
|
|
||||||
const numCircles = placedCircles.length;
|
const numCircles = placedCircles.length;
|
||||||
for (let i = 0; i < numCircles; i++) {
|
let newCircle: UtxoCircle = null;
|
||||||
for (let j = i + 1; j < numCircles; j++) {
|
while (positions.length > 0) {
|
||||||
const c1 = placedCircles[i];
|
const position = positions.shift();
|
||||||
const c2 = placedCircles[j];
|
// if the circles are too far apart, skip
|
||||||
if (c1.distances[j] > (c1.r + c2.r + r + r)) {
|
if (position.d > (position.c1.r + position.c2.r + r + r)) {
|
||||||
// too far apart for new circle to touch both
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const points = intersectionPoints(c1.x, c1.y, c1.r + r, c2.x, c2.y, c2.r + r);
|
|
||||||
points.forEach(([x, y]) => {
|
const { x, y } = intersection(position.c1, position.c2, position.d, r, position.side);
|
||||||
const distances: number[] = [];
|
if (isNaN(x) || isNaN(y)) {
|
||||||
|
// should never happen
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the circle would overlap any other circles here
|
||||||
let valid = true;
|
let valid = true;
|
||||||
|
const nearbyCircles: { c: UtxoCircle, d: number, s: number }[] = [];
|
||||||
for (let k = 0; k < numCircles; k++) {
|
for (let k = 0; k < numCircles; k++) {
|
||||||
const c = placedCircles[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 d = distance(x, y, c.x, c.y);
|
const d = distance(x, y, c.x, c.y);
|
||||||
if (k !== i && k !== j && d < (r + c.r)) {
|
if (d < (r + c.r)) {
|
||||||
valid = false;
|
valid = false;
|
||||||
break;
|
break;
|
||||||
} else {
|
} else {
|
||||||
distances.push(d);
|
nearbyCircles.push({ c, d, s: d - c.r - r });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (valid) {
|
if (valid) {
|
||||||
candidates.push([x, y, distances]);
|
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) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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]) <
|
if (newCircle) {
|
||||||
distance(closest[0], closest[1], centerOfMass[0], centerOfMass[1])
|
placedCircles.push(newCircle);
|
||||||
? candidate
|
} else {
|
||||||
: closest
|
// should never happen
|
||||||
) : [0, 0, []];
|
return;
|
||||||
|
|
||||||
placedCircles.push({ x, y, r, utxo, distances });
|
|
||||||
for (let i = 0; i < distances.length; i++) {
|
|
||||||
placedCircles[i].distances.push(distances[i]);
|
|
||||||
}
|
}
|
||||||
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
|
// Precompute the bounding box of the graph
|
||||||
@ -194,23 +222,26 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
|||||||
const width = maxX - minX;
|
const width = maxX - minX;
|
||||||
const height = maxY - minY;
|
const height = maxY - minY;
|
||||||
|
|
||||||
const data = placedCircles.map((circle, index) => [
|
const data = placedCircles.map((circle) => [
|
||||||
|
circle.utxo.txid + circle.utxo.vout,
|
||||||
circle.utxo,
|
circle.utxo,
|
||||||
index,
|
|
||||||
circle.x,
|
circle.x,
|
||||||
circle.y,
|
circle.y,
|
||||||
circle.r
|
circle.r,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.chartOptions = {
|
this.chartOptions = {
|
||||||
series: [{
|
series: [{
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
coordinateSystem: undefined,
|
coordinateSystem: undefined,
|
||||||
data,
|
data: data,
|
||||||
|
encode: {
|
||||||
|
itemName: 0,
|
||||||
|
x: 2,
|
||||||
|
y: 3,
|
||||||
|
r: 4,
|
||||||
|
},
|
||||||
renderItem: (params, api) => {
|
renderItem: (params, api) => {
|
||||||
const idx = params.dataIndex;
|
|
||||||
const datum = data[idx];
|
|
||||||
const utxo = datum[0] as Utxo;
|
|
||||||
const chartWidth = api.getWidth();
|
const chartWidth = api.getWidth();
|
||||||
const chartHeight = api.getHeight();
|
const chartHeight = api.getHeight();
|
||||||
const scale = Math.min(chartWidth / width, chartHeight / height);
|
const scale = Math.min(chartWidth / width, chartHeight / height);
|
||||||
@ -218,6 +249,9 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
|||||||
const scaledHeight = height * scale;
|
const scaledHeight = height * scale;
|
||||||
const offsetX = (chartWidth - scaledWidth) / 2 - minX * scale;
|
const offsetX = (chartWidth - scaledWidth) / 2 - minX * scale;
|
||||||
const offsetY = (chartHeight - scaledHeight) / 2 - minY * 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 x = datum[2] as number;
|
||||||
const y = datum[3] as number;
|
const y = datum[3] as number;
|
||||||
const r = datum[4] 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
|
// skip items too small to render cleanly
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueStr = renderSats(utxo.value, this.stateService.network);
|
const valueStr = renderSats(utxo.value, this.stateService.network);
|
||||||
const elements: any[] = [
|
const elements: any[] = [
|
||||||
{
|
{
|
||||||
type: 'circle',
|
type: 'circle',
|
||||||
autoBatch: true,
|
autoBatch: true,
|
||||||
shape: {
|
shape: {
|
||||||
cx: (x * scale) + offsetX,
|
|
||||||
cy: (y * scale) + offsetY,
|
|
||||||
r: (r * scale) - 1,
|
r: (r * scale) - 1,
|
||||||
},
|
},
|
||||||
style: {
|
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) {
|
if (labelFontSize > 8) {
|
||||||
elements.push({
|
elements.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
x: (x * scale) + offsetX,
|
|
||||||
y: (y * scale) + offsetY,
|
|
||||||
style: {
|
style: {
|
||||||
text: valueStr,
|
text: valueStr,
|
||||||
fontSize: labelFontSize,
|
fontSize: labelFontSize,
|
||||||
@ -257,6 +288,8 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
|
x: (x * scale) + offsetX,
|
||||||
|
y: (y * scale) + offsetY,
|
||||||
children: elements,
|
children: elements,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -271,7 +304,7 @@ export class UtxoGraphComponent implements OnChanges, OnDestroy {
|
|||||||
},
|
},
|
||||||
borderColor: '#000',
|
borderColor: '#000',
|
||||||
formatter: (params: any): string => {
|
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);
|
const valueStr = renderSats(utxo.value, this.stateService.network);
|
||||||
return `
|
return `
|
||||||
<b style="color: white;">${utxo.txid.slice(0, 6)}...${utxo.txid.slice(-6)}:${utxo.vout}</b>
|
<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