Skip to content

Commit 6ef4b40

Browse files
JanLewDevsfroment
andauthored
feat: add hashgraph visualizer (#512)
Signed-off-by: Sacha Froment <[email protected]> Co-authored-by: Sacha Froment <[email protected]>
1 parent 5b50a95 commit 6ef4b40

File tree

5 files changed

+814
-1
lines changed

5 files changed

+814
-1
lines changed

packages/utils/package.json

+23
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,30 @@
1414
"!dist/test",
1515
"!**/*.tsbuildinfo"
1616
],
17+
"typesVersions": {
18+
"*": {
19+
"*": [
20+
"*",
21+
"dist/*",
22+
"dist/src/*",
23+
"dist/src/*/index"
24+
],
25+
"src/*": [
26+
"*",
27+
"dist/*",
28+
"dist/src/*",
29+
"dist/src/*/index"
30+
]
31+
}
32+
},
1733
"exports": {
1834
".": {
1935
"types": "./dist/src/index.d.ts",
2036
"import": "./dist/src/index.js"
37+
},
38+
"./debug": {
39+
"types": "./dist/src/debug/index.d.ts",
40+
"import": "./dist/src/debug/index.js"
2141
}
2242
},
2343
"scripts": {
@@ -29,5 +49,8 @@
2949
"test": "vitest",
3050
"typecheck": "tsc --noEmit",
3151
"watch": "tsc -b -w"
52+
},
53+
"dependencies": {
54+
"@ts-drp/types": "^0.9.1"
3255
}
3356
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import type { Hash, IHashGraph } from "@ts-drp/types";
2+
3+
type Direction = "up" | "down" | "left" | "right";
4+
5+
interface Node {
6+
id: string;
7+
text: string;
8+
x: number;
9+
y: number;
10+
width: number;
11+
height: number;
12+
}
13+
14+
interface Edge {
15+
from: string;
16+
to: string;
17+
}
18+
19+
interface Shape {
20+
type: "rect" | "vline" | "hline" | "arrow";
21+
x: number;
22+
y: number;
23+
width?: number;
24+
height?: number;
25+
text?: string[];
26+
dir?: Direction;
27+
}
28+
29+
/**
30+
* Visualizes a HashGraph structure in ASCII art format
31+
* Renders nodes as boxes connected by lines and arrows
32+
*/
33+
export class HashGraphVisualizer {
34+
private nodeWidth = 13;
35+
private nodeHeight = 3;
36+
private padding = 4;
37+
private arrow = "v";
38+
39+
/**
40+
* Performs a topological sort on the graph in a layered manner
41+
* Returns nodes in order where each node appears after all its dependencies
42+
*
43+
* @param edges - Array of edges representing dependencies between nodes
44+
* @returns Array of node IDs in topologically sorted order
45+
*/
46+
private topologicalSort(edges: Edge[]): string[] {
47+
const nodes = new Set<string>();
48+
const inDegree: Map<string, number> = new Map();
49+
const graph: Map<string, string[]> = new Map();
50+
51+
edges.forEach(({ from, to }) => {
52+
nodes.add(from);
53+
nodes.add(to);
54+
if (!graph.has(from)) graph.set(from, []);
55+
graph.get(from)?.push(to);
56+
inDegree.set(to, (inDegree.get(to) || 0) + 1);
57+
});
58+
59+
const queue: string[] = [];
60+
nodes.forEach((node) => {
61+
if (!inDegree.has(node)) queue.push(node);
62+
});
63+
64+
const result: string[] = [];
65+
let head = 0;
66+
while (queue.length > 0) {
67+
const node = queue[head++];
68+
if (!node) continue;
69+
result.push(node);
70+
graph.get(node)?.forEach((neighbor) => {
71+
inDegree.set(neighbor, (inDegree.get(neighbor) || 0) - 1);
72+
if (inDegree.get(neighbor) === 0) queue.push(neighbor);
73+
});
74+
75+
if (head > queue.length / 2) {
76+
queue.splice(0, head);
77+
head = 0;
78+
}
79+
}
80+
81+
return result;
82+
}
83+
84+
/**
85+
* Assigns layer numbers to nodes based on their dependencies
86+
* Uses topologically sorted nodes to assign layers in a single pass
87+
* Each node's layer will be one more than its highest dependency
88+
*
89+
* @param edges - Array of all edges
90+
* @param sortedNodes - Array of node IDs in topological order
91+
* @returns Map of node IDs to their assigned layer numbers
92+
*/
93+
private assignLayers(edges: Edge[], sortedNodes: string[]): Map<string, number> {
94+
const layers = new Map<string, number>();
95+
const dependencies = new Map<string, string[]>();
96+
97+
edges.forEach(({ from, to }) => {
98+
if (!dependencies.has(to)) {
99+
dependencies.set(to, []);
100+
}
101+
dependencies.get(to)?.push(from);
102+
});
103+
104+
sortedNodes.forEach((node) => layers.set(node, 0));
105+
106+
sortedNodes.forEach((node) => {
107+
const deps = dependencies.get(node) || [];
108+
if (deps.length > 0) {
109+
const maxDepLayer = Math.max(...deps.map((dep) => layers.get(dep) || 0));
110+
layers.set(node, maxDepLayer + 1);
111+
}
112+
});
113+
114+
return layers;
115+
}
116+
117+
/**
118+
* Calculates x,y coordinates for each node based on its layer
119+
* Arranges nodes in each layer horizontally with padding
120+
*
121+
* @param layers - Map of node IDs to their layer numbers
122+
* @returns Map of node IDs to their position and display information
123+
*/
124+
private positionNodes(layers: Map<string, number>): Map<string, Node> {
125+
const layerMap = new Map<number, string[]>();
126+
layers.forEach((layer, node) => {
127+
if (!layerMap.has(layer)) layerMap.set(layer, []);
128+
layerMap.get(layer)?.push(node);
129+
});
130+
131+
const positioned = new Map<string, Node>();
132+
let y = 0;
133+
layerMap.forEach((nodesInLayer) => {
134+
let x = 0;
135+
nodesInLayer.forEach((node) => {
136+
positioned.set(node, {
137+
id: node,
138+
text: `${node.slice(0, 4)}...${node.slice(-4)}`,
139+
x: x,
140+
y: y,
141+
width: this.nodeWidth,
142+
height: this.nodeHeight,
143+
});
144+
x += this.nodeWidth + this.padding;
145+
});
146+
y += this.nodeHeight + 2; // Space for node and edge
147+
});
148+
149+
return positioned;
150+
}
151+
152+
/**
153+
* Generates shapes representing edges between nodes
154+
* Creates vertical lines, horizontal lines, and arrows to show dependencies
155+
*
156+
* @param edges - Array of edges to visualize
157+
* @param nodes - Map of node positions
158+
* @returns Array of shapes representing the edges
159+
*/
160+
private generateEdges(edges: Edge[], nodes: Map<string, Node>): Shape[] {
161+
const shapes: Shape[] = [];
162+
const arrowPositions = new Set<string>();
163+
164+
edges.forEach(({ from, to }) => {
165+
const fromNode = nodes.get(from) as Node;
166+
const toNode = nodes.get(to) as Node;
167+
168+
const startX = fromNode.x + Math.floor(fromNode.width / 2);
169+
const startY = fromNode.y + fromNode.height;
170+
const endX = toNode.x + Math.floor(toNode.width / 2);
171+
const endY = toNode.y;
172+
173+
// Vertical line from bottom of source to just above target
174+
for (let y = startY; y < endY - 1; y++) {
175+
shapes.push({ type: "vline", x: startX, y });
176+
}
177+
178+
const arrowKey = `${endX},${endY - 1}`;
179+
// Horizontal line at endY - 1 if nodes aren't aligned
180+
if (startX !== endX) {
181+
const minX = Math.min(startX, endX);
182+
const maxX = Math.max(startX, endX);
183+
for (let x = minX; x <= maxX; x++) {
184+
const key = `${x},${endY - 1}`;
185+
// Check if there is an arrow at this position
186+
if (!arrowPositions.has(key)) {
187+
shapes.push({ type: "hline", x, y: endY - 1 });
188+
}
189+
}
190+
}
191+
192+
// Arrow just above the target node
193+
shapes.push({ type: "arrow", x: endX, y: endY - 1, dir: "down" });
194+
arrowPositions.add(arrowKey);
195+
});
196+
197+
return shapes;
198+
}
199+
200+
/**
201+
* Renders the graph visualization as ASCII art
202+
* Draws nodes as boxes and connects them with lines and arrows
203+
*
204+
* @param nodes - Map of node positions and display information
205+
* @param edges - Array of shapes representing edges
206+
* @returns String containing the ASCII art visualization
207+
*/
208+
private render(nodes: Map<string, Node>, edges: Shape[]): string {
209+
const allShapes = Array.from(nodes.values())
210+
.map(
211+
(node) =>
212+
({
213+
type: "rect",
214+
x: node.x,
215+
y: node.y,
216+
width: node.width,
217+
height: node.height,
218+
text: [node.text],
219+
}) as Shape
220+
)
221+
.concat(edges);
222+
223+
const maxX = Math.max(...allShapes.map((s) => s.x + (s.width || 0))) + this.padding;
224+
const maxY = Math.max(...allShapes.map((s) => s.y + (s.height || 0)));
225+
226+
const grid: string[][] = Array.from({ length: maxY + 1 }, () => Array(maxX + 1).fill(" "));
227+
228+
// Draw edges first
229+
edges.forEach((shape) => {
230+
if (shape.type === "vline") {
231+
grid[shape.y][shape.x] = "│";
232+
} else if (shape.type === "hline") {
233+
grid[shape.y][shape.x] = "─";
234+
} else if (shape.type === "arrow") {
235+
grid[shape.y][shape.x] = this.arrow;
236+
}
237+
});
238+
239+
// Draw nodes on top
240+
nodes.forEach((node) => {
241+
for (let dy = 0; dy < node.height; dy++) {
242+
for (let dx = 0; dx < node.width; dx++) {
243+
const x = node.x + dx;
244+
const y = node.y + dy;
245+
246+
if (dy === 0 || dy === node.height - 1) {
247+
grid[y][x] = "─";
248+
} else if (dx === 0 || dx === node.width - 1) {
249+
grid[y][x] = "│";
250+
} else if (dy === 1) {
251+
const textLength = node.text.length;
252+
const totalPadding = node.width - 2 - textLength;
253+
const leftPadding = Math.floor(totalPadding / 2);
254+
const charIndex = dx - 1 - leftPadding;
255+
grid[y][x] = charIndex >= 0 && charIndex < textLength ? node.text[charIndex] : " ";
256+
}
257+
}
258+
}
259+
260+
// Draw corners
261+
grid[node.y][node.x] = "┌";
262+
grid[node.y][node.x + node.width - 1] = "┐";
263+
grid[node.y + node.height - 1][node.x] = "└";
264+
grid[node.y + node.height - 1][node.x + node.width - 1] = "┘";
265+
});
266+
return grid.map((row) => row.join("").trimEnd()).join("\n");
267+
}
268+
269+
/**
270+
* Main entry point for visualizing a HashGraph
271+
* Processes the graph structure and outputs an ASCII visualization
272+
*
273+
* @param hashGraph - The HashGraph to visualize
274+
*/
275+
public stringify(hashGraph: IHashGraph): string {
276+
const nodes = new Set<string>();
277+
278+
const edges: { from: Hash; to: Hash }[] = [];
279+
for (const v of hashGraph.getAllVertices()) {
280+
nodes.add(v.hash);
281+
for (const dep of v.dependencies) {
282+
edges.push({ from: dep, to: v.hash });
283+
}
284+
}
285+
286+
const sortedNodes = this.topologicalSort(edges);
287+
const layers = this.assignLayers(edges, sortedNodes);
288+
const positionedNodes = this.positionNodes(layers);
289+
const edgeShapes = this.generateEdges(edges, positionedNodes);
290+
return this.render(positionedNodes, edgeShapes);
291+
}
292+
}

packages/utils/src/debug/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./hashgraph-visualizer.js";

0 commit comments

Comments
 (0)