Files
UT-Registration-Plus/src/views/components/map/PathFinder.ts
doprz 218477404f feat: map page (#390)
* feat: add boilerplate

* feat: add working paths

* feat: improve building selection controls and add week schedule

Signed-off-by: doprz <52579214+doprz@users.noreply.github.com>

* fix: sort week schedule

Signed-off-by: doprz <52579214+doprz@users.noreply.github.com>

* feat(testing): improve pathfinding

* Revert "feat(testing): improve pathfinding"

This reverts commit 87998cedbf.

* feat: add pathfinding with building selection controls

Signed-off-by: doprz <52579214+doprz@users.noreply.github.com>

* feat: improve path finding algorithm thresholds

* feat: add DaySelector, PathStats, and WeekSchedule components

* feat: add working stats and daily schedule

* chore: refactor everything

* feat: add linear walkway node generation

* feat: add bezier curve walkway node generation

* feat: add circular walkway node generation

* docs: add docs

* feat: add individual path selection and bump version

* fix: tsdoc and updated components/utils

* chore(deps): update deps

* feat: add UTRP Map and Debug Page to Settings > Developer Mode

* chore: fix pr review comments

* chore: add showDebugNodes

* chore: add all buildings around the UT tower

* chore: add stadium POIs

* chore: add east mall buildings

* chore: update DaySelector to use proper button styling

* chore: add university ave walkway

* feat: add zoom, pan, and dev controls functionality

- Fix SVG Overlay Alignment
- Use SVG for map
- Add Dev Controls
- Fix day selector position
- Update the SVG's `preserveAspectRatio` attribute to `xMidYMid` meet to
ensure proper scaling
- Use `useCallback` for event handlers to prevent unnecessary re-renders
- Remove old PNG map

* feat: add dynamic rendering"

* feat: add dynamicRendering dev toggle and fullscreen support

* chore: update deps

* chore: disable viewport svg overlay culling if dynamic rendering is off

* chore: update pnpm-lock.yaml

* chore: add north mall buildings

* chore: add buildings next to JES

* refactor: map components into individual files

* fix: missing import

---------

Signed-off-by: doprz <52579214+doprz@users.noreply.github.com>
2025-02-27 19:44:03 -06:00

194 lines
6.4 KiB
TypeScript

/* eslint-disable max-classes-per-file */
import {
DIRECT_PATH_THRESHOLD,
type DistanceMap,
type Graph,
isValidNode,
type MapNode,
NEIGHBOR_DISTANCE_THRESHOLD,
type NodeId,
type PreviousMap,
} from './types';
import { calculateDistance, getNeighbors } from './utils';
/**
* Custom error class for handling pathfinding errors.
*/
export class PathFindingError extends Error {
constructor(message: string) {
super(message);
this.name = 'PathFindingError';
}
}
/**
* The `PathFinder` class is responsible for finding the shortest path between nodes in a graph.
* It uses Dijkstra's algorithm to compute the shortest path and supports bidirectional connections.
*/
export class PathFinder {
private graph: Graph;
private nodeConnections: Map<NodeId, Set<NodeId>>;
constructor(graph: Graph) {
this.graph = graph;
this.nodeConnections = this.buildNodeConnections();
}
private buildNodeConnections(): Map<NodeId, Set<NodeId>> {
const connections = new Map<NodeId, Set<NodeId>>();
// Initialize connections for each node
Object.keys(this.graph).forEach(nodeId => {
connections.set(nodeId, new Set<NodeId>());
});
// Build bidirectional connections
Object.keys(this.graph).forEach(nodeId => {
const neighbors = getNeighbors(nodeId, this.graph);
neighbors.forEach(neighbor => {
connections.get(nodeId)?.add(neighbor);
connections.get(neighbor)?.add(nodeId);
});
});
return connections;
}
public findPath(startId: NodeId, endId: NodeId): NodeId[] {
const startNode = this.graph[startId];
const endNode = this.graph[endId];
// Validate input
if (!isValidNode(startNode)) {
throw new PathFindingError(`Invalid start node: ${startId}`);
}
if (!isValidNode(endNode)) {
throw new PathFindingError(`Invalid end node: ${endId}`);
}
// Check for direct path possibility
if (this.shouldUseDirectPath(startNode, endNode)) {
return [startId, endId];
}
// Initialize Dijkstra's algorithm data structures
const distances = Object.keys(this.graph).reduce<DistanceMap>((acc, nodeId) => {
acc[nodeId] = nodeId === startId ? 0 : Infinity;
return acc;
}, {});
const previous = Object.keys(this.graph).reduce<PreviousMap>((acc, nodeId) => {
acc[nodeId] = null;
return acc;
}, {});
const unvisited = new Set(Object.keys(this.graph));
// Main Dijkstra's algorithm loop
while (unvisited.size > 0) {
const current = this.getMinDistanceNode(distances, unvisited);
if (current === null) break;
if (current === endId) break;
unvisited.delete(current);
// Use pre-computed connections
const neighbors = this.nodeConnections.get(current) ?? new Set<NodeId>();
neighbors.forEach(neighbor => {
if (!unvisited.has(neighbor)) return;
const currentNode = this.graph[current];
const neighborNode = this.graph[neighbor];
if (!isValidNode(currentNode) || !isValidNode(neighborNode)) return;
const distance = calculateDistance(currentNode, neighborNode);
const totalDistance = distances[current]! + distance;
if (totalDistance < distances[neighbor]!) {
distances[neighbor] = totalDistance;
previous[neighbor] = current;
}
});
}
// Reconstruct and validate path
const path = this.reconstructPath(previous, startId, endId);
// Verify path distance is reasonable
const totalPathDistance = this.calculatePathDistance(path);
if (totalPathDistance > NEIGHBOR_DISTANCE_THRESHOLD * path.length) {
console.warn(`Long path detected (${totalPathDistance.toFixed(2)} units) from ${startId} to ${endId}`);
}
return path;
}
private calculatePathDistance(path: NodeId[]): number {
let totalDistance = 0;
for (let i = 0; i < path.length - 1; i++) {
const currentNode = this.graph[path[i]!];
const nextNode = this.graph[path[i + 1]!];
if (isValidNode(currentNode) && isValidNode(nextNode)) {
totalDistance += calculateDistance(currentNode, nextNode);
}
}
return totalDistance;
}
private shouldUseDirectPath(start: MapNode, end: MapNode): boolean {
const distance = calculateDistance(start, end);
return distance <= DIRECT_PATH_THRESHOLD;
}
private getMinDistanceNode(distances: DistanceMap, unvisited: Set<NodeId>): NodeId | null {
let minDistance = Infinity;
let minNode: NodeId | null = null;
unvisited.forEach(nodeId => {
const distance = distances[nodeId] ?? Infinity;
if (distance < minDistance) {
minDistance = distance;
minNode = nodeId;
}
});
return minNode;
}
private reconstructPath(previous: PreviousMap, startId: NodeId, endId: NodeId): NodeId[] {
const path: NodeId[] = [];
let currentNode: NodeId | null = endId;
// Keep track of visited nodes to prevent infinite loops
const visited = new Set<NodeId>();
while (currentNode !== null) {
// Prevent infinite loops
if (visited.has(currentNode)) {
throw new PathFindingError('Circular path detected during reconstruction');
}
visited.add(currentNode);
path.unshift(currentNode);
const prevNode: NodeId | null = previous[currentNode] ?? null;
// If we can't find the previous node and we haven't reached the start,
// then the path is broken
if (prevNode === undefined) {
throw new PathFindingError('Path reconstruction failed: broken path chain');
}
currentNode = prevNode;
}
// Verify that we actually found a path to the start
if (path[0] !== startId) {
throw new PathFindingError('No valid path found between the specified nodes');
}
return path;
}
}