Working "find a path" code (#13)

The client first requests stops from the TS server (`0.0.0.0:80`), then the path from the Dart server (`localhost:8001`). It will be trivial to move the TS logic to the Dart server since the Dart server is the one that parsed all that data, and it's anyway in a `JSON` file. 

There is a `server/GET_ROUTES.json` file, but it's not needed. I just included it for easier debugging. `server/GET_STOPS.json` includes both the stops and the routes, and the client does some (inefficient) work to re-organize it.

The Dart server uses a basic A* implementation to sort find direct routes and routes with transfers. The A* logic is in `src/shared/lib/src/graph/graph.dart`. This file defines the state itself, and `package:a_star` on Pub.dev (mine) defines the algorithm. `src/shared/bin/path.dart` is the whole of the Dart server. 

TODO: 
- add unit tests
- this PR breaks almost nothing, so there is duplicate work being done on the client and some unused logic
- return a structured path object for the client to use as it wants. This PR just sends a well-formatted string
This commit is contained in:
Levi Lesches 2025-05-02 19:55:59 -04:00 committed by GitHub
parent 5e24c8ad30
commit d0387c7aa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 5921 additions and 1420 deletions

2
src/.gitignore vendored
View file

@ -1 +1,3 @@
*.env
pubspec.lock
.dart_tool

View file

@ -1,6 +1,7 @@
import "package:flutter/foundation.dart" show debugPrint, kDebugMode;
import "package:client/data.dart";
import "package:http/http.dart" as http;
import "api_client.dart";
import "service.dart";
@ -41,6 +42,28 @@ class ApiService extends Service {
PathStep.fromJson,
);
Future<String?> getPath2({
required Coordinates start,
required Coordinates end,
}) async {
final uri = Uri.parse("http://localhost:8001/path").replace(
queryParameters: {
"start_lat": start.lat.toString(),
"start_lon": start.long.toString(),
"end_lat": end.lat.toString(),
"end_lon": end.long.toString(),
},
);
try {
final response = await http.get(uri);
return response.body;
// Any error should show null
// ignore: avoid_catches_without_on_clauses
} catch (error) {
return null;
}
}
Future<List<Stop>?> getStops() => _client.getJsonList(
_base.resolve("stops"),
Stop.fromJson,

View file

@ -1,6 +1,7 @@
import "dart:convert";
import "package:client/data.dart";
import "package:flutter/foundation.dart";
import "package:http/http.dart" as http;
class ApiClient {
@ -19,9 +20,10 @@ class ApiClient {
for (final json in listOfJson)
fromJson(json),
];
// Want to catch all errors for the UI
// ignore: avoid_catches_without_on_clauses
} catch (error, stackTrace) {
// ignore: avoid_print
print("Error: $error\n$stackTrace");
debugPrint("Error: $error\n$stackTrace");
return null;
}
}

View file

@ -64,6 +64,7 @@ class HomeModel extends ViewModel with HomeMarkers {
super.updateEnd(coordinates, marker);
}
String? pathText;
Future<void> search() async {
final start = (lat: startLatitude, long: startLongitude);
final end = (lat: endLatitude, long: endLongitude);
@ -71,16 +72,17 @@ class HomeModel extends ViewModel with HomeMarkers {
if (end.lat == null || end.long == null) return;
isSearching = true;
isLoading = true;
final result = await services.api.getPath(start: start as Coordinates, end: end as Coordinates);
path = result;
final result = await services.api.getPath2(start: start as Coordinates, end: end as Coordinates);
pathText = result ?? "An error occurred";
// path = result;
isLoading = false;
if (result == null) return;
isSearching = false;
routes = [
for (final step in result) [
...decodePolyline(step.trip.polyline),
],
];
// routes = [
// for (final step in result) [
// ...decodePolyline(step.trip.polyline),
// ],
// ];
notifyListeners();
}
}

View file

@ -78,12 +78,11 @@ mixin HomeMarkers on ChangeNotifier {
stopCounts[route] ??= 0;
stopCounts[route] = stopCounts[route]! + 1;
}
if (stop.provider.contains("OCCT")) {
occtRouteNames.addAll(stop.routes);
} else {
bcRouteNames.addAll(stop.routes);
}
final namesList = switch (stop.provider) {
Provider.occt => occtRouteNames,
Provider.bc => bcRouteNames,
};
namesList.addAll(stop.routes);
}
notifyListeners();
}

View file

@ -32,27 +32,29 @@ class Sidebar extends ReusableReactiveWidget<HomeModel> {
Tab(text: provider),
],
),
Expanded(
child: !model.shouldShowMarkers
? const Center(child: Text("Choose start or end location"))
: TabBarView(
children: [
for (final (_, routesList) in model.providers)
ListView(
children: [
for (final route in routesList) CheckboxListTile(
title: Text(route, maxLines: 1),
subtitle: Text(
"${model.stopCounts[route] ?? 0} stops",
maxLines: 1,
),
value: model.routesToShow.contains(route),
onChanged: (value) => model.showRoute(route, shouldShow: value!),
),
],
if (!model.shouldShowMarkers && model.pathText != null)
Center(child: Text(model.pathText!, style: context.textTheme.bodySmall)),
if (!model.shouldShowMarkers && model.pathText == null)
const Center(child: Text("Choose start or end location")),
if (model.shouldShowMarkers) Expanded(
child: TabBarView(
children: [
for (final (_, routesList) in model.providers)
ListView(
children: [
for (final route in routesList) CheckboxListTile(
title: Text(route, maxLines: 1),
subtitle: Text(
"${model.stopCounts[route] ?? 0} stops",
maxLines: 1,
),
value: model.routesToShow.contains(route),
onChanged: (value) => model.showRoute(route, shouldShow: value!),
),
],
),
],
),
],
),
),
],
),

View file

@ -1,433 +0,0 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
url: "https://pub.dev"
source: hosted
version: "2.12.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build_cli_annotations:
dependency: transitive
description:
name: build_cli_annotations
sha256: b59d2769769efd6c9ff6d4c4cede0be115a566afc591705c2040b707534b1172
url: "https://pub.dev"
source: hosted
version: "2.1.0"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
csv:
dependency: "direct main"
description:
name: csv
sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c
url: "https://pub.dev"
source: hosted
version: "6.0.0"
dhttpd:
dependency: "direct dev"
description:
name: dhttpd
sha256: "2e24765d7569b8e0a02a441e3cf96f09cca69dfecba646e7e9f6b3ab45a2f3fe"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
fixnum:
dependency: "direct main"
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3"
url: "https://pub.dev"
source: hosted
version: "2.0.27"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.dev"
source: hosted
version: "14.8.1"
google_maps:
dependency: transitive
description:
name: google_maps
sha256: "4d6e199c561ca06792c964fa24b2bac7197bf4b401c2e1d23e345e5f9939f531"
url: "https://pub.dev"
source: hosted
version: "8.1.1"
google_maps_flutter:
dependency: "direct main"
description:
name: google_maps_flutter
sha256: b42ff7f3875a5eedbe388d883100561b85c62beed1c39ad66dd60537c75bb424
url: "https://pub.dev"
source: hosted
version: "2.12.0"
google_maps_flutter_android:
dependency: transitive
description:
name: google_maps_flutter_android
sha256: "0ede4ae8326335c0c007c8c7a8c9737449263123385e2bdf49f3e71103b2dc2e"
url: "https://pub.dev"
source: hosted
version: "2.16.0"
google_maps_flutter_ios:
dependency: transitive
description:
name: google_maps_flutter_ios
sha256: ef72c822930ce69515cb91c10cd88cfb8b26296f765808a43cbc9a10eaffacfe
url: "https://pub.dev"
source: hosted
version: "2.15.0"
google_maps_flutter_platform_interface:
dependency: transitive
description:
name: google_maps_flutter_platform_interface
sha256: "970c8f766c02909c7be282dea923c971f83a88adaf07f8871d0aacebc3b07bb2"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
google_maps_flutter_web:
dependency: transitive
description:
name: google_maps_flutter_web
sha256: a45786ea6691cc7cdbe2cf3ce2c2daf4f82a885745666b4a36baada3a4e12897
url: "https://pub.dev"
source: hosted
version: "0.5.12"
html:
dependency: "direct main"
description:
name: html
sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec"
url: "https://pub.dev"
source: hosted
version: "0.15.5"
http:
dependency: "direct main"
description:
name: http
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
url: "https://pub.dev"
source: hosted
version: "1.3.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev"
source: hosted
version: "10.0.8"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev"
source: hosted
version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
polyline_tools:
dependency: "direct main"
description:
name: polyline_tools
sha256: "8c523335bb8d16fb0c7835916ed94d97d9a063a4386e9193bd6e8fe233591df9"
url: "https://pub.dev"
source: hosted
version: "0.0.2"
sanitize_html:
dependency: transitive
description:
name: sanitize_html
sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
shared:
dependency: "direct main"
description:
path: "../shared"
relative: true
source: path
version: "1.0.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
version: "0.7.4"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
very_good_analysis:
dependency: "direct dev"
description:
name: very_good_analysis
sha256: "1fb637c0022034b1f19ea2acb42a3603cbd8314a470646a59a2fb01f5f3a8629"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.dev"
source: hosted
version: "14.3.1"
web:
dependency: "direct main"
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.7.0 <4.0.0"
flutter: ">=3.27.0"

View file

@ -6,6 +6,7 @@ version: 0.1.0+1
environment:
sdk: ^3.5.0
resolution: workspace
dependencies:
fixnum: ^1.1.1
csv: ^6.0.0
@ -24,7 +25,6 @@ dev_dependencies:
dhttpd: ^4.1.0
flutter_test:
sdk: flutter
very_good_analysis: ^6.0.0
flutter:
uses-material-design: true

View file

@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:client/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}

10
src/pubspec.yaml Normal file
View file

@ -0,0 +1,10 @@
name: cs445
environment:
sdk: ^3.7.0
workspace:
- shared
- client
dev_dependencies:
very_good_analysis: ^7.0.0

File diff suppressed because it is too large Load diff

1454
src/server/GET_routes.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,52 +0,0 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints. See the following for docs:
# https://dart.dev/guides/language/analysis-options
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
include: package:very_good_analysis/analysis_options.yaml # has more lints
analyzer:
language:
# Strict casts isn't helpful with null safety. It only notifies you on `dynamic`,
# which happens all the time in JSON.
#
# See https://github.com/dart-lang/language/blob/main/resources/type-system/strict-casts.md
strict-casts: false
# Don't let any types be inferred as `dynamic`.
#
# See https://github.com/dart-lang/language/blob/main/resources/type-system/strict-inference.md
strict-inference: true
# Don't let Dart infer the wrong type on the left side of an assignment.
#
# See https://github.com/dart-lang/language/blob/main/resources/type-system/strict-raw-types.md
strict-raw-types: true
exclude:
- lib/generated/**.dart
- test/**.dart
- example/**.dart
linter:
rules:
# Rules NOT in package:very_good_analysis
prefer_double_quotes: true
prefer_expression_function_bodies: true
# Rules to be disabled from package:very_good_analysis
prefer_single_quotes: false # prefer_double_quotes
lines_longer_than_80_chars: false # lines should be at most 100 chars
sort_pub_dependencies: false # Sort dependencies by function
use_key_in_widget_constructors: false # not in Flutter apps
directives_ordering: false # sort dart, then flutter, then package imports
always_use_package_imports: false # not when importing sibling files
sort_constructors_first: false # final properties, then constructor
avoid_dynamic_calls: false # this lint takes over errors in the IDE
one_member_abstracts: false # abstract classes are good for interfaces
cascade_invocations: false # cascades are often harder to read
# Temporarily disabled until we are ready to document
public_member_api_docs: false

View file

@ -1,6 +1,6 @@
import "package:shared/generator.dart";
void main() async {
final stops = StopGenerator();
await stops.generate();
await Generator.stops.generate();
await Generator.routes.generate();
}

147
src/shared/bin/path.dart Normal file
View file

@ -0,0 +1,147 @@
// 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

@ -1,2 +1,4 @@
export "src/utils.dart";
export "src/stops/stop.dart";
export "src/data/provider.dart";
export "src/data/route.dart";
export "src/data/stop.dart";
export "src/data/utils.dart";

View file

@ -1,2 +1,6 @@
export "src/generator_utils.dart";
export "src/stops/generator.dart";
export "src/generator/generator.dart";
export "src/generator/routes_bc.dart";
export "src/generator/routes_occt.dart";
export "src/generator/stops_bc.dart";
export "src/generator/stops_occt.dart";
export "src/generator/utils.dart";

View file

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

View file

@ -1,2 +0,0 @@
export "src/stops/stop.dart";
export "src/utils.dart";

View file

@ -0,0 +1,11 @@
enum Provider {
bc("BCT", "BC Transit"),
occt("OCCT", "OCC Transport");
const Provider(this.id, this.humanName);
factory Provider.fromJson(String json) => values.firstWhere((value) => value.id == json);
final String id;
final String humanName;
}

View file

@ -0,0 +1,55 @@
import "provider.dart";
import "stop.dart";
import "utils.dart";
extension type RouteID._(String id) {
RouteID(Provider provider, Object json) :
id = "${provider.id}_$json";
RouteID.fromJson(dynamic json) : id = json.toString();
factory RouteID.fromBcCsv(CsvRow csv) => RouteID(Provider.bc, csv[0]);
RouteID withDirection(String direction) => RouteID._("${id}_$direction");
}
class Route with Encodable {
final RouteID id;
final Provider provider;
final String fullName;
final String shortName;
final List<StopID> stops;
const Route({
required this.id,
required this.provider,
required this.stops,
required this.fullName,
required this.shortName,
});
Route.fromOcctJson(Json json) :
id = RouteID(Provider.occt, json["id"]),
provider = Provider.occt,
shortName = json["abbr"],
fullName = json["name"],
stops = [
for (final stopID in (json["stops"] as List).cast<int>())
StopID(stopID.toString()),
];
Route.fromBcCsv(CsvRow csv) :
id = RouteID.fromBcCsv(csv),
provider = Provider.bc,
shortName = csv[2],
fullName = "${csv[2]}) ${csv[3]}",
stops = [];
@override
Json toJson() => {
"id": id,
"provider": provider.id,
"full_name": fullName,
"short_name": shortName,
"stops": stops,
};
}

View file

@ -1,16 +1,19 @@
import "../utils.dart";
import "utils.dart";
import "route.dart";
import "provider.dart";
extension type StopID(String id) {
StopID.fromJson(dynamic value) : id = value.toString();
}
class Stop {
class Stop with Encodable {
final StopID id;
final String name;
final String? description;
final Coordinates coordinates;
final String provider;
final Provider provider;
final Set<String> routes;
final Set<RouteID> routeIDs;
Stop({
required this.id,
@ -18,32 +21,39 @@ class Stop {
required this.description,
required this.coordinates,
required this.provider,
}) : routes = {};
}) : routes = {}, routeIDs = {};
Stop.fromJson(Json json) :
id = StopID(json["id"]),
name = json["name"],
description = json["description"],
coordinates = (lat: json["latitude"], long: json["longitude"]),
provider = json["provider"],
routes = (json["routes"] as List).cast<String>().toSet();
provider = Provider.fromJson(json["provider"]),
routes = (json["routes"] as List).cast<String>().toSet(),
routeIDs = {
for (final routeID in (json["route_ids"] as List))
RouteID.fromJson(routeID),
};
Stop.fromOcctJson(Json json) :
id = StopID.fromJson(json["id"]),
name = json["name"],
description = null,
coordinates = (lat: json["lat"], long: json["lng"]),
provider = "OCCT",
routes = <String>{};
provider = Provider.occt,
routes = <String>{},
routeIDs = <RouteID>{};
@override
Map<String, dynamic> toJson() => {
"id": id.id,
"name": name,
"description": description,
"latitude": coordinates.lat,
"longitude": coordinates.long,
"provider": provider,
"provider": provider.id,
"routes": routes.toList(),
"route_ids": routeIDs.toList(),
};
String get summary {
@ -57,4 +67,9 @@ class Stop {
}
return buffer.toString();
}
void addRoute(RouteID id, String name) {
routeIDs.add(id);
routes.add(name);
}
}

View file

@ -1,10 +1,18 @@
/// A JSON object
import "dart:math";
typedef Json = Map<String, dynamic>;
typedef CsvRow = List<String>;
typedef FromJson<T> = T Function(Json);
typedef Coordinates = ({double lat, double long});
mixin Encodable {
Json toJson();
}
extension CoordinateUtils on Coordinates {
double distanceTo(Coordinates other) => sqrt(pow(lat - other.lat, 2) + pow(long - other.long, 2));
}
/// Utils on [Map].
extension MapUtils<K, V> on Map<K, V> {
/// Gets all the keys and values as 2-element records.
@ -27,4 +35,16 @@ extension ListUtils<E> on List<E> {
yield (i, this[i]);
}
}
E max(int Function(E) count) => reduce((a, b) {
final numA = count(a);
final numB = count(b);
return numA > numB ? a : b;
});
E min(int Function(E) count) => reduce((a, b) {
final numA = count(a);
final numB = count(b);
return numA < numB ? a : b;
});
}

View file

@ -0,0 +1,44 @@
import "dart:convert";
import "dart:io";
import "package:shared/generator.dart";
class Generator<T extends Encodable> extends Parser<T> {
final Parser<T> bc;
final Parser<T> occt;
final File outputFile;
Generator({
required this.bc,
required this.occt,
required this.outputFile,
});
@override
Future<List<T>> parse() async => [
...await bc.parse(),
...await occt.parse(),
];
Future<void> generate() async {
final result = [
for (final data in await parse())
data.toJson(),
];
const encoder = JsonEncoder.withIndent(" ");
final contents = encoder.convert(result);
await outputFile.writeAsString(contents);
}
static final stops = Generator(
bc: BcStopParser(),
occt: OcctStopParser(),
outputFile: File(serverDir / "GET_STOPS.json"),
);
static final routes = Generator(
bc: BcRoutesParser(),
occt: OcctRoutesParser(),
outputFile: File(serverDir / "GET_routes.json"),
);
}

View file

@ -0,0 +1,46 @@
import "dart:io";
import "utils.dart";
import "stops_bc.dart";
typedef InOutTrips = (TripID, TripID);
class BcRoutesParser extends Parser<Route> {
final routesFile = File(bcDataDir / "routes.txt");
final tripsFile = File(bcDataDir / "trips.txt");
final stopParser = BcStopParser();
Future<Map<RouteID, Route>> getRoutes() async => {
for (final csv in await readCsv(routesFile))
RouteID.fromBcCsv(csv): Route.fromBcCsv(csv),
};
Future<Map<RouteID, TripID>> getTripForRoutes(Map<TripID, List<StopID>> trips) async {
final tripsForRoutes = <RouteID, List<TripID>>{};
for (final row in await readCsv(tripsFile)) {
final routeID = RouteID(Provider.bc, row[0]);
final trip = TripID(row[2]);
// print(trip);
tripsForRoutes.addToList(routeID, trip);
}
return {
for (final (routeID, tripsForRoute) in tripsForRoutes.records)
routeID: tripsForRoute.max((trip) => trips[trip]!.length),
};
}
@override
Future<Iterable<Route>> parse() async {
final routes = await getRoutes();
final trips = await stopParser.getTrips();
final tripsForRoutes = await getTripForRoutes(trips);
for (final (routeID, route) in routes.records) {
final longestTrip = tripsForRoutes[routeID]!;
final stops = trips[longestTrip]!;
route.stops.addAll(stops);
}
return routes.values;
}
}

View file

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

View file

@ -2,7 +2,7 @@ import "dart:io";
import "package:csv/csv.dart";
import "../generator_utils.dart";
import "utils.dart";
class BcStopParser extends Parser<Stop> {
static final tripsFile = File(bcDataDir / "stop_times.txt");
@ -12,23 +12,23 @@ class BcStopParser extends Parser<Stop> {
static final converter = CsvCodec(shouldParseNumbers: false).decoder;
Future<Map<TripID, Set<StopID>>> getTrips() async {
final result = <TripID, Set<StopID>>{};
Future<Map<TripID, List<StopID>>> getTrips() async {
final result = <TripID, List<StopID>>{};
for (final row in await readCsv(tripsFile)) {
result.addToSet(TripID(row[0]), StopID(row[3]));
result.addToList(TripID(row[0]), StopID(row[3]));
}
return result;
}
Future<Map<TripID, RouteID>> getRoutes() async => {
for (final row in await readCsv(routesFile))
TripID(row[2]): RouteID(row[0]),
TripID(row[2]): RouteID(Provider.bc, row[0]),
};
Future<Map<RouteID, String>> getRouteNames() async => {
for (final row in await readCsv(routeNamesFile))
if (row[2].isNotEmpty)
RouteID(row[0]): "${row[2]}) ${row[3]}",
RouteID(Provider.bc, row[0]): "${row[2]}) ${row[3]}",
};
Future<Map<StopID, Stop>> getStops() async {
@ -44,7 +44,7 @@ class BcStopParser extends Parser<Stop> {
name: name,
description: description,
coordinates: (lat: latitude, long: longitude),
provider: "BC Transit",
provider: Provider.bc,
);
result[stopID] = stop;
}
@ -53,7 +53,7 @@ class BcStopParser extends Parser<Stop> {
void findRoutesForStops({
required Iterable<Stop> stops,
required Map<TripID, Set<StopID>> trips,
required Map<TripID, List<StopID>> trips,
required Map<TripID, RouteID> routes,
required Map<RouteID, String> routeNames,
}) {
@ -63,7 +63,7 @@ class BcStopParser extends Parser<Stop> {
final routeID = routes[tripID]!;
final routeName = routeNames[routeID];
if (routeName == null) continue; // old route
stop.routes.add(routeName);
stop.addRoute(routeID, routeName);
}
}
}

View file

@ -1,6 +1,6 @@
import "dart:io";
import "../generator_utils.dart";
import "utils.dart";
class OcctStopParser extends Parser<Stop> {
/// Taken from: https://binghamtonupublic.etaspot.net/service.php?service=get_stops&token=TESTING
@ -22,7 +22,7 @@ class OcctStopParser extends Parser<Stop> {
final result = <StopID, List<RouteID>>{};
for (final json in await readJson(stopsFile)) {
final stopID = StopID.fromJson(json["id"]);
final routeID = RouteID.fromJson(json["rid"]);
final routeID = RouteID(Provider.occt, json["rid"]);
result.addToList(stopID, routeID);
}
return result;
@ -30,7 +30,7 @@ class OcctStopParser extends Parser<Stop> {
Future<Map<RouteID, String>> getRouteNames() async => {
for (final json in await readJson(routesFile))
RouteID.fromJson(json["id"]): json["name"],
RouteID(Provider.occt, json["id"]): json["name"],
};
@override
@ -38,12 +38,12 @@ class OcctStopParser extends Parser<Stop> {
final stops = await getStops();
final routes = await getRoutes();
final routeNames = await getRouteNames();
final routesToSkip = <RouteID>{RouteID("11")}; // no longer used
final routesToSkip = <RouteID>{RouteID(Provider.occt, "11")};
for (final (stopID, stop) in stops.records) {
for (final routeID in routes[stopID]!) {
if (routesToSkip.contains(routeID)) continue;
final routeName = routeNames[routeID]!;
stop.routes.add(routeName);
stop.addRoute(routeID, routeName);
}
}
return stops.values;

View file

@ -9,12 +9,6 @@ extension type TripID(String id) {
TripID.fromJson(dynamic value) : id = value.toString();
}
extension type RouteID(String id) {
RouteID.fromJson(dynamic value) : id = value.toString();
}
typedef CsvRow = List<String>;
extension DirectoryUtils on Directory {
String operator /(String child) => "$path/$child";
}

View file

@ -0,0 +1,73 @@
import "package:a_star/a_star.dart";
import "package:shared/data.dart";
class StopState extends AStarState<StopState> {
static late final Map<RouteID, Route> routes;
static late final Map<StopID, Stop> stops;
static void init(Iterable<Route> allRoutes, Iterable<Stop> allStops) {
routes = {
for (final route in allRoutes)
route.id: route,
};
stops = {
for (final stop in allStops)
stop.id: stop,
};
}
final StopID stopID;
final StopID goalID;
final RouteID routeID;
StopState({
required this.stopID,
required this.goalID,
required this.routeID,
required super.depth,
});
@override
String hash() => "$stopID-$goalID-$routeID";
@override
bool isGoal() => stopID == goalID;
@override
double heuristic() {
final stop = stops[stopID]!;
final goal = stops[goalID]!;
return stop.coordinates.distanceTo(goal.coordinates);
}
@override
Iterable<StopState> expand() {
final route = routes[routeID]!;
final stop = stops[stopID]!;
final stopIndex = route.stops.indexOf(stopID);
final result = <StopState>[];
// 1): Go forward one stop on the same route
final nextIndex = stopIndex + 1;
if (nextIndex < route.stops.length) {
final nextStop = route.stops[nextIndex];
final state = StopState(stopID: nextStop, goalID: goalID, routeID: routeID, depth: depth + 1);
result.add(state);
}
// 2) Go back one stop on the same route
final prevIndex = stopIndex - 1;
if (prevIndex > 0) {
final prevStop = route.stops[prevIndex];
final state = StopState(stopID: prevStop, goalID: goalID, routeID: routeID, depth: depth + 1);
result.add(state);
}
// 3) Make any available transfers
for (final otherRoute in stop.routeIDs.difference({routeID})) {
final state = StopState(stopID: stopID, goalID: goalID, routeID: otherRoute, depth: depth + 1);
result.add(state);
}
return result;
}
}

View file

@ -1,28 +0,0 @@
import "dart:convert";
import "dart:io";
import "../generator_utils.dart";
import "bc.dart";
import "occt.dart";
class StopGenerator {
static final serverDir = Directory("../server");
static final outputFile = File(serverDir / "GET_STOPS.json");
final Parser<Stop> bc = BcStopParser();
final Parser<Stop> occt = OcctStopParser();
Future<void> generate() async {
final bcStops = await bc.parse();
final occtStops = await occt.parse();
final stops = [...bcStops, ...occtStops];
final result = [
for (final stop in stops)
stop.toJson(),
];
const encoder = JsonEncoder.withIndent(" ");
final contents = encoder.convert(result);
await outputFile.writeAsString(contents);
}
}

View file

@ -6,9 +6,14 @@ version: 1.0.0
environment:
sdk: ^3.7.0
resolution: workspace
# Add regular dependencies here.
dependencies:
csv: ^6.0.0
a_star: ^3.0.1
shelf: ^1.4.2
shelf_router: ^1.1.4
# path: ^1.8.0
dev_dependencies: