UNPKG

minotor

Version:

A lightweight client-side transit routing library.

581 lines (507 loc) 17.6 kB
import { StopId } from '../stops/stops.js'; import { durationToString, timeToString } from '../timetable/time.js'; import { routeTypeToString } from '../timetable/timetable.js'; import { Result } from './result.js'; import { AccessEdge, RoutingEdge, TransferEdge, VehicleEdge, } from './router.js'; /** * Configuration for DOT graph styling. */ const DOT_CONFIG = { colors: { rounds: [ '#60a5fa', // Round 1 - Blue '#ff9800', // Round 2 - Orange '#14b8a6', // Round 3 - Teal '#fb7185', // Round 4 - Pink '#ffdf00', // Round 5 - Yellow '#b600ff', // Round 6 - Purple '#ee82ee', // Round 7+ - Violet ], defaultRound: '#888888', originStation: '#60a5fa', destinationStation: '#ee82ee', defaultStation: 'white', continuationFill: '#ffffcc', }, penWidth: { default: 1, continuation: 2, continuationEdge: 3, }, } as const; /** * Type guard to check if an edge is a VehicleEdge. */ function isVehicleEdge(edge: RoutingEdge): edge is VehicleEdge { return 'routeId' in edge && 'stopIndex' in edge && 'hopOffStopIndex' in edge; } /** * Type guard to check if an edge is a TransferEdge. */ function isTransferEdge(edge: RoutingEdge): edge is TransferEdge { return 'from' in edge && 'to' in edge && 'type' in edge; } /** * Type guard to check if an edge is an AccessEdge (walking access leg). */ function isAccessEdge(edge: RoutingEdge): edge is AccessEdge { return 'from' in edge && 'duration' in edge; } /** * Helper class for building DOT graph syntax. */ class DotBuilder { private lines: string[] = []; /** * Adds the DOT graph header with default styling. */ addHeader(): this { this.lines.push( 'digraph RoutingGraph {', ' graph [overlap=false, splines=true, rankdir=TB, bgcolor=white, nodesep=0.8, ranksep=1.2, concentrate=true];', ' node [fontname="Arial" margin=0.1];', ' edge [fontname="Arial" fontsize=10];', ); return this; } /** * Adds a comment section to the graph. */ addComment(comment: string): this { this.lines.push('', ` // ${comment}`); return this; } /** * Adds a node with the given attributes. */ addNode(id: string, attrs: Record<string, string>): this { const attrStr = Object.entries(attrs) .map(([k, v]) => `${k}="${v}"`) .join(' '); this.lines.push(` "${id}" [${attrStr}];`); return this; } /** * Adds an edge between two nodes with optional attributes. */ addEdge(from: string, to: string, attrs: Record<string, string> = {}): this { const attrStr = Object.entries(attrs) .map(([k, v]) => `${k}="${v}"`) .join(' '); const attrPart = attrStr ? ` [${attrStr}]` : ''; this.lines.push(` "${from}" -> "${to}"${attrPart};`); return this; } /** * Adds raw lines to the graph. */ addRaw(lines: string[]): this { this.lines.push(...lines); return this; } /** * Builds the final DOT graph string. */ build(): string { this.lines.push('}'); return this.lines.join('\n'); } } /** * Generates DOT graph visualizations of routing results. * * The generated graph shows: * - Stations as rectangular nodes (origin=blue, destination=violet) * - Vehicle edges as ovals with route info * - Transfer edges as dashed ovals * - Continuation edges (same-station transfers) as bold yellow ovals * * @example * ```typescript * const plotter = new Plotter(routingResult); * const dotGraph = plotter.plotDotGraph(); * // Use with Graphviz: dot -Tpng -o graph.png * ``` */ export class Plotter { private result: Result; constructor(result: Result) { this.result = result; } /** * Generates a unique node ID for a station. */ private stationNodeId(stopId: StopId): string { return `s_${stopId}`; } /** * Generates a unique node ID for a vehicle edge oval. */ private vehicleEdgeNodeId( fromStopId: StopId, toStopId: StopId, routeId: number, round: number, ): string { return `e_${fromStopId}_${toStopId}_${routeId}_${round}`; } /** * Generates a unique node ID for a transfer edge oval. */ private transferEdgeNodeId( fromStopId: StopId, toStopId: StopId, round: number, ): string { return `e_${fromStopId}_${toStopId}_${round}`; } /** * Generates a unique node ID for a walking access edge oval. */ private accessEdgeNodeId(fromStopId: StopId, toStopId: StopId): string { return `access_${fromStopId}_${toStopId}`; } /** * Generates a unique node ID for a continuation edge oval. */ private continuationNodeId( fromStopId: StopId, toStopId: StopId, round: number, ): string { return `continuation_${fromStopId}_${toStopId}_${round}`; } /** * Gets the color for a round based on the configured palette. */ private getRoundColor(round: number): string { if (round === 0) { return DOT_CONFIG.colors.defaultRound; } const colorIndex = Math.min(round - 1, DOT_CONFIG.colors.rounds.length - 1); return DOT_CONFIG.colors.rounds[colorIndex] ?? '#ee82ee'; } /** * Gets the appropriate fill color for a station based on its type. */ private getStationFillColor( isOrigin: boolean, isDestination: boolean, ): string { if (isOrigin) { return DOT_CONFIG.colors.originStation; } if (isDestination) { return DOT_CONFIG.colors.destinationStation; } return DOT_CONFIG.colors.defaultStation; } /** * Escapes special characters in DOT strings to prevent syntax errors. */ private escapeDotString(str: string): string { return str .replace(/\\/g, '\\\\') .replace(/"/g, '\\"') .replace(/\n/g, '\\n') .replace(/\r/g, '\\r') .replace(/\t/g, '\\t'); } /** * Formats a stop name for display, including platform information. */ private formatStopName(stopId: StopId): string { const stop = this.result.stopsIndex.findStopById(stopId); if (!stop) { return `Unknown Stop (${stopId})`; } const escapedName = this.escapeDotString(stop.name); const escapedPlatform = stop.platform ? this.escapeDotString(stop.platform) : ''; return escapedPlatform ? `${escapedName}\\nPl. ${escapedPlatform}` : escapedName; } /** * Determines station type (origin/destination) information. */ private getStationInfo(stopId: StopId): { isOrigin: boolean; isDestination: boolean; } { const isOrigin = this.result.routingState.graph[0]?.[stopId] !== undefined; const isDestination = this.result.routingState.destinations.includes(stopId); return { isOrigin, isDestination }; } /** * Resolves the actual StopId from a VehicleEdge's stopIndex. */ private getVehicleEdgeFromStopId(edge: VehicleEdge): StopId | undefined { const route = this.result.timetable.getRoute(edge.routeId); return route?.stopId(edge.stopIndex); } /** * Resolves the actual StopId from a VehicleEdge's hopOffStopIndex. */ private getVehicleEdgeToStopId(edge: VehicleEdge): StopId | undefined { const route = this.result.timetable.getRoute(edge.routeId); return route?.stopId(edge.hopOffStopIndex); } /** * Creates a DOT node for a station. */ private createStationNode(stopId: StopId): string | null { const stop = this.result.stopsIndex.findStopById(stopId); if (!stop) { return null; } const displayName = this.formatStopName(stopId); const stopIdStr = this.escapeDotString(String(stopId)); const nodeId = this.stationNodeId(stopId); const stationInfo = this.getStationInfo(stopId); const fillColor = this.getStationFillColor( stationInfo.isOrigin, stationInfo.isDestination, ); return ` "${nodeId}" [label="${displayName}\\n${stopIdStr}" shape=box style=filled fillcolor="${fillColor}"];`; } /** * Creates a vehicle edge with route information oval in the middle. */ private createVehicleEdge(edge: VehicleEdge, round: number): string[] { const route = this.result.timetable.getRoute(edge.routeId); if (!route) { return []; } const fromStopId = route.stopId(edge.stopIndex); const toStopId = route.stopId(edge.hopOffStopIndex); const fromNodeId = this.stationNodeId(fromStopId); const toNodeId = this.stationNodeId(toStopId); const roundColor = this.getRoundColor(round); const routeOvalId = this.vehicleEdgeNodeId( fromStopId, toStopId, edge.routeId, round, ); const serviceRouteInfo = this.result.timetable.getServiceRouteInfo(route); const routeName = serviceRouteInfo.name; const routeType = routeTypeToString(serviceRouteInfo.type); const departureTime = timeToString( route.departureFrom(edge.stopIndex, edge.tripIndex), ); const arrivalTime = timeToString(edge.arrival); const escapedRouteName = this.escapeDotString(routeName); const escapedRouteType = this.escapeDotString(routeType); const routeInfo = `${edge.routeId}:${edge.tripIndex}`; const ovalLabel = `${escapedRouteType} ${escapedRouteName}\\n${routeInfo}\\n${departureTime} → ${arrivalTime}`; return [ ` "${routeOvalId}" [label="${ovalLabel}" shape=oval style=filled fillcolor="white" color="${roundColor}"];`, ` "${fromNodeId}" -> "${routeOvalId}" [color="${roundColor}"];`, ` "${routeOvalId}" -> "${toNodeId}" [color="${roundColor}"];`, ]; } /** * Creates a walking access leg as a dashed oval connecting the query origin * to the initial boarding stop. */ private createAccessEdge(edge: AccessEdge): string[] { const fromNodeId = this.stationNodeId(edge.from); const toNodeId = this.stationNodeId(edge.to); const color = DOT_CONFIG.colors.defaultRound; const ovalId = this.accessEdgeNodeId(edge.from, edge.to); const label = `Walk\\n${durationToString(edge.duration)}`; return [ ` "${ovalId}" [label="${label}" shape=oval style="dashed,filled" fillcolor="white" color="${color}"];`, ` "${fromNodeId}" -> "${ovalId}" [color="${color}" style="dashed"];`, ` "${ovalId}" -> "${toNodeId}" [color="${color}" style="dashed"];`, ]; } /** * Creates a transfer edge with transfer information oval in the middle. */ private createTransferEdge(edge: TransferEdge, round: number): string[] { const fromNodeId = this.stationNodeId(edge.from); const toNodeId = this.stationNodeId(edge.to); const roundColor = this.getRoundColor(round); const transferOvalId = this.transferEdgeNodeId(edge.from, edge.to, round); const transferTime = edge.minTransferTime !== undefined ? durationToString(edge.minTransferTime) : 'N/A'; const escapedTransferTime = this.escapeDotString(transferTime); const ovalLabel = `Transfer\\n${escapedTransferTime}`; return [ ` "${transferOvalId}" [label="${ovalLabel}" shape=oval style="dashed,filled" fillcolor="white" color="${roundColor}"];`, ` "${fromNodeId}" -> "${transferOvalId}" [color="${roundColor}" style="dashed"];`, ` "${transferOvalId}" -> "${toNodeId}" [color="${roundColor}" style="dashed"];`, ]; } /** * Creates a continuation edge to visually link trip continuations. */ private createContinuationEdge( fromEdge: VehicleEdge, toEdge: VehicleEdge, round: number, ): string[] { const fromStopId = this.getVehicleEdgeToStopId(fromEdge); const toStopId = this.getVehicleEdgeFromStopId(toEdge); if (!fromStopId || !toStopId) { return []; } const fromStationId = this.stationNodeId(fromStopId); const toStationId = this.stationNodeId(toStopId); const roundColor = this.getRoundColor(round); const continuationOvalId = this.continuationNodeId( fromStopId, toStopId, round, ); const fromRoute = this.result.timetable.getRoute(fromEdge.routeId); const toRoute = this.result.timetable.getRoute(toEdge.routeId); const fromServiceRouteInfo = fromRoute ? this.result.timetable.getServiceRouteInfo(fromRoute) : null; const toServiceRouteInfo = toRoute ? this.result.timetable.getServiceRouteInfo(toRoute) : null; const fromRouteName = fromServiceRouteInfo?.name ?? `Route ${String(fromEdge.routeId)}`; const toRouteName = toServiceRouteInfo?.name ?? `Route ${String(toEdge.routeId)}`; const fromRouteType = fromServiceRouteInfo ? routeTypeToString(fromServiceRouteInfo.type) : 'UNKNOWN'; const toRouteType = toServiceRouteInfo ? routeTypeToString(toServiceRouteInfo.type) : 'UNKNOWN'; const fromArrivalTime = timeToString(fromEdge.arrival); const toDepartureTime = toRoute ? timeToString(toRoute.departureFrom(toEdge.stopIndex, toEdge.tripIndex)) : 'N/A'; const escapedFromRouteName = this.escapeDotString(fromRouteName); const escapedToRouteName = this.escapeDotString(toRouteName); const escapedFromRouteType = this.escapeDotString(fromRouteType); const escapedToRouteType = this.escapeDotString(toRouteType); const fromRouteInfo = `${fromEdge.routeId}:${fromEdge.tripIndex}`; const toRouteInfo = `${toEdge.routeId}:${toEdge.tripIndex}`; const ovalLabel = `${escapedFromRouteType} ${escapedFromRouteName} (${fromRouteInfo}) ${fromArrivalTime}\\n↓\\n${escapedToRouteType} ${escapedToRouteName} (${toRouteInfo}) ${toDepartureTime}`; const { continuationFill } = DOT_CONFIG.colors; const { continuation: penWidth, continuationEdge: edgePenWidth } = DOT_CONFIG.penWidth; return [ ` "${continuationOvalId}" [label="${ovalLabel}" shape=oval style="filled,bold" fillcolor="${continuationFill}" color="${roundColor}" penwidth="${penWidth}"];`, ` "${fromStationId}" -> "${continuationOvalId}" [color="${roundColor}" style="bold" penwidth="${edgePenWidth}"];`, ` "${continuationOvalId}" -> "${toStationId}" [color="${roundColor}" style="bold" penwidth="${edgePenWidth}"];`, ]; } /** * Collects all stations that appear in the routing graph. */ private collectStations(): Set<StopId> { const stations = new Set<StopId>(); const graph = this.result.routingState.graph; for (const roundEdges of graph) { for (let stopId = 0; stopId < roundEdges.length; stopId++) { const edge = roundEdges[stopId]; if (edge === undefined) continue; stations.add(stopId); if (isVehicleEdge(edge)) { const fromStopId = this.getVehicleEdgeFromStopId(edge); const toStopId = this.getVehicleEdgeToStopId(edge); if (fromStopId) stations.add(fromStopId); if (toStopId) stations.add(toStopId); } else if (isAccessEdge(edge)) { // Ensure the query origin (edge.from) is always collected even when // its own OriginNode hasn't been processed yet in this iteration. stations.add(edge.from); stations.add(edge.to); } } } return stations; } /** * Collects all continuation edges from a vehicle edge chain. */ private collectContinuationChain(edge: VehicleEdge, round: number): string[] { const continuationEdges: string[] = []; let currentEdge = edge; let previousEdge = edge.continuationOf; while (previousEdge) { const edgeParts = this.createContinuationEdge( previousEdge, currentEdge, round, ); continuationEdges.push(...edgeParts); currentEdge = previousEdge; previousEdge = previousEdge.continuationOf; } return continuationEdges; } /** * Collects all edges for the routing graph. */ private collectEdges(): string[] { const edges: string[] = []; const continuationEdges: string[] = []; const graph = this.result.routingState.graph; for (let round = 0; round < graph.length; round++) { const roundEdges = graph[round]; if (!roundEdges) continue; for (let stopId = 0; stopId < roundEdges.length; stopId++) { const edge = roundEdges[stopId]; if (edge === undefined) continue; if (round === 0) { // Round 0 holds OriginNodes (no edge to draw) and AccessEdges // (walking legs from the query origin to the first boarding stop). if (isAccessEdge(edge)) { edges.push(...this.createAccessEdge(edge)); } continue; } if (isVehicleEdge(edge)) { edges.push(...this.createVehicleEdge(edge, round)); if (edge.continuationOf) { continuationEdges.push( ...this.collectContinuationChain(edge, round), ); } } else if (isTransferEdge(edge)) { edges.push(...this.createTransferEdge(edge, round)); } } } return [...edges, ...continuationEdges]; } /** * Plots the routing graph as a DOT graph for visualization. * * @returns A string containing the DOT graph representation. */ plotDotGraph(): string { const stations = this.collectStations(); const edges = this.collectEdges(); const builder = new DotBuilder(); builder.addHeader(); builder.addComment('Stations'); for (const stopId of stations) { const stationNode = this.createStationNode(stopId); if (stationNode) { builder.addRaw([stationNode]); } } builder.addComment('Edges'); builder.addRaw(edges); return builder.build(); } }