Optimizations (#15)

- Add walking transfers
- Pick route with minimal walking distance, instead of stop count
- Turned `shared` into the new server, client no longer relies on `server`
- Prepare to send full route data (instead of string) to the client
This commit is contained in:
Levi Lesches 2025-05-04 03:19:06 -04:00 committed by GitHub
parent d0387c7aa2
commit e8de6475c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2536 additions and 2366 deletions

View file

@ -16,13 +16,19 @@ class HomePage extends ReactiveWidget<HomeModel> {
body: Row( body: Row(
children: [ children: [
SizedBox( SizedBox(
width: 300, width: 325,
child: Sidebar(model), child: Sidebar(model),
), ),
Expanded( Expanded(
child: Card( child: Card(
child: Column( child: Column(
children: [ children: [
SwitchListTile(
value: model.markerState == MarkerState.override,
onChanged: model.overrideMarkers,
title: const Text("Show stops list"),
subtitle: const Text("To select a start or end stop, use the buttons below"),
),
LatLongEditor( LatLongEditor(
latitudeController: model.startLatitudeController, latitudeController: model.startLatitudeController,
longitudeController: model.startLongitudeController, longitudeController: model.startLongitudeController,
@ -65,7 +71,7 @@ class HomePage extends ReactiveWidget<HomeModel> {
markers: model.markers, markers: model.markers,
onTap: model.onMapTapped, onTap: model.onMapTapped,
polylines: { polylines: {
for (final (index, route) in model.routes.indexed) Polyline( for (final (index, route) in model.paths.indexed) Polyline(
polylineId: PolylineId(index.toString()), polylineId: PolylineId(index.toString()),
color: routeColors[index], color: routeColors[index],
points: route, points: route,

View file

@ -17,36 +17,13 @@ class ApiService extends Service {
Uri get _base => usingDocker Uri get _base => usingDocker
? Uri(path: "api/") ? Uri(path: "api/")
: Uri(scheme: "http", host: "localhost", port: 80); : Uri(scheme: "http", host: "localhost", port: 8001);
Future<List<Trip>?> getTrips() => _client.getJsonList( Future<String?> getPath({
_base.resolve("trips"),
Trip.fromJson,
key: "path",
);
Future<Path?> getPath({
required Coordinates start,
required Coordinates end,
}) => _client.getJsonList(
_base.resolve("path").replace(
queryParameters: {
"start_lat": start.lat.toString(),
"start_lon": start.long.toString(),
"end_lat": end.lat.toString(),
"end_lon": end.long.toString(),
"time": DateTime.now().millisecondsSinceEpoch.toString(),
},
),
key: "path",
PathStep.fromJson,
);
Future<String?> getPath2({
required Coordinates start, required Coordinates start,
required Coordinates end, required Coordinates end,
}) async { }) async {
final uri = Uri.parse("http://localhost:8001/path").replace( final uri = _base.resolve("/path").replace(
queryParameters: { queryParameters: {
"start_lat": start.lat.toString(), "start_lat": start.lat.toString(),
"start_lon": start.long.toString(), "start_lon": start.long.toString(),
@ -64,8 +41,13 @@ class ApiService extends Service {
} }
} }
Future<List<Route>?> getRoutes() => _client.getJsonList(
_base.resolve("/routes"),
Route.fromJson,
);
Future<List<Stop>?> getStops() => _client.getJsonList( Future<List<Stop>?> getStops() => _client.getJsonList(
_base.resolve("stops"), _base.resolve("/stops"),
Stop.fromJson, Stop.fromJson,
); );
} }

View file

@ -26,7 +26,7 @@ class HomeModel extends ViewModel with HomeMarkers {
bool isSearching = false; bool isSearching = false;
bool isGoogleReady = false; bool isGoogleReady = false;
List<List<LatLng>> routes = []; List<List<LatLng>> paths = [];
@override @override
Future<void> init() async { Future<void> init() async {
@ -39,14 +39,15 @@ class HomeModel extends ViewModel with HomeMarkers {
void onMapTapped(LatLng coordinates) { void onMapTapped(LatLng coordinates) {
if (!shouldShowMarkers) return; if (!shouldShowMarkers) return;
final marker = Marker( final marker = Marker(
markerId: markerId, markerId: markerId ?? const MarkerId("Tapped"),
position: coordinates, position: coordinates,
icon: markerIcon, icon: markerIcon ?? BitmapDescriptor.defaultMarker,
infoWindow: InfoWindow(title: "$startOrEnd here"), infoWindow: InfoWindow(title: "$startOrEnd here"),
); );
switch (markerState!) { switch (markerState!) {
case MarkerState.start: updateStart(coordinates, marker); case MarkerState.start: updateStart(coordinates, marker);
case MarkerState.end: updateEnd(coordinates, marker); case MarkerState.end: updateEnd(coordinates, marker);
case MarkerState.override: return;
} }
} }
@ -72,17 +73,11 @@ class HomeModel extends ViewModel with HomeMarkers {
if (end.lat == null || end.long == null) return; if (end.lat == null || end.long == null) return;
isSearching = true; isSearching = true;
isLoading = true; isLoading = true;
final result = await services.api.getPath2(start: start as Coordinates, end: end as Coordinates); final result = await services.api.getPath(start: start as Coordinates, end: end as Coordinates);
pathText = result ?? "An error occurred"; pathText = result ?? "An error occurred";
// path = result;
isLoading = false; isLoading = false;
if (result == null) return; if (result == null) return;
isSearching = false; isSearching = false;
// routes = [
// for (final step in result) [
// ...decodePolyline(step.trip.polyline),
// ],
// ];
notifyListeners(); notifyListeners();
} }
} }

View file

@ -5,20 +5,27 @@ import "package:google_maps_flutter/google_maps_flutter.dart";
enum MarkerState { enum MarkerState {
start, start,
end; end,
override;
} }
mixin HomeMarkers on ChangeNotifier { mixin HomeMarkers on ChangeNotifier {
MarkerState? markerState; MarkerState? markerState;
bool get shouldShowMarkers => markerState != null; bool get shouldShowMarkers => markerState != null;
List<Stop>? stops; // List<Stop>? stops;
Map<StopID, Stop> stops = {};
Map<RouteID, Route> routes = {};
Marker? _startMarker; Marker? _startMarker;
Marker? _endMarker; Marker? _endMarker;
Iterable<Stop> getStopsForRoute(RouteID routeID) => routes[routeID]!.stops
.map((stopID) => stops[stopID]!);
Set<Marker> get _filteredMarkers => { Set<Marker> get _filteredMarkers => {
for (final stop in stops ?? <Stop>[]) // for (final stop in stops ?? <Stop>[])
if (stop.routes.any(routesToShow.contains)) for (final routeID in routesToShow)
for (final stop in getStopsForRoute(routeID))
Marker( Marker(
markerId: MarkerId(stop.name), markerId: MarkerId(stop.name),
position: stop.coordinates.toLatLng(), position: stop.coordinates.toLatLng(),
@ -36,55 +43,67 @@ mixin HomeMarkers on ChangeNotifier {
if (_endMarker != null) _endMarker!, if (_endMarker != null) _endMarker!,
}; };
Set<String> bcRouteNames = {}; List<Route> bcRouteNames = [];
Set<String> occtRouteNames = {}; List<Route> occtRouteNames = [];
Set<String> routesToShow = {}; Set<RouteID> routesToShow = {};
Map<String, int> stopCounts = {}; Iterable<(String, List<Route>)> get providers => [
Iterable<(String, List<String>)> get providers => [ ("OCCT", occtRouteNames),
("OCCT", occtRouteNames.toList()..sort()), ("BC Transit", bcRouteNames),
("BC Transit", bcRouteNames.toList()..sort(compareNums)),
]; ];
int parseBcNumber(String routeName) { int _parseBcNumber(String routeName) {
// eg, "53)" --> 53 // eg, "53)" --> 53
final first = routeName.split(" ").first; final first = routeName.split(" ").first;
final withoutParen = first.substring(0, first.length - 1); final withoutParen = first.substring(0, first.length - 1);
return int.parse(withoutParen); return int.parse(withoutParen);
} }
int compareNums(String a, String b) => int compareBcRoutes(Route a, Route b) =>
parseBcNumber(a).compareTo(parseBcNumber(b)); _parseBcNumber(a.shortName).compareTo(_parseBcNumber(b.shortName));
MarkerId get markerId => switch (markerState!) { int compareOcctRoutes(Route a, Route b) =>
a.shortName.compareTo(b.shortName);
MarkerId? get markerId => switch (markerState!) {
MarkerState.start => const MarkerId("start"), MarkerState.start => const MarkerId("start"),
MarkerState.end => const MarkerId("end"), MarkerState.end => const MarkerId("end"),
_ => null,
}; };
BitmapDescriptor get markerIcon => switch (markerState!) { BitmapDescriptor? get markerIcon => switch (markerState!) {
MarkerState.start => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan), MarkerState.start => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan),
MarkerState.end => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen), MarkerState.end => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen),
_ => null,
}; };
String get startOrEnd => switch (markerState!) { String? get startOrEnd => switch (markerState!) {
MarkerState.start => "Start", MarkerState.start => "Start",
MarkerState.end => "End", MarkerState.end => "End",
_ => null,
}; };
Future<void> updateMarkers() async { Future<void> updateMarkers() async {
stops = await services.api.getStops(); final stopsResponse = await services.api.getStops();
if (stops == null) return; if (stopsResponse == null) return;
for (final stop in stops!) { stops = {
for (final route in stop.routes) { for (final stop in stopsResponse)
stopCounts[route] ??= 0; stop.id: stop,
stopCounts[route] = stopCounts[route]! + 1; };
} final routesResponse = await services.api.getRoutes();
final namesList = switch (stop.provider) { if (routesResponse == null) return;
routes = {
for (final route in routesResponse)
route.id: route,
};
for (final route in routesResponse) {
final namesList = switch (route.provider) {
Provider.occt => occtRouteNames, Provider.occt => occtRouteNames,
Provider.bc => bcRouteNames, Provider.bc => bcRouteNames,
}; };
namesList.addAll(stop.routes); namesList.add(route);
} }
notifyListeners(); occtRouteNames.sort(compareOcctRoutes);
bcRouteNames.sort(compareBcRoutes);
} }
void showMarkers(MarkerState newState) { void showMarkers(MarkerState newState) {
@ -100,20 +119,28 @@ mixin HomeMarkers on ChangeNotifier {
void onMarkerTapped(Stop stop) { void onMarkerTapped(Stop stop) {
final coordinates = stop.coordinates.toLatLng(); final coordinates = stop.coordinates.toLatLng();
final marker = Marker( final marker = Marker(
markerId: markerId, markerId: markerId ?? MarkerId(stop.name),
position: coordinates, position: coordinates,
infoWindow: InfoWindow( infoWindow: InfoWindow(
title: "$startOrEnd at ${stop.name}", title: "$startOrEnd at ${stop.name}",
snippet: stop.description, snippet: stop.description,
), ),
icon: markerIcon, icon: markerIcon ?? BitmapDescriptor.defaultMarker,
); );
switch (markerState!) { switch (markerState!) {
case MarkerState.start: updateStart(coordinates, marker); case MarkerState.start: updateStart(coordinates, marker);
case MarkerState.end: updateEnd(coordinates, marker); case MarkerState.end: updateEnd(coordinates, marker);
case MarkerState.override: return;
} }
} }
// Flutter widget
// ignore: avoid_positional_boolean_parameters
void overrideMarkers(bool value) {
markerState = value ? MarkerState.override : null;
notifyListeners();
}
@mustCallSuper @mustCallSuper
void updateStart(LatLng coordinates, Marker marker) { void updateStart(LatLng coordinates, Marker marker) {
_startMarker = marker; _startMarker = marker;
@ -128,11 +155,11 @@ mixin HomeMarkers on ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void showRoute(String route, {required bool shouldShow}) { void showRoute(Route route, {required bool shouldShow}) {
if (shouldShow) { if (shouldShow) {
routesToShow.add(route); routesToShow.add(route.id);
} else { } else {
routesToShow.remove(route); routesToShow.remove(route.id);
} }
notifyListeners(); notifyListeners();
} }

View file

@ -13,6 +13,7 @@ class Sidebar extends ReusableReactiveWidget<HomeModel> {
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 16), const SizedBox(height: 16),
if (model.markerState != null && model.markerState != MarkerState.override) ...[
Text( Text(
"Select routes", "Select routes",
maxLines: 1, maxLines: 1,
@ -25,6 +26,7 @@ class Sidebar extends ReusableReactiveWidget<HomeModel> {
style: context.textTheme.bodyMedium, style: context.textTheme.bodyMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
],
const SizedBox(height: 8), const SizedBox(height: 8),
if (model.shouldShowMarkers) TabBar( if (model.shouldShowMarkers) TabBar(
tabs: [ tabs: [
@ -33,7 +35,7 @@ class Sidebar extends ReusableReactiveWidget<HomeModel> {
], ],
), ),
if (!model.shouldShowMarkers && model.pathText != null) if (!model.shouldShowMarkers && model.pathText != null)
Center(child: Text(model.pathText!, style: context.textTheme.bodySmall)), SingleChildScrollView(child: Center(child: Text(model.pathText!, style: context.textTheme.bodySmall))),
if (!model.shouldShowMarkers && model.pathText == null) if (!model.shouldShowMarkers && model.pathText == null)
const Center(child: Text("Choose start or end location")), const Center(child: Text("Choose start or end location")),
if (model.shouldShowMarkers) Expanded( if (model.shouldShowMarkers) Expanded(
@ -43,12 +45,12 @@ class Sidebar extends ReusableReactiveWidget<HomeModel> {
ListView( ListView(
children: [ children: [
for (final route in routesList) CheckboxListTile( for (final route in routesList) CheckboxListTile(
title: Text(route, maxLines: 1), title: Text(route.fullName, maxLines: 1),
subtitle: Text( subtitle: Text(
"${model.stopCounts[route] ?? 0} stops", "${route.stops.length} stops",
maxLines: 1, maxLines: 1,
), ),
value: model.routesToShow.contains(route), value: model.routesToShow.contains(route.id),
onChanged: (value) => model.showRoute(route, shouldShow: value!), onChanged: (value) => model.showRoute(route, shouldShow: value!),
), ),
], ],

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2,3 +2,5 @@
# Created by `dart pub` # Created by `dart pub`
.dart_tool/ .dart_tool/
pubspec.lock pubspec.lock
path.log
notes.md

View file

@ -1,6 +1,11 @@
import "package:shared/generator.dart"; import "package:shared/generator.dart";
void main() async { void main(List<String> args) async {
if (args.contains("-n")) { // dry-run
await Generator.stops.parse();
await Generator.routes.parse();
} else {
await Generator.stops.generate(); await Generator.stops.generate();
await Generator.routes.generate(); await Generator.routes.generate();
}
} }

View file

@ -1,147 +0,0 @@
// User-facing script
// ignore_for_file: avoid_print
import "package:a_star/a_star.dart";
import "package:shared/generator.dart";
import "package:shared/graph.dart";
import "package:shelf_router/shelf_router.dart";
import "package:shelf/shelf.dart";
import "package:shelf/shelf_io.dart" as io;
// const startLat = 42.0924949645996;
// const startLong = -75.9538421630859;
// const endLat = 42.0869369506836;
// const endLong = -75.965934753418;
const startLat = 42.092083;
const startLong = -75.952271;
const endLat = 42.080822;
const endLong = -75.912529;
const numStops = 5;
void log(String message) {
// print(message);
}
Iterable<Stop> findStopsNear(Coordinates location) {
log("Finding stops near $location...");
final stopDistances = [
for (final stop in StopState.stops.values)
(stop, stop.coordinates.distanceTo(location)),
];
stopDistances.sort((a, b) => a.$2.compareTo(b.$2));
return stopDistances.take(numStops).map((pair) => pair.$1);
}
void printStops(Iterable<Stop> stops) {
log("Found stops:");
for (final stop in stops) {
log("- ${stop.routes}, ${stop.name}");
}
}
Set<RouteID> findRoutes(Iterable<Stop> stops) => {
for (final stop in stops)
...stop.routeIDs,
};
Stop findStopWithRoute(Iterable<Stop> stops, RouteID route) =>
stops.firstWhere((stop) => stop.routeIDs.contains(route));
Future<void> init() async {
print("Initializing...");
final stops = await Generator.stops.parse();
final routes = await Generator.routes.parse();
StopState.init(routes, stops);
}
final headers = {
"Access-Control-Allow-Origin": "*",
};
Future<Response> handleRequest(Request request) async {
final startLat = double.tryParse(request.url.queryParameters["start_lat"] ?? "");
final startLong = double.tryParse(request.url.queryParameters["start_lon"] ?? "");
final endLat = double.tryParse(request.url.queryParameters["end_lat"] ?? "");
final endLong = double.tryParse(request.url.queryParameters["end_lon"] ?? "");
if (startLat == null || startLong == null || endLat == null || endLong == null) {
return Response.badRequest(body: "Could not parse coordinates", headers: headers);
}
final start = (lat: startLat, long: startLong);
final end = (lat: endLat, long: endLong);
// 1) Find [numStops] nearest stops by the given locations
log("Finding nearest stops...");
final startStops = findStopsNear(start);
printStops(startStops);
final endStops = findStopsNear(end);
printStops(endStops);
// 2) Find all routes by those stops
log("Finding intersecting routes");
final startRoutes = findRoutes(startStops);
final endRoutes = findRoutes(endStops);
log("Start routes: $startRoutes");
log("End routes: $endRoutes");
// 3) Check for a direct route without transfers
final routesInCommon = startRoutes.intersection(endRoutes);
if (routesInCommon.isNotEmpty) {
final route = routesInCommon.first;
final startStop = findStopWithRoute(startStops, route);
final endStop = findStopWithRoute(endStops, route);
return Response.ok("\nBoard the $route at ${startStop.name} and get off at ${endStop.name}", headers: headers);
}
// 4) Use A* to find a route with transfers
log("Using A*");
final paths = <Iterable<StopState>>[];
for (final startStop in startStops) {
for (final startRoute in startStop.routeIDs) {
for (final endStop in endStops) {
final state = StopState(stopID: startStop.id, goalID: endStop.id, routeID: startRoute, depth: 0);
log("Finding a route using ${state.hash()}");
final result = aStar(state);
if (result == null) continue;
paths.add(result.reconstructPath());
}
}
}
if (paths.isEmpty) {
return Response.ok("No routes found", headers: headers);
}
final minPath = paths.min((path) => path.length);
final buffer = StringBuffer();
buffer.writeln("Found ${paths.length} paths, here's the best one with only ${minPath.length} steps:");
StopState? prevStep;
var index = 0;
for (final step in minPath) {
index++;
final stop = StopState.stops[step.stopID]!;
final route = StopState.routes[step.routeID]!;
if (prevStep == null) {
buffer.writeln("$index. Board the ${route.provider.humanName} ${route.fullName} bus\n at ${stop.name}");
} else if (index == minPath.length) {
buffer.writeln("$index. Get off at ${stop.name}");
} else if (prevStep.routeID != step.routeID) {
buffer.writeln("$index. Transfer to the ${route.provider.humanName} ${route.fullName}");
} else {
buffer.writeln("$index. Go to ${stop.name}");
}
prevStep = step;
}
return Response.ok(buffer.toString(), headers: headers);
}
void main() async {
await init();
final router = Router();
router.get("/path", handleRequest);
await io.serve(router.call, "localhost", 8001);
print("Listening on localhost:8001");
}

View file

@ -0,0 +1,25 @@
// User-facing script
// ignore_for_file: avoid_print
import "package:shared/generator.dart";
import "package:shared/graph.dart";
import "package:shared/server.dart" as handlers;
import "package:shelf_router/shelf_router.dart";
import "package:shelf/shelf_io.dart" as io;
Future<void> init() async {
print("Initializing...");
StopState.stops = await Generator.stops.parse();
StopState.routes = await Generator.routes.parse();
}
void main() async {
await init();
final router = Router();
router.get("/path", handlers.getPath);
router.get("/stops", handlers.getStops);
router.get("/routes", handlers.getRoutes);
await io.serve(router.call, "localhost", 8001);
print("Listening on localhost:8001");
}

18
src/shared/bin/test.dart Normal file
View file

@ -0,0 +1,18 @@
// User-facing script
// ignore_for_file: avoid_print
import "package:shared/generator.dart";
void main(List<String> args) async {
final provider = args.first;
final routeName = args.last;
final routes = await Generator.routes.parse();
final stops = await Generator.stops.parse();
final routeID = RouteID(Provider.fromJson(provider), routeName);
final route = routes[routeID]!;
print("Here are the stops for $route:");
for (final stopID in route.stops) {
final stop = stops[stopID]!;
print("- $stop");
}
}

View file

@ -1 +1,2 @@
export "src/graph/state.dart"; export "src/graph/state.dart";
export "src/graph/algorithm.dart";

View file

@ -0,0 +1,2 @@
export "src/server/misc.dart";
export "src/server/path.dart";

View file

@ -8,4 +8,7 @@ enum Provider {
final String id; final String id;
final String humanName; final String humanName;
@override
String toString() => humanName;
} }

View file

@ -34,7 +34,7 @@ class Route with Encodable {
fullName = json["name"], fullName = json["name"],
stops = [ stops = [
for (final stopID in (json["stops"] as List).cast<int>()) for (final stopID in (json["stops"] as List).cast<int>())
StopID(stopID.toString()), StopID(Provider.occt, stopID.toString()),
]; ];
Route.fromBcCsv(CsvRow csv) : Route.fromBcCsv(CsvRow csv) :
@ -44,6 +44,19 @@ class Route with Encodable {
fullName = "${csv[2]}) ${csv[3]}", fullName = "${csv[2]}) ${csv[3]}",
stops = []; stops = [];
Route.fromJson(Json json) :
id = RouteID.fromJson(json["id"]),
provider = Provider.fromJson(json["provider"]),
fullName = json["full_name"],
shortName = json["short_name"],
stops = [
for (final stopID in json["stops"])
StopID.fromJson(stopID),
];
@override
String toString() => "$provider $fullName";
@override @override
Json toJson() => { Json toJson() => {
"id": id, "id": id,

View file

@ -2,7 +2,8 @@ import "utils.dart";
import "route.dart"; import "route.dart";
import "provider.dart"; import "provider.dart";
extension type StopID(String id) { extension type StopID._(String id) {
StopID(Provider provider, Object value) : id = "${provider.id}_$value";
StopID.fromJson(dynamic value) : id = value.toString(); StopID.fromJson(dynamic value) : id = value.toString();
} }
@ -24,7 +25,7 @@ class Stop with Encodable {
}) : routes = {}, routeIDs = {}; }) : routes = {}, routeIDs = {};
Stop.fromJson(Json json) : Stop.fromJson(Json json) :
id = StopID(json["id"]), id = StopID.fromJson(json["id"]),
name = json["name"], name = json["name"],
description = json["description"], description = json["description"],
coordinates = (lat: json["latitude"], long: json["longitude"]), coordinates = (lat: json["latitude"], long: json["longitude"]),
@ -36,7 +37,7 @@ class Stop with Encodable {
}; };
Stop.fromOcctJson(Json json) : Stop.fromOcctJson(Json json) :
id = StopID.fromJson(json["id"]), id = StopID(Provider.occt, json["id"]),
name = json["name"], name = json["name"],
description = null, description = null,
coordinates = (lat: json["lat"], long: json["lng"]), coordinates = (lat: json["lat"], long: json["lng"]),
@ -44,6 +45,9 @@ class Stop with Encodable {
routes = <String>{}, routes = <String>{},
routeIDs = <RouteID>{}; routeIDs = <RouteID>{};
@override
String toString() => name;
@override @override
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
"id": id.id, "id": id.id,

View file

@ -35,16 +35,27 @@ extension ListUtils<E> on List<E> {
yield (i, this[i]); yield (i, this[i]);
} }
} }
}
E max(int Function(E) count) => reduce((a, b) { extension IterableUtils<E> on Iterable<E> {
E max(num Function(E) count) => reduce((a, b) {
final numA = count(a); final numA = count(a);
final numB = count(b); final numB = count(b);
return numA > numB ? a : b; return numA > numB ? a : b;
}); });
E min(int Function(E) count) => reduce((a, b) { E min(num Function(E) count) => reduce((a, b) {
final numA = count(a); final numA = count(a);
final numB = count(b); final numB = count(b);
return numA < numB ? a : b; return numA < numB ? a : b;
}); });
double sum(double Function(E) transform) => fold(
0,
(total, element) => total + transform(element),
);
}
void log(String message) {
// print(message);
} }

View file

@ -3,9 +3,9 @@ import "dart:io";
import "package:shared/generator.dart"; import "package:shared/generator.dart";
class Generator<T extends Encodable> extends Parser<T> { class Generator<ID, T extends Encodable> extends Parser<ID, T> {
final Parser<T> bc; final Parser<ID, T> bc;
final Parser<T> occt; final Parser<ID, T> occt;
final File outputFile; final File outputFile;
Generator({ Generator({
@ -15,14 +15,15 @@ class Generator<T extends Encodable> extends Parser<T> {
}); });
@override @override
Future<List<T>> parse() async => [ Future<Map<ID, T>> parse() async => {
...await bc.parse(), ...await bc.parse(),
...await occt.parse(), ...await occt.parse(),
]; };
Future<void> generate() async { Future<void> generate() async {
final allData = await parse();
final result = [ final result = [
for (final data in await parse()) for (final data in allData.values)
data.toJson(), data.toJson(),
]; ];
const encoder = JsonEncoder.withIndent(" "); const encoder = JsonEncoder.withIndent(" ");

View file

@ -5,7 +5,7 @@ import "stops_bc.dart";
typedef InOutTrips = (TripID, TripID); typedef InOutTrips = (TripID, TripID);
class BcRoutesParser extends Parser<Route> { class BcRoutesParser extends Parser<RouteID, Route> {
final routesFile = File(bcDataDir / "routes.txt"); final routesFile = File(bcDataDir / "routes.txt");
final tripsFile = File(bcDataDir / "trips.txt"); final tripsFile = File(bcDataDir / "trips.txt");
@ -31,7 +31,7 @@ class BcRoutesParser extends Parser<Route> {
} }
@override @override
Future<Iterable<Route>> parse() async { Future<Map<RouteID, Route>> parse() async {
final routes = await getRoutes(); final routes = await getRoutes();
final trips = await stopParser.getTrips(); final trips = await stopParser.getTrips();
final tripsForRoutes = await getTripForRoutes(trips); final tripsForRoutes = await getTripForRoutes(trips);
@ -39,8 +39,9 @@ class BcRoutesParser extends Parser<Route> {
for (final (routeID, route) in routes.records) { for (final (routeID, route) in routes.records) {
final longestTrip = tripsForRoutes[routeID]!; final longestTrip = tripsForRoutes[routeID]!;
final stops = trips[longestTrip]!; final stops = trips[longestTrip]!;
log("Chose $longestTrip as the longest trip for $routeID");
route.stops.addAll(stops); route.stops.addAll(stops);
} }
return routes.values; return routes;
} }
} }

View file

@ -2,12 +2,12 @@ import "dart:io";
import "utils.dart"; import "utils.dart";
class OcctRoutesParser extends Parser<Route> { class OcctRoutesParser extends Parser<RouteID, Route> {
final routesFile = File(occtDataDir / "routes.json"); final routesFile = File(occtDataDir / "routes.json");
@override @override
Future<List<Route>> parse() async => [ Future<Map<RouteID, Route>> parse() async => {
for (final routeJson in await readJson(routesFile)) for (final routeJson in await readJson(routesFile))
Route.fromOcctJson(routeJson), RouteID(Provider.occt, routeJson["id"]): Route.fromOcctJson(routeJson),
]; };
} }

View file

@ -4,7 +4,7 @@ import "package:csv/csv.dart";
import "utils.dart"; import "utils.dart";
class BcStopParser extends Parser<Stop> { class BcStopParser extends Parser<StopID, Stop> {
static final tripsFile = File(bcDataDir / "stop_times.txt"); static final tripsFile = File(bcDataDir / "stop_times.txt");
static final routesFile = File(bcDataDir / "trips.txt"); static final routesFile = File(bcDataDir / "trips.txt");
static final stopsFile = File(bcDataDir / "stops.txt"); static final stopsFile = File(bcDataDir / "stops.txt");
@ -15,7 +15,7 @@ class BcStopParser extends Parser<Stop> {
Future<Map<TripID, List<StopID>>> getTrips() async { Future<Map<TripID, List<StopID>>> getTrips() async {
final result = <TripID, List<StopID>>{}; final result = <TripID, List<StopID>>{};
for (final row in await readCsv(tripsFile)) { for (final row in await readCsv(tripsFile)) {
result.addToList(TripID(row[0]), StopID(row[3])); result.addToList(TripID(row[0]), StopID(Provider.bc, row[3]));
} }
return result; return result;
} }
@ -34,7 +34,7 @@ class BcStopParser extends Parser<Stop> {
Future<Map<StopID, Stop>> getStops() async { Future<Map<StopID, Stop>> getStops() async {
final result = <StopID, Stop>{}; final result = <StopID, Stop>{};
for (final row in await readCsv(stopsFile)) { for (final row in await readCsv(stopsFile)) {
final stopID = StopID(row[0]); final stopID = StopID(Provider.bc, row[0]);
final name = row[2]; final name = row[2];
final description = row[3]; final description = row[3];
final latitude = double.parse(row[4]); final latitude = double.parse(row[4]);
@ -69,12 +69,12 @@ class BcStopParser extends Parser<Stop> {
} }
@override @override
Future<Iterable<Stop>> parse() async { Future<Map<StopID, Stop>> parse() async {
final trips = await getTrips(); final trips = await getTrips();
final routes = await getRoutes(); final routes = await getRoutes();
final stops = await getStops(); final stops = await getStops();
final routeNames = await getRouteNames(); final routeNames = await getRouteNames();
findRoutesForStops(stops: stops.values, trips: trips, routes: routes, routeNames: routeNames); findRoutesForStops(stops: stops.values, trips: trips, routes: routes, routeNames: routeNames);
return stops.values; return stops;
} }
} }

View file

@ -2,7 +2,7 @@ import "dart:io";
import "utils.dart"; import "utils.dart";
class OcctStopParser extends Parser<Stop> { class OcctStopParser extends Parser<StopID, Stop> {
/// Taken from: https://binghamtonupublic.etaspot.net/service.php?service=get_stops&token=TESTING /// Taken from: https://binghamtonupublic.etaspot.net/service.php?service=get_stops&token=TESTING
static final stopsFile = File(occtDataDir / "stops.json"); static final stopsFile = File(occtDataDir / "stops.json");
@ -12,7 +12,7 @@ class OcctStopParser extends Parser<Stop> {
Future<Map<StopID, Stop>> getStops() async { Future<Map<StopID, Stop>> getStops() async {
final result = <StopID, Stop>{}; final result = <StopID, Stop>{};
for (final stopJson in await readJson(stopsFile)) { for (final stopJson in await readJson(stopsFile)) {
final id = StopID.fromJson(stopJson["id"]); final id = StopID(Provider.occt, stopJson["id"]);
result[id] ??= Stop.fromOcctJson(stopJson); result[id] ??= Stop.fromOcctJson(stopJson);
} }
return result; return result;
@ -21,7 +21,7 @@ class OcctStopParser extends Parser<Stop> {
Future<Map<StopID, List<RouteID>>> getRoutes() async { Future<Map<StopID, List<RouteID>>> getRoutes() async {
final result = <StopID, List<RouteID>>{}; final result = <StopID, List<RouteID>>{};
for (final json in await readJson(stopsFile)) { for (final json in await readJson(stopsFile)) {
final stopID = StopID.fromJson(json["id"]); final stopID = StopID(Provider.occt, json["id"]);
final routeID = RouteID(Provider.occt, json["rid"]); final routeID = RouteID(Provider.occt, json["rid"]);
result.addToList(stopID, routeID); result.addToList(stopID, routeID);
} }
@ -34,7 +34,7 @@ class OcctStopParser extends Parser<Stop> {
}; };
@override @override
Future<Iterable<Stop>> parse() async { Future<Map<StopID, Stop>> parse() async {
final stops = await getStops(); final stops = await getStops();
final routes = await getRoutes(); final routes = await getRoutes();
final routeNames = await getRouteNames(); final routeNames = await getRouteNames();
@ -46,6 +46,6 @@ class OcctStopParser extends Parser<Stop> {
stop.addRoute(routeID, routeName); stop.addRoute(routeID, routeName);
} }
} }
return stops.values; return stops;
} }
} }

View file

@ -27,8 +27,8 @@ extension MapSetUtils<K, V> on Map<K, Set<V>> {
} }
} }
abstract class Parser<T> { abstract class Parser<ID, T> {
Future<Iterable<T>> parse(); Future<Map<ID, T>> parse();
} }
final csvConverter = CsvCodec(shouldParseNumbers: false).decoder; final csvConverter = CsvCodec(shouldParseNumbers: false).decoder;

View file

@ -0,0 +1,60 @@
import "package:a_star/a_star.dart";
import "package:shared/data.dart";
import "state.dart";
void printStops(Iterable<Stop> stops) {
log("Found stops:");
for (final stop in stops) {
log("- ${stop.routes}, ${stop.name}");
}
}
Iterable<StopState>? findPath(Coordinates start, Coordinates end) {
// 1) Find [numStops] nearest stops by the given locations
log("Finding nearest stops...");
final startStops = StopState.findStopsNear(start);
printStops(startStops);
final endStops = StopState.findStopsNear(end);
printStops(endStops);
// 2) Use A* to find a route with transfers
log("Using A*");
final paths = <Iterable<StopState>>[];
for (final startStop in startStops) {
for (final startRouteID in startStop.routeIDs) {
for (final endStop in endStops) {
final state = StopState.start(
stopID: startStop.id,
goalID: endStop.id,
routeID: startRouteID,
startPoint: start,
endPoint: end,
);
log("Finding a route using ${state.hash()}");
final result = aStar(state, limit: 10000);
if (result == null) continue;
paths.add(result.reconstructPath());
}
}
}
if (paths.isEmpty) return null;
return paths.min(getTotalDistance);
}
double getTotalDistance(Iterable<StopState> path) =>
path.sum((step) => step.distanceWalked);
void explainPath(Iterable<StopState> path, void Function(String) printer) {
var index = 0;
for (final step in path) {
index++;
final stop = StopState.stops[step.stopID]!;
if (index == path.length) {
printer("$index. Get off at ${stop.name}");
} else {
printer("$index. ${step.explanation}");
}
}
}

View file

@ -1,33 +1,81 @@
import "package:a_star/a_star.dart"; import "package:a_star/a_star.dart";
import "package:shared/data.dart"; import "package:shared/data.dart";
class StopState extends AStarState<StopState> { enum SearchMethod {
start,
bus,
transfer,
walk;
factory SearchMethod.fromJson(String json) => values.byName(json);
}
class StopState extends AStarState<StopState> with Encodable {
static late final Map<RouteID, Route> routes; static late final Map<RouteID, Route> routes;
static late final Map<StopID, Stop> stops; static late final Map<StopID, Stop> stops;
static void init(Iterable<Route> allRoutes, Iterable<Stop> allStops) { static Iterable<Stop> findStopsNear(Coordinates location) {
routes = { const numStops = 5;
for (final route in allRoutes) log("Finding stops near $location...");
route.id: route, final stopDistances = [
}; for (final stop in stops.values)
stops = { (stop, stop.coordinates.distanceTo(location)),
for (final stop in allStops) ];
stop.id: stop, stopDistances.sort((a, b) => a.$2.compareTo(b.$2));
}; return stopDistances.take(numStops).map((pair) => pair.$1);
} }
final StopID stopID; final StopID stopID;
final StopID goalID; final StopID goalID;
final RouteID routeID; final RouteID routeID;
final double distanceWalked;
final SearchMethod method;
StopState({ StopState({
required this.stopID, required this.stopID,
required this.goalID, required this.goalID,
required this.routeID, required this.routeID,
required this.distanceWalked,
required this.method,
required super.depth, required super.depth,
}); });
factory StopState.start({
required StopID stopID,
required StopID goalID,
required RouteID routeID,
required Coordinates startPoint,
required Coordinates endPoint,
}) {
final stop = stops[stopID]!;
final goal = stops[goalID]!;
final distanceToStart = startPoint.distanceTo(stop.coordinates);
final distanceToEnd = endPoint.distanceTo(goal.coordinates);
return StopState(
stopID: stopID,
goalID: goalID,
routeID: routeID,
distanceWalked: distanceToStart + distanceToEnd,
depth: 0,
method: SearchMethod.start,
);
}
factory StopState.fromJson(Json json) => StopState(
stopID: StopID.fromJson(json["stop_id"]),
routeID: RouteID.fromJson(json["route_id"]),
method: SearchMethod.fromJson(json["method"]),
depth: 0,
distanceWalked: 0,
goalID: StopID.fromJson("N/A"),
);
@override @override
String hash() => "$stopID-$goalID-$routeID"; String hash() {
final stop = stops[stopID];
final goal = stops[goalID];
final route = routes[routeID];
return "${method.name}-$stop-$goal-$route";
}
@override @override
bool isGoal() => stopID == goalID; bool isGoal() => stopID == goalID;
@ -44,30 +92,86 @@ class StopState extends AStarState<StopState> {
final route = routes[routeID]!; final route = routes[routeID]!;
final stop = stops[stopID]!; final stop = stops[stopID]!;
final stopIndex = route.stops.indexOf(stopID); final stopIndex = route.stops.indexOf(stopID);
if (stopIndex == -1) return [];
final result = <StopState>[]; final result = <StopState>[];
// 1): Go forward one stop on the same route // 1): Go forward one stop on the same route
final nextIndex = stopIndex + 1; final nextIndex = stopIndex + 1;
if (nextIndex < route.stops.length) { if (nextIndex < route.stops.length) {
final nextStop = route.stops[nextIndex]; final nextStopID = route.stops[nextIndex];
final state = StopState(stopID: nextStop, goalID: goalID, routeID: routeID, depth: depth + 1); final state = StopState(
stopID: nextStopID,
goalID: goalID,
routeID: routeID,
depth: depth + 1,
distanceWalked: distanceWalked,
method: SearchMethod.bus,
);
result.add(state); result.add(state);
} }
// 2) Go back one stop on the same route // 2) Go back one stop on the same route
final prevIndex = stopIndex - 1; final prevIndex = stopIndex - 1;
if (prevIndex > 0) { if (prevIndex > 0) {
final prevStop = route.stops[prevIndex]; final prevStopID = route.stops[prevIndex];
final state = StopState(stopID: prevStop, goalID: goalID, routeID: routeID, depth: depth + 1); final state = StopState(
stopID: prevStopID,
goalID: goalID,
routeID: routeID,
depth: depth + 1,
distanceWalked: distanceWalked,
method: SearchMethod.bus,
);
result.add(state); result.add(state);
} }
// 3) Make any available transfers // 3) Make any available transfers
for (final otherRoute in stop.routeIDs.difference({routeID})) { for (final otherRouteID in stop.routeIDs.difference({routeID})) {
final state = StopState(stopID: stopID, goalID: goalID, routeID: otherRoute, depth: depth + 1); final state = StopState(
stopID: stopID,
goalID: goalID,
routeID: otherRouteID,
depth: depth + 2,
distanceWalked: distanceWalked,
method: SearchMethod.transfer,
);
result.add(state); result.add(state);
} }
// 4) Walk to a new stop
for (final otherStop in findStopsNear(stop.coordinates)) {
final distanceToOther = stop.coordinates.distanceTo(otherStop.coordinates);
for (final otherRouteID in otherStop.routeIDs) {
final state = StopState(
stopID: otherStop.id,
goalID: goalID,
routeID: otherRouteID,
depth: depth + 3,
distanceWalked: distanceWalked + distanceToOther,
method: SearchMethod.walk,
);
result.add(state);
}
}
return result; return result;
} }
String get explanation {
final route = routes[routeID]!;
final stop = stops[stopID]!;
return switch (method) {
SearchMethod.bus => "Go one stop to $stop",
SearchMethod.start => "Walk to $stop\n and board the $route line",
SearchMethod.transfer => "Transfer to the $route line",
SearchMethod.walk => "Walk to $stop\n and board the $route line"
};
}
@override
Json toJson() => {
"stop_id": stopID,
"route_id": routeID,
"method": method.name,
};
} }

View file

@ -0,0 +1,16 @@
import "package:shared/graph.dart";
import "package:shelf/shelf.dart";
import "utils.dart";
Response getStops(Request _) {
final stops = StopState.stops.values;
final jsonBody = encodeJsonList(stops);
return Response.ok(jsonBody, headers: corsHeaders);
}
Response getRoutes(Request _) {
final routes = StopState.routes.values.where((route) => !route.fullName.startsWith(")"));
final jsonBody = encodeJsonList(routes);
return Response.ok(jsonBody, headers: corsHeaders);
}

View file

@ -0,0 +1,24 @@
import "package:shared/graph.dart";
import "package:shelf/shelf.dart";
import "utils.dart";
Response getPath(Request request) {
final startLat = double.tryParse(request.url.queryParameters["start_lat"] ?? "");
final startLong = double.tryParse(request.url.queryParameters["start_lon"] ?? "");
final endLat = double.tryParse(request.url.queryParameters["end_lat"] ?? "");
final endLong = double.tryParse(request.url.queryParameters["end_lon"] ?? "");
if (startLat == null || startLong == null || endLat == null || endLong == null) {
return Response.badRequest(body: "Could not parse coordinates", headers: corsHeaders);
}
final start = (lat: startLat, long: startLong);
final end = (lat: endLat, long: endLong);
final path = findPath(start, end);
if (path == null) {
return Response.ok("No routes found", headers: corsHeaders);
}
final buffer = StringBuffer();
explainPath(path, buffer.writeln);
return Response.ok(buffer.toString(), headers: corsHeaders);
}

View file

@ -0,0 +1,15 @@
import "dart:convert";
import "package:shared/generator.dart";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
};
String encodeJsonList<T extends Encodable>(Iterable<T> data) {
final jsonList = [
for (final object in data)
object.toJson(),
];
return jsonEncode(jsonList);
}