|
| 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 | +} |
0 commit comments