blog post
Building a Pathfinding Visualizer: My path to Graph algorithms
By Luis Velazquez Bilbao • Dec 29, 2022
Table of Contents
Introduction

Pathfinding algorithms are the unsung heroes of modern technology, quietly powering everything from GPS navigation to video game AI. As a software engineer, I've always been fascinated by how these algorithms can efficiently solve complex routing problems. This fascination led me to embark on an exciting side project: building a Pathfinding Visualizer.

The goal of this project was twofold. First, I wanted to deepen my understanding of graph algorithms by implementing them from scratch. Second, I aimed to create an interactive tool that could help others visualize and understand how these algorithms work in real-time.

In this blog post, I'll take you through my journey of creating this visualizer, from the initial concept to the final implementation. We'll dive into the algorithms, explore the code, and discuss the challenges and insights I gained along the way.

Project Overview

My Pathfinding Visualizer is an interactive web application that allows users to see pathfinding algorithms in action. Here's what it does:

  • Presents a grid where users can set start and end points
  • Allows users to set obstacles on the grid
  • Implements two popular pathfinding algorithms: Breadth-First Search (BFS) and Dijkstra's Algorithm
  • Visualizes the process of each algorithm as it explores the grid
  • Highlights the final path found by the algorithm

Users can switch between algorithms, adjust the grid size, and see real-time comparisons of how different algorithms perform under various conditions. This hands-on approach makes it easier to understand the strengths and weaknesses of each algorithm.

Technologies Used

For this project, I chose a stack that would allow for rapid development and smooth performance:

  • HTML: I used HTML5 to structure the application. Its semantic elements helped create a clear, accessible layout.
  • Tailwind CSS: This utility-first CSS framework was instrumental in quickly styling the application. Tailwind's responsive design utilities made it easy to create a grid that looks good on all device sizes.
  • JavaScript: The core functionality and algorithms were implemented in vanilla JavaScript. I chose not to use a framework to keep the focus on the algorithms themselves and to minimize overhead.

Here's a snippet of how these technologies work together:

    
component
Copy
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Pathfinding Visualizer</title> <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"> </head> <body class="bg-gray-100"> <div class="container mx-auto px-4 py-8"> <h1 class="text-4xl font-bold mb-4">Pathfinding Visualizer</h1> <div id="grid" class="grid grid-cols-20 gap-1 mb-4"></div> <div class="flex space-x-4"> <button id="bfs" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"> Run BFS </button> <button id="dijkstra" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"> Run Dijkstra </button> </div> </div> <script src="app.js"></script> </body> </html>

This combination of technologies allowed me to create a responsive, interactive, and visually appealing application while maintaining full control over the core algorithms.

Setting Up the Grid

The grid is the foundation of our visualizer. It represents the space in which our pathfinding algorithms will operate. Creating a flexible, interactive grid was crucial for the project's success.

Here's how I implemented the grid:

    
javascript
Copy
class Grid { constructor(rows, cols) { this.rows = rows; this.cols = cols; this.cells = []; this.createGrid(); } createGrid() { const gridElement = document.getElementById('grid'); gridElement.style.gridTemplateColumns = `repeat(${this.cols}, minmax(0, 1fr))`; for (let i = 0; i < this.rows * this.cols; i++) { const cell = document.createElement('div'); cell.classList.add('w-6', 'h-6', 'bg-gray-200', 'border', 'border-gray-400'); cell.dataset.index = i; cell.addEventListener('click', () => this.toggleCellState(i)); this.cells.push({ element: cell, isWall: false, isStart: false, isEnd: false }); gridElement.appendChild(cell); } } toggleCellState(index) { const cell = this.cells[index]; if (cell.isStart || cell.isEnd) return; cell.isWall = !cell.isWall; cell.element.classList.toggle('bg-gray-800'); } getNeighbors(index) { const neighbors = []; const row = Math.floor(index / this.cols); const col = index % this.cols; if (row > 0) neighbors.push(index - this.cols); // Up if (row < this.rows - 1) neighbors.push(index + this.cols); // Down if (col > 0) neighbors.push(index - 1); // Left if (col < this.cols - 1) neighbors.push(index + 1); // Right return neighbors.filter(i => !this.cells[i].isWall); } } const grid = new Grid(20, 20);

This Grid class encapsulates the logic for creating and managing the grid. It uses Tailwind CSS classes for styling and includes methods for toggling cell states and finding neighbors, which will be crucial for our pathfinding algorithms.

Implementing Breadth-First Search (BFS)

Breadth-First Search is one of the simplest and most intuitive pathfinding algorithms. It explores all the neighbors at the present depth before moving on to nodes at the next depth level.

Here's a breakdown of how BFS works:

  • Start at the initial node
  • Explore all neighboring nodes at the present depth
  • Move on to the next level of nodes
  • Repeat steps 2 and 3 until you find the goal or exhaust all nodes

The time complexity of BFS is O(V + E) , where V is the number of vertices and E is the number of edges. In the worst case, we might have to visit all vertices and edges. The space complexity is O(V) because, in the worst case, we might need to store all vertices in the queue.

Here's the implementation of BFS:

    
javascript
Copy
class BFS { constructor(grid) { this.grid = grid; } search(start, end) { const queue = [start]; const visited = new Set([start]); const parent = new Map(); while (queue.length > 0) { const current = queue.shift(); if (current === end) { return this.reconstructPath(parent, end); } for (const neighbor of this.grid.getNeighbors(current)) { if (!visited.has(neighbor)) { visited.add(neighbor); parent.set(neighbor, current); queue.push(neighbor); // Visualize the exploration if (neighbor !== end) { this.grid.cells[neighbor].element.classList.add('bg-blue-300'); } } } } return null; // Path not found } reconstructPath(parent, end) { const path = [end]; let current = end; while (parent.has(current)) { current = parent.get(current); path.unshift(current); } return path; } }

This implementation includes a method for reconstructing the path once the end node is found, which is crucial for visualizing the final result.

Implementing Dijkstra's Algorithm

Dijkstra's Algorithm is more sophisticated than BFS and is used to find the shortest path between nodes in a graph. Unlike BFS, Dijkstra's Algorithm can handle weighted edges, making it more versatile for real-world scenarios where different paths might have different costs.

Here's how Dijkstra's Algorithm works:

  • Initialize distances to all nodes as infinite, except the start node (distance = 0)
  • Add the start node to a priority queue
  • While the priority queue is not empty: a. Remove the node with the smallest distance from the queue b. For each neighbor of this node:
    • Calculate the distance to the neighbor through the current node
    • If this distance is less than the previously recorded distance, update it
    • Add the neighbor to the priority queue
  • Repeat step 3 until the end node is reached or the queue is empty

The time complexity of Dijkstra's Algorithm is O((V + E) log V) when using a binary heap as a priority queue. The space complexity is O(V) for storing the distances and the priority queue.

Here's the implementation:

    
javascript
Copy
class PriorityQueue { constructor() { this.elements = []; } enqueue(element, priority) { this.elements.push({element, priority}); this.elements.sort((a, b) => a.priority - b.priority); } dequeue() { return this.elements.shift().element; } isEmpty() { return this.elements.length === 0; } } class Dijkstra { constructor(grid) { this.grid = grid; } search(start, end) { const distances = new Map(); const parent = new Map(); const pq = new PriorityQueue(); for (let i = 0; i < this.grid.rows * this.grid.cols; i++) { distances.set(i, Infinity); } distances.set(start, 0); pq.enqueue(start, 0); while (!pq.isEmpty()) { const current = pq.dequeue(); if (current === end) { return this.reconstructPath(parent, end); } for (const neighbor of this.grid.getNeighbors(current)) { const distance = distances.get(current) + 1; // Assuming unit weight if (distance < distances.get(neighbor)) { distances.set(neighbor, distance); parent.set(neighbor, current); pq.enqueue(neighbor, distance); // Visualize the exploration if (neighbor !== end) { this.grid.cells[neighbor].element.classList.add('bg-green-300'); } } } } return null; // Path not found } reconstructPath(parent, end) { const path = [end]; let current = end; while (parent.has(current)) { current = parent.get(current); path.unshift(current); } return path; } }

This implementation includes a simple PriorityQueue class. In a production environment, you might want to use a more efficient implementation of a priority queue, such as a Fibonacci heap.

Visualizing the Algorithms

Visualizing the algorithms is key to understanding how they work. I implemented this by updating the grid cells' colors as the algorithms explore them.

Here's how I visualized the path:

    
javascript
Copy
function visualizePath(path, delay = 50) { path.forEach((cellIndex, i) => { setTimeout(() => { const cell = grid.cells[cellIndex].element; cell.classList.remove('bg-blue-300', 'bg-green-300'); cell.classList.add('bg-yellow-400'); }, i * delay); }); }

This function takes the path returned by our search algorithms and gradually colors the cells, creating an animation effect. The delay parameter allows us to control the speed of the visualization.

To tie everything together:

    
javascript
Copy
document.getElementById('bfs').addEventListener('click', () => { const bfs = new BFS(grid); const path = bfs.search(startIndex, endIndex); path ? visualizePath(path) : alert('No path found!'); }); document.getElementById('dijkstra').addEventListener('click', () => { const dijkstra = new Dijkstra(grid); const path = dijkstra.search(startIndex, endIndex); path ? visualizePath(path) : alert('No path found!'); });

These event listeners connect our UI buttons to the respective algorithms and visualization function.

Performance Analysis

To compare the performance of BFS and Dijkstra's algorithm, I implemented a simple benchmarking function:

    
javascript
Copy
function benchmark(algorithm, start, end, iterations = 100) { const startTime = performance.now(); for (let i = 0; i < iterations; i++) { algorithm.search(start, end); } const endTime = performance.now(); return (endTime - startTime) / iterations; } const bfs = new BFS(grid); const dijkstra = new Dijkstra(grid); console.log(`BFS average time: ${benchmark(bfs, startIndex, endIndex)} ms`); console.log(`Dijkstra average time: ${benchmark(dijkstra, startIndex, endIndex)} ms`);

In my tests, I found that BFS was generally faster than Dijkstra's algorithm for unweighted grids. This is expected because BFS has a simpler data structure (a queue) compared to Dijkstra's priority queue.

However, Dijkstra's algorithm shines when dealing with weighted edges. To demonstrate this, I modified the grid to include different terrain costs:

    
javascript
Copy
class Grid { // ... existing code ... getNeighborsWithWeights(index) { const neighbors = this.getNeighbors(index); return neighbors.map(neighbor => { const weight = this.cells[neighbor].isRough ? 5 : 1; return [neighbor, weight]; }); } }

With this modification, Dijkstra's algorithm consistently found shorter paths in scenarios with varied terrain, showcasing its superiority in more complex pathfinding scenarios.

Challenges and Learnings

Throughout this project, I encountered several challenges:

  • Performance optimization: Initially, the visualizer was slow on larger grids. I optimized it by using more efficient data structures and minimizing DOM manipulations.
  • Edge cases: Handling scenarios where no path exists or when the start/end points are the same required careful consideration.
  • User experience: Creating an intuitive interface for setting up the grid and visualizing the algorithms took several iterations.

Key learnings:

  • The importance of choosing the right algorithm for the task. BFS is great for unweighted graphs, while Dijkstra's shines with weighted edges.
  • The value of visualization in understanding complex algorithms. Seeing the algorithms in action made their behavior much clearer.
  • The balance between performance and visual appeal. While frequent updates make for a smoother animation, they can significantly slow down the pathfinding process on larger grids.
Conclusion and Future Enhancements

Building this Pathfinding Visualizer has been an enlightening journey into the world of graph algorithms and web development. This project not only deepened my understanding of fundamental computer science concepts but also honed my skills in creating interactive, educational tools.

Key takeaways:

  • Algorithm Implementation: Translating theoretical knowledge of BFS and Dijkstra's algorithms into working code was a valuable exercise. It reinforced the importance of understanding data structures and their impact on algorithm efficiency.
  • Visualization Power: The project highlighted how visualization can make complex concepts more accessible. Seeing these algorithms in action provides insights that textbook descriptions alone cannot convey.
  • Web Technologies: Leveraging HTML, Tailwind CSS, and vanilla JavaScript demonstrated that powerful, interactive tools can be built with fundamental web technologies. This stack allowed for rapid development without sacrificing performance.
  • User Experience: Designing an intuitive interface taught me the importance of considering the end-user throughout the development process. Small details, like color choices for visualizing different states, can significantly impact the user's understanding.
  • Performance Optimization: Working with larger grids revealed the need for careful performance consideration, especially when balancing smooth animations with algorithm execution speed.

Future enhancements:

While the current version of the Pathfinding Visualizer serves its purpose well, there are several exciting avenues for future development:

  • Additional Algorithms: Implementing more pathfinding algorithms such as A*, Depth-First Search (DFS), or Bidirectional Search would provide a broader comparison of different approaches.
        
    javascript
    Copy
    class AStar { constructor(grid) { this.grid = grid; } heuristic(a, b) { const [x1, y1] = this.grid.getCoordinates(a); const [x2, y2] = this.grid.getCoordinates(b); return Math.abs(x1 - x2) + Math.abs(y1 - y2); } search(start, end) { // A* implementation } }
  • Custom Maps: Allowing users to save and load custom grid configurations would enable sharing of interesting pathfinding scenarios.
  • 3D Visualization: Extending the visualizer to work with 3D grids could offer insights into pathfinding for games or robotics applications.
  • Weighted Edges: Implementing a more sophisticated system for edge weights (e.g., different terrain types) would showcase the full potential of Dijkstra's algorithm and A*.
  • Algorithm Racing: A feature to run multiple algorithms simultaneously on the same grid would provide a direct visual comparison of their performance.
  • Educational Mode: Adding an educational mode with step-by-step explanations of each algorithm's decisions could enhance the tool's value for learning.
        
    javascript
    Copy
    function educationalMode(algorithm, grid, start, end) { const steps = algorithm.getStepByStep(start, end); let currentStep = 0; function explainStep() { if (currentStep < steps.length) { const step = steps[currentStep]; visualizeStep(step); explainStepLogic(step); currentStep++; setTimeout(explainStep, 1000); // Delay between steps } } explainStep(); }
  • Performance Metrics: Implementing more detailed performance tracking and visualization (e.g., nodes explored, time taken) would provide quantitative insights into algorithm efficiency.

Closing Thoughts:

This Pathfinding Visualizer project has been a fascinating exploration of the intersection between theoretical computer science and practical web development. It stands as a testament to the power of interactive visualization in education and has opened up numerous possibilities for future exploration and learning.

As I continue to refine and expand this tool, I'm excited about its potential to help others grasp these fundamental algorithms more intuitively. The project has reinforced my belief in the importance of creating tools that not only solve problems but also illuminate the underlying principles at work.

I invite fellow developers and enthusiasts to explore the code, suggest improvements, and perhaps even contribute to its evolution. Together, we can create even more powerful tools for understanding the fascinating world of algorithms and computer science.

Thank you for joining me on this journey through the creation of the Pathfinding Visualizer. I hope it inspires you to explore, learn, and create your own tools for understanding complex concepts.

Luis Velazquez Bilbao
Software Engineer