Compare commits

..

4 commits

68 changed files with 4948 additions and 12157 deletions

1
.vscode/launch.json vendored
View file

@ -17,7 +17,6 @@
"request": "launch",
"type": "dart",
"args": ["-d", "chrome", "--dart-define-from-file=.env"],
"program": "lib/main.dart",
},
]
}

41
LICENSE
View file

@ -1,41 +0,0 @@
Copyright (c) 2025 Spencer Powell & Levi Lesches
At time of writing this software is not licensed under a permissive license.
If you are Professor Steven Moore of Binghamton University you may access the code as it exists on May 5th 2025 under an MIT license for the purposes of grading.
A copy of the MIT license can be found at https://choosealicense.com/licenses/mit/ or at the bottom of this file.
If you are anyone else you ARE NOT granted permission to read, use, modify, copy, merge, publich, distribute, sublicense or sell the software or any other rights to this software.
Versions of this software written/published after May 5th 2025 may fall under a different license.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----------------------------------------------------
Example of MIT License THIS IS NOT THE LICENSE THIS SOFTWARE FALLS UNDER barring relicensing.
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,60 +1,47 @@
[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/vfKrPwQS)
# Binghamton Better Bus (BBB) v2
# << Project Title >>
## CS 445 Final Project
### Spring, 2025
### Team: Team 4
### Team: << team name >>
- Spencer Powell
- Levi Lesches
## Getting Started
This project is a website which allows for picking a source and destination and getting the ideal bus route between the 2 given static information.
<<One paragraph of project description goes here>>
### Roadmap
<<
A list of features, function or non-functional, you would like to add in the future if you had time, i.e. Phase 2 stuff
- [ ] Make use of time/direction information available in static scheduling information
- [ ] Place our hardware on OCCT buses to remove ETA Spot dependence/legal question mark
- [ ] Design/build hardware
- Keyword reminder for mobile data: IoT data plan and/or M2M
- [ ] Negotiate placing hardware on OCCT buses
- [ ] Chat with BCT about getting their live bus info
- [ ] Use machine learning to predict bus locations based on current position, current time of day and current route,
- [ ] Add Changelog
- [ ] Add back to top links
- [ ] Add Additional Templates w/ Examples
- [ ] Add "components" document to easily copy & paste sections of the readme
>>
## SRS
[doc](https://docs.google.com/document/d/1kSWMxsK0NakhvHRQnNx0wRlL6wDgYGO-tA7JB_4qldE/edit?usp=sharing)
### Prerequisites
* [Docker](https://www.docker.com/)
* Docker Compose
* [Just](https://github.com/casey/just)
* <<any additional software. Be specific about versions.>>
### Installing
cd into the `src` directory and run `just setup` then run `docker-compose build`.
To run just run `docker-compose up` in the `src` directory, the site will be up on `localhost:8080`
<<
A step by step series of examples that tell you how to get a development env running
Clearly outline each step and repeat until the environment is set up.
End with an example of getting some output from the system, such as a menu or prompt
>>
## Built With
* [Deno](https://deno.com/)
- [Acorn](https://oakserver.org/acorn)
* [Dart](https://dart.dev/)
* [Flutter](https://flutter.dev/)
- Google maps
* [Caddy](https://caddyserver.com/)
<< list all frameworks and modules used here >>
* [requests](https://docs.python-requests.org/en/latest/user/quickstart/#make-a-request) - request for humans
## License
DIY license written out which grants MIT rights to professor moore for the version of this submitted for grading and no rights to anyone else.
<< Add a [license](https://choosealicense.com/) >>
## Acknowledgments
* Claude and chatGPT were used for the creation of scripts for certain rote data conversion tasks
* This project would not exist with the data it has if not for prior instances of attempts to make this idea by US, of particular note is the usage of GTFS data which was only learned of in a prior attempt with Lucy Loerker
* Hat tip to anyone whose code was used
* Inspiration
* etc

BIN
demo.mp4

Binary file not shown.

View file

@ -1,16 +0,0 @@
# Topic: Deno
## Outline
- WHat is deno
- Deno imporvements over node
- typescript
- security
## Questions
- migration to deno
## Review
Good Job

View file

@ -5,18 +5,25 @@
## Requirements Attempted in this Sprint
- Routing is based on start and stop location
- Merge client sevrer to one branch, and integration
- Docker Services working
## Requirements complete
- Routing is based on start and stop location
- Merge client sevrer to one branch, and integration
- Docker Services working
## Requirements incomplete
## Requirement Flex Remaining
<< 3 / 5 >>
## Requirements Attempted in next Sprint
- Routing is based on start and stop location
# Milestone Status
Pass

View file

@ -1,71 +0,0 @@
# Sprint Meeting Notes
*note: replace anything surrounded by << >> and **remove** the << >>*
**Attended**: << record the team members in attendance (virtual counts as long as they are participating) >>
**DATE**: << meeting date >>
***
## Sprint << num >> Review
### SRS Sections Updated
<< List any SRS sections that were updated in the last sprint >>
### User Story
<< Corresponding User Stories completed in this sprint >>
### Sprint Requirements Attempted
<< The corresponding SRS requirement that the team completed in the last sprint >>
### Completed Requirements
<< The work that's been completed in this sprint >>
### Incomplete Requirements
<< The work that has not been completed in this sprint. Be VERY detailed and specific regarding what isn't working and what needs to be completed >>
### The summary of the entire project
<< A general overview of the entire project >>
***
## Sprint << num >> Planning
## Requirements Flex
<< # >>/5 requirement flexes remaining
## Technical Debt
<< Any requirements from the previous sprint that are using a technical flex >>
### Requirement Target
<< The corresponding SRS requirement that our team will be complete in next sprint >>
### User Stories
<< Corresponding User Stories >>
### Planning
<< Our team's detailed plan to complete the sprint >>
### Action Items
<< A list of things that need to happen in our for our team to complete the sprint >>
### Issues and Risks
<< A list of potential obstacles that could keep us from completing the sprint and what's being done about them >>
### Team Work Assignments
<< A list of each team member and their works assignments for this sprint >>

2
src/.gitignore vendored
View file

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

View file

@ -1,13 +0,0 @@
setup:
# this is a hack that is done due to a lack of communication
# towards the end of this project
# a slightly better hack could be done which avoids this but I
# don't wanna write things to work that way
cp -r client shared/client-dir
cp -r shared client/shared-dir
cp -r server/data shared/server-data
cp pubspec.yaml client/root-pubspec.yaml
cp pubspec.yaml shared/root-pubspec.yaml
clean:
rm -rf client/root-pubspec.yaml shared/root-pubspec.yaml shared/client-dir client/shared-dir shared/server-data

View file

@ -45,6 +45,3 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
shared-dir
root-pubspec.yaml

View file

@ -20,15 +20,11 @@ RUN flutter upgrade
RUN flutter doctor
COPY root-pubspec.yaml /client/pubspec.yaml
COPY ./shared-dir /client/shared
COPY ./ /client/client
WORKDIR /client/client
COPY ./ /client/
RUN flutter build web --dart-define-from-file=.env
WORKDIR /client/client/build/web
WORKDIR /client/build/web
EXPOSE 80/tcp

149
src/client/bin/bc.dart Normal file
View file

@ -0,0 +1,149 @@
import "dart:convert";
import "dart:io";
import "package:csv/csv.dart";
/// Utils on [Map].
extension MapUtils<K, V> on Map<K, V> {
/// Gets all the keys and values as 2-element records.
Iterable<(K, V)> get records => entries.map((entry) => (entry.key, entry.value));
}
extension on Directory {
String operator /(String child) => "$path/$child";
}
final serverDir = Directory("../server");
final dataDir = Directory(serverDir / "data/BCT");
final tripsFile = File(dataDir / "stop_times.txt");
final routesFile = File(dataDir / "trips.txt");
final stopsFile = File(dataDir / "stops.txt");
final routeNamesFile = File(dataDir / "routes.txt");
final outputFile = File(serverDir / "GET_STOPS.json");
extension type TripID(String id) { }
extension type StopID(String id) { }
extension type RouteID(String id) { }
class Stop {
final StopID id;
final double latitude;
final double longitude;
final String name;
final String description;
final String provider;
final Set<String> routes = {};
Stop({
required this.id,
required this.latitude,
required this.longitude,
required this.name,
required this.description,
required this.provider,
});
Map<String, dynamic> toJson() => {
"id": id.id,
"name": name,
"description": description,
"latitude": latitude,
"longitude": longitude,
"provider": provider,
"routes": routes.toList(),
};
}
final converter = CsvCodec(shouldParseNumbers: false).decoder;
Future<Map<TripID, Set<StopID>>> getTrips() async {
final contents = await tripsFile.readAsString();
final csv = converter.convert(contents);
final result = <TripID, Set<StopID>>{};
for (final row in csv.skip(1)) {
final tripId = TripID(row[0]);
final stopId = StopID(row[3]);
result[tripId] ??= <StopID>{};
result[tripId]!.add(stopId);
}
return result;
}
Future<Map<TripID, RouteID>> getRoutes() async {
final contents = await routesFile.readAsString();
final csv = converter.convert(contents);
final result = <TripID, RouteID>{};
for (final row in csv.skip(1)) {
final routeID = RouteID(row[0]);
final tripID = TripID(row[2]);
result[tripID] = routeID;
}
return result;
}
Future<Map<RouteID, String>> getRouteNames() async {
final contents = await routeNamesFile.readAsString();
final csv = converter.convert(contents);
return {
for (final row in csv.skip(1))
RouteID(row[0]): row[3],
};
}
Future<Map<StopID, Stop>> getStops() async {
final contents = await stopsFile.readAsString();
final csv = converter.convert(contents);
final result = <StopID, Stop>{};
for (final row in csv.skip(1)) {
final stopID = StopID(row[0]);
final name = row[2];
final description = row[3];
final latitude = double.parse(row[4]);
final longitude = double.parse(row[5]);
final stop = Stop(
id: stopID,
name: name,
description: description,
latitude: latitude,
longitude: longitude,
provider: "BC Transit",
);
result[stopID] = stop;
}
return result;
}
void findRoutesForStops({
required Iterable<Stop> stops,
required Map<TripID, Set<StopID>> trips,
required Map<TripID, RouteID> routes,
required Map<RouteID, String> routeNames,
}) {
for (final stop in stops) {
for (final (tripID, trip) in trips.records) {
if (!trip.contains(stop.id)) continue;
final routeID = routes[tripID]!;
final routeName = routeNames[routeID]!;
stop.routes.add(routeName);
}
}
}
Future<void> outputJson(Iterable<Stop> stops) async {
final result = [
for (final stop in stops)
stop.toJson(),
];
const encoder = JsonEncoder.withIndent(" ");
final contents = encoder.convert(result);
await outputFile.writeAsString(contents);
}
void main() async {
final trips = await getTrips();
final routes = await getRoutes();
final stops = await getStops();
final routeNames = await getRouteNames();
findRoutesForStops(stops: stops.values, trips: trips, routes: routes, routeNames: routeNames);
await outputJson(stops.values);
}

View file

@ -2,5 +2,4 @@ export "src/data/utils.dart";
export "src/data/trip.dart";
export "src/data/path.dart";
export "src/data/polyline.dart";
export "package:shared/data.dart";
export "src/data/stop.dart";

View file

@ -1,5 +1,5 @@
import "trip.dart";
import "package:shared/data.dart";
import "utils.dart";
class PathStep {
final PathTrip trip;

View file

@ -0,0 +1,13 @@
import "trip.dart";
import "utils.dart";
class Stop {
final double latitude;
final double longitude;
final List<TripID> trips;
Stop.fromJson(Json json) :
latitude = json["latitude"],
longitude = json["longitude"],
trips = json["trips"];
}

View file

@ -1,8 +1,9 @@
import "package:shared/data.dart";
import "utils.dart";
import "package:flutter/material.dart" show TimeOfDay;
extension type TripID(String id) { }
extension type StopID(String id) { }
class Trip {
final List<TripStop> stops;

View file

@ -1,7 +1,30 @@
import "package:google_maps_flutter/google_maps_flutter.dart";
/// A JSON object
typedef Json = Map<String, dynamic>;
import "package:shared/data.dart";
typedef FromJson<T> = T Function(Json);
extension CoordinatesUtils on Coordinates {
LatLng toLatLng() => LatLng(lat, long);
typedef Coordinates = ({double lat, double long});
/// Utils on [Map].
extension MapUtils<K, V> on Map<K, V> {
/// Gets all the keys and values as 2-element records.
Iterable<(K, V)> get records => entries.map((entry) => (entry.key, entry.value));
}
/// Zips two lists, like Python
Iterable<(E1, E2)> zip<E1, E2>(List<E1> list1, List<E2> list2) sync* {
if (list1.length != list2.length) throw ArgumentError("Trying to zip lists of different lengths");
for (var index = 0; index < list1.length; index++) {
yield (list1[index], list2[index]);
}
}
/// Extensions on lists
extension ListUtils<E> on List<E> {
/// Iterates over a pair of indexes and elements, like Python
Iterable<(int, E)> get enumerate sync* {
for (var i = 0; i < length; i++) {
yield (i, this[i]);
}
}
}

View file

@ -13,77 +13,92 @@ class HomePage extends ReactiveWidget<HomeModel> {
@override
Widget build(BuildContext context, HomeModel model) => Scaffold(
appBar: AppBar(title: const Text("Counter")),
body: Row(
body: Column(
children: [
SizedBox(
width: 325,
child: Sidebar(model),
),
Expanded(
child: Card(
child: Column(
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(
latitudeController: model.startLatitudeController,
longitudeController: model.startLongitudeController,
label: "Start at",
isShowingMarkers: model.markerState == MarkerState.start,
showMarkers: () => model.showMarkers(MarkerState.start),
hideMarkers: model.hideMarkers,
),
const SizedBox(height: 12),
LatLongEditor(
latitudeController: model.endLatitudeController,
longitudeController: model.endLongitudeController,
label: "End at",
isShowingMarkers: model.markerState == MarkerState.end,
showMarkers: () => model.showMarkers(MarkerState.end),
hideMarkers: model.hideMarkers,
),
const SizedBox(height: 24),
FilledButton(onPressed: model.search, child: const Text("Search for a path")),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
if (model.isLoading)
const LinearProgressIndicator()
else if (model.isSearching && model.path == null)
const Text("Could not connect to API")
else
for (final step in model.path ?? <PathStep>[]) Text(
"Get on at ${step.enter.lat}, ${step.enter.long}\n"
"Get off at ${step.exit.lat}, ${step.exit.long}",
width: 300,
child: Row(
children: [
Expanded(
child: TextField(
controller: model.startLatitudeController,
decoration: const InputDecoration(
hintText: "Start latitude",
border: OutlineInputBorder(),
),
Expanded(
child: model.isGoogleReady
? GoogleMap(
onMapCreated: (controller) => model.mapController = controller,
initialCameraPosition: const CameraPosition(
target: LatLng(42.10125081757972, -75.94181323552698),
zoom: 14,
),
markers: model.markers,
onTap: model.onMapTapped,
polylines: {
for (final (index, route) in model.paths.indexed) Polyline(
polylineId: PolylineId(index.toString()),
color: routeColors[index],
points: route,
),
},
)
: const Center(child: CircularProgressIndicator()),
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: model.startLongitudeController,
decoration: const InputDecoration(
hintText: "Start longitude",
border: OutlineInputBorder(),
),
),
),
],
),
),
const SizedBox(height: 12),
SizedBox(
width: 300,
child: Row(
children: [
Expanded(
child: TextField(
controller: model.endLatitudeController,
decoration: const InputDecoration(
hintText: "End latitude",
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: model.endLongitudeController,
decoration: const InputDecoration(
hintText: "End Longitude",
border: OutlineInputBorder(),
),
),
),
],
),
),
const SizedBox(height: 24),
FilledButton(onPressed: model.search, child: const Text("Search for a path")),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
if (model.isLoading)
const LinearProgressIndicator()
else if (model.isSearching && model.path == null)
const Text("Could not connect to API")
else
for (final step in model.path ?? <PathStep>[]) Text(
"Get on at ${step.enter.lat}, ${step.enter.long}\n"
"Get off at ${step.exit.lat}, ${step.exit.long}",
),
Expanded(
child: model.isGoogleReady
? GoogleMap(
initialCameraPosition: const CameraPosition(
target: LatLng(42.10125081757972, -75.94181323552698),
zoom: 14,
),
polylines: {
for (final (index, route) in model.routes.indexed) Polyline(
polylineId: PolylineId(index.toString()),
color: routeColors[index],
points: route,
),
},
)
: const Center(child: CircularProgressIndicator()),
),
],
),
);

View file

@ -1,53 +1,58 @@
import "package:flutter/foundation.dart" show debugPrint, kDebugMode;
import "dart:convert";
import "package:client/data.dart";
import "package:http/http.dart" as http;
import "api_client.dart";
import "package:client/data.dart";
import "service.dart";
class ApiService extends Service {
static const usingDocker = !kDebugMode;
final _client = ApiClient();
static const bool usingDocker = true;
final client = http.Client();
@override
Future<void> init() async {
debugPrint("Running with Docker? $usingDocker");
}
Future<void> init() async { }
Uri get _base => usingDocker
? Uri(path: "api/")
: Uri(scheme: "http", host: "localhost", port: 8001);
: Uri(scheme: "http", host: "localhost", port: 80);
Future<String?> getPath({
Future<List<T>?> _getAll<T>(Uri uri, FromJson<T> fromJson) async {
try {
final response = await client.get(uri);
if (response.statusCode != 200) return null;
final json = jsonDecode(response.body)["path"] as List;
return [
for (final obj in json.cast<Json>())
fromJson(obj),
];
} catch (error, stackTrace) {
// ignore: avoid_print
print("Error: $error\n$stackTrace");
return null;
}
}
Future<List<Trip>?> getTrips() => _getAll(
_base.resolve("trips"),
Trip.fromJson,
);
Future<Path?> getPath({
required Coordinates start,
required Coordinates end,
}) async {
final uri = Uri.parse("/tmp-api/path").replace(
}) => _getAll(
_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(),
},
);
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<Route>?> getRoutes() => _client.getJsonList(
_base.resolve("/routes"),
Route.fromJson,
),
PathStep.fromJson,
);
Future<List<Stop>?> getStops() => _client.getJsonList(
_base.resolve("/stops"),
Stop.fromJson,
);
}

View file

@ -1,30 +0,0 @@
import "dart:convert";
import "package:client/data.dart";
import "package:flutter/foundation.dart";
import "package:http/http.dart" as http;
class ApiClient {
final client = http.Client();
Future<List<T>?> getJsonList<T>(Uri uri, FromJson<T> fromJson, {String? key}) async {
try {
final response = await client.get(uri);
if (response.statusCode != 200) return null;
// No key: Assume the entire body is a list
// With key: Assume the body is a map with a list at the key
final listOfJson = key == null
? (jsonDecode(response.body) as List).cast<Json>()
: ((jsonDecode(response.body) as Json)[key] as List).cast<Json>();
return [
for (final json in listOfJson)
fromJson(json),
];
// Want to catch all errors for the UI
// ignore: avoid_catches_without_on_clauses
} catch (error, stackTrace) {
debugPrint("Error: $error\n$stackTrace");
return null;
}
}
}

View file

@ -1,23 +1,21 @@
import "dart:async";
import "package:client/data.dart";
import "package:client/services.dart";
import "package:flutter/widgets.dart" hide Path;
import "package:google_maps_flutter/google_maps_flutter.dart";
import "home_markers.dart";
import "view_model.dart";
typedef LatLng2 = (double lat, double lng);
/// The view model for the home page.
class HomeModel extends ViewModel with HomeMarkers {
class HomeModel extends ViewModel {
final startLatitudeController = TextEditingController();
final startLongitudeController = TextEditingController();
final endLatitudeController = TextEditingController();
final endLongitudeController = TextEditingController();
Path? path;
GoogleMapController? mapController;
double? get startLatitude => double.tryParse(startLatitudeController.text);
double? get startLongitude => double.tryParse(startLongitudeController.text);
@ -26,46 +24,16 @@ class HomeModel extends ViewModel with HomeMarkers {
bool isSearching = false;
bool isGoogleReady = false;
List<List<LatLng>> paths = [];
@override
Future<void> init() async {
await Future<void>.delayed(const Duration(seconds: 2));
isGoogleReady = true;
notifyListeners();
await updateMarkers();
}
void onMapTapped(LatLng coordinates) {
if (!shouldShowMarkers) return;
final marker = Marker(
markerId: markerId ?? const MarkerId("Tapped"),
position: coordinates,
icon: markerIcon ?? BitmapDescriptor.defaultMarker,
infoWindow: InfoWindow(title: "$startOrEnd here"),
);
switch (markerState!) {
case MarkerState.start: updateStart(coordinates, marker);
case MarkerState.end: updateEnd(coordinates, marker);
case MarkerState.override: return;
}
}
List<List<LatLng>> routes = [];
@override
void updateStart(LatLng coordinates, Marker marker) {
startLatitudeController.text = coordinates.latitude.toString();
startLongitudeController.text = coordinates.longitude.toString();
super.updateStart(coordinates, marker);
}
@override
void updateEnd(LatLng coordinates, Marker marker) {
endLatitudeController.text = coordinates.latitude.toString();
endLongitudeController.text = coordinates.longitude.toString();
super.updateEnd(coordinates, marker);
}
String? pathText;
Future<void> search() async {
final start = (lat: startLatitude, long: startLongitude);
final end = (lat: endLatitude, long: endLongitude);
@ -74,10 +42,15 @@ class HomeModel extends ViewModel with HomeMarkers {
isSearching = true;
isLoading = true;
final result = await services.api.getPath(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),
],
];
notifyListeners();
}
}

View file

@ -1,166 +0,0 @@
import "package:client/data.dart";
import "package:client/services.dart";
import "package:flutter/foundation.dart";
import "package:google_maps_flutter/google_maps_flutter.dart";
enum MarkerState {
start,
end,
override;
}
mixin HomeMarkers on ChangeNotifier {
MarkerState? markerState;
bool get shouldShowMarkers => markerState != null;
// List<Stop>? stops;
Map<StopID, Stop> stops = {};
Map<RouteID, Route> routes = {};
Marker? _startMarker;
Marker? _endMarker;
Iterable<Stop> getStopsForRoute(RouteID routeID) => routes[routeID]!.stops
.map((stopID) => stops[stopID]!);
Set<Marker> get _filteredMarkers => {
// for (final stop in stops ?? <Stop>[])
for (final routeID in routesToShow)
for (final stop in getStopsForRoute(routeID))
Marker(
markerId: MarkerId(stop.name),
position: stop.coordinates.toLatLng(),
onTap: () => onMarkerTapped(stop),
consumeTapEvents: true,
infoWindow: InfoWindow(
title: stop.name,
snippet: stop.summary,
),
),
};
Set<Marker> get markers => shouldShowMarkers ? _filteredMarkers : {
if (_startMarker != null) _startMarker!,
if (_endMarker != null) _endMarker!,
};
List<Route> bcRouteNames = [];
List<Route> occtRouteNames = [];
Set<RouteID> routesToShow = {};
Iterable<(String, List<Route>)> get providers => [
("OCCT", occtRouteNames),
("BC Transit", bcRouteNames),
];
int _parseBcNumber(String routeName) {
// eg, "53)" --> 53
final first = routeName.split(" ").first;
final withoutParen = first.substring(0, first.length - 1);
return int.parse(withoutParen);
}
int compareBcRoutes(Route a, Route b) =>
_parseBcNumber(a.shortName).compareTo(_parseBcNumber(b.shortName));
int compareOcctRoutes(Route a, Route b) =>
a.shortName.compareTo(b.shortName);
MarkerId? get markerId => switch (markerState!) {
MarkerState.start => const MarkerId("start"),
MarkerState.end => const MarkerId("end"),
_ => null,
};
BitmapDescriptor? get markerIcon => switch (markerState!) {
MarkerState.start => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan),
MarkerState.end => BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen),
_ => null,
};
String? get startOrEnd => switch (markerState!) {
MarkerState.start => "Start",
MarkerState.end => "End",
_ => null,
};
Future<void> updateMarkers() async {
final stopsResponse = await services.api.getStops();
if (stopsResponse == null) return;
stops = {
for (final stop in stopsResponse)
stop.id: stop,
};
final routesResponse = await services.api.getRoutes();
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.bc => bcRouteNames,
};
namesList.add(route);
}
occtRouteNames.sort(compareOcctRoutes);
bcRouteNames.sort(compareBcRoutes);
}
void showMarkers(MarkerState newState) {
markerState = newState;
notifyListeners();
}
void hideMarkers() {
markerState = null;
notifyListeners();
}
void onMarkerTapped(Stop stop) {
final coordinates = stop.coordinates.toLatLng();
final marker = Marker(
markerId: markerId ?? MarkerId(stop.name),
position: coordinates,
infoWindow: InfoWindow(
title: "$startOrEnd at ${stop.name}",
snippet: stop.description,
),
icon: markerIcon ?? BitmapDescriptor.defaultMarker,
);
switch (markerState!) {
case MarkerState.start: updateStart(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
void updateStart(LatLng coordinates, Marker marker) {
_startMarker = marker;
markerState = null;
notifyListeners();
}
@mustCallSuper
void updateEnd(LatLng coordinates, Marker marker) {
_endMarker = marker;
markerState = null;
notifyListeners();
}
void showRoute(Route route, {required bool shouldShow}) {
if (shouldShow) {
routesToShow.add(route.id);
} else {
routesToShow.remove(route.id);
}
notifyListeners();
}
}

View file

@ -1,60 +0,0 @@
import "package:client/widgets.dart";
import "package:flutter/material.dart";
class LatLongEditor extends StatelessWidget {
final TextEditingController latitudeController;
final TextEditingController longitudeController;
final String label;
final VoidCallback showMarkers;
final VoidCallback hideMarkers;
final bool isShowingMarkers;
const LatLongEditor({
required this.latitudeController,
required this.longitudeController,
required this.label,
required this.isShowingMarkers,
required this.showMarkers,
required this.hideMarkers,
super.key,
});
@override
Widget build(BuildContext context) => SizedBox(
width: 400,
child: Row(
children: [
Text(label, style: context.textTheme.titleSmall),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: latitudeController,
decoration: const InputDecoration(
hintText: "Latitude",
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: longitudeController,
decoration: const InputDecoration(
hintText: "Longitude",
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
if (isShowingMarkers) TextButton(
onPressed: hideMarkers,
child: const Text("Cancel"),
) else TextButton.icon(
icon: const Icon(Icons.location_on),
onPressed: showMarkers,
label: const Text("Pick on map"),
),
],
),
);
}

View file

@ -1,65 +0,0 @@
import "package:client/view_models.dart";
import "package:client/widgets.dart";
import "package:flutter/material.dart";
class Sidebar extends ReusableReactiveWidget<HomeModel> {
const Sidebar(super.model);
@override
Widget build(BuildContext context, HomeModel model) => DefaultTabController(
length: 2,
child: Card(
clipBehavior: Clip.hardEdge,
child: Column(
children: [
const SizedBox(height: 16),
if (model.markerState != null && model.markerState != MarkerState.override) ...[
Text(
"Select routes",
maxLines: 1,
style: context.textTheme.titleLarge,
textAlign: TextAlign.center,
),
Text(
"Or click anywhere on the map",
maxLines: 1,
style: context.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
const SizedBox(height: 8),
if (model.shouldShowMarkers) TabBar(
tabs: [
for (final (provider, _) in model.providers)
Tab(text: provider),
],
),
if (!model.shouldShowMarkers && model.pathText != null)
SingleChildScrollView(child: 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.fullName, maxLines: 1),
subtitle: Text(
"${route.stops.length} stops",
maxLines: 1,
),
value: model.routesToShow.contains(route.id),
onChanged: (value) => model.showRoute(route, shouldShow: value!),
),
],
),
],
),
),
],
),
),
);
}

View file

@ -1,4 +1,3 @@
export "src/view_models/view_model.dart";
export "src/view_models/home_markers.dart";
export "src/view_models/home.dart";

View file

@ -2,9 +2,7 @@ import "package:flutter/material.dart";
export "package:go_router/go_router.dart";
export "src/widgets/lat_long_editor.dart";
export "src/widgets/reactive_widget.dart";
export "src/widgets/sidebar.dart";
export "src/widgets/generic/reactive_widget.dart";
/// Helpful methods on [BuildContext].
extension ContextUtils on BuildContext {

426
src/client/pubspec.lock Normal file
View file

@ -0,0 +1,426 @@
# 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: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.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: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
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: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
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"
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: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "15.0.0"
web:
dependency: "direct main"
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.7.0-0 <4.0.0"
flutter: ">=3.27.0"

View file

@ -6,7 +6,6 @@ version: 0.1.0+1
environment:
sdk: ^3.5.0
resolution: workspace
dependencies:
fixnum: ^1.1.1
csv: ^6.0.0
@ -18,13 +17,12 @@ dependencies:
http: ^1.3.0
polyline_tools: ^0.0.2
web: ^1.1.1
shared:
path: ../shared
dev_dependencies:
dhttpd: ^4.1.0
flutter_test:
sdk: flutter
very_good_analysis: ^6.0.0
flutter:
uses-material-design: true

View file

@ -0,0 +1,30 @@
// 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);
});
}

View file

@ -6,17 +6,12 @@ services:
depends_on:
- server
- client
- hack
restart: unless-stopped
client:
build: ./client
restart: unless-stopped
hack:
build: ./shared
restart: unless-stopped
server:
build: ./server
environment:
@ -24,30 +19,30 @@ services:
#ports:
# - "127.0.0.1:8080:80"
restart: unless-stopped
# depends_on:
# neo4j:
# condition: service_healthy
#neo4j:
# image: neo4j:latest
# volumes:
# - neo4jconfig:/config
# - neo4jdata:/data
# - neo4jplugins:/plugins
# environment:
# # neo4j isn't exposed to the internet so having the password checked into version control doesn't matter
# - NEO4J_AUTH=neo4j/your_password
# ports:
# # useful for dev
# - "127.0.0.1:7474:7474"
# - "127.0.0.1:7687:7687"
# restart: unless-stopped
# healthcheck:
# test: wget http://localhost:7474 || exit 1
# interval: 1s
# timeout: 10s
# retries: 20
# start_period: 3s
depends_on:
neo4j:
condition: service_healthy
neo4j:
image: neo4j:latest
volumes:
- neo4jconfig:/config
- neo4jdata:/data
- neo4jplugins:/plugins
environment:
# neo4j isn't exposed to the internet so having the password checked into version control doesn't matter
- NEO4J_AUTH=neo4j/your_password
- NEO4J_PLUGINS=["graph-data-science"]
ports:
# useful for dev
- "127.0.0.1:7474:7474"
- "127.0.0.1:7687:7687"
restart: unless-stopped
healthcheck:
test: wget http://localhost:7474 || exit 1
interval: 1s
timeout: 10s
retries: 20
start_period: 3s
volumes:
neo4jconfig:

View file

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

View file

@ -3,7 +3,4 @@
handle_path /api/* {
reverse_proxy server:80
}
handle_path /tmp-api/* {
reverse_proxy hack:8001
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
[
"https://binghamtonupublic.etaspot.net/service.php?service=get_routes&token=TESTING",
{
"id": 1,
"name": "Westside Outbound",

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,13 @@ interface GTFSStop {
stop_url?: string;
[key: string]: string | undefined; // Allow for additional fields
}
interface GTFSStopTime {
trip_id: string;
arrival_time: string;
departure_time: string;
stop_id: string;
stop_sequence: number;
}
/**
* Sets up a Neo4j graph database with location nodes from JSON and GTFS stop data
@ -31,19 +38,24 @@ export async function graph_setup(
driver: Neo4j.Driver,
OCCT_stops_json: string = "./data/OCCT/stops.json",
BCT_GTFS_stops_txt: string = "./data/BCT/stops.txt",
BCT_GTFS_stops_times_txt: string = "./data/BCT/stop_times.txt",
): Promise<void> {
const session = driver.session();
const jsonData = await Deno.readTextFile(OCCT_stops_json);
const locationNodes: LocationNode[] = JSON.parse(jsonData);
await stops_json_node_import(session, locationNodes);
await stops_json_node_import(session, locationNodes, { provider: "OCCT" });
const BCTStopsData = await Deno.readTextFile(BCT_GTFS_stops_txt);
const BCT_stops = await parse_gtfs_stops(BCTStopsData);
await stops_gtfs_node_import(session, BCT_stops);
await stops_gtfs_node_import(session, BCT_stops, { provider: "BCT" });
const BCTStopTimesData = await Deno.readTextFile(BCT_GTFS_stops_times_txt);
const BCT_stop_times = await parse_gtfs_stop_times(BCTStopTimesData);
await gtfs_edge_import(session, BCT_stop_times);
await session.close();
}
@ -51,7 +63,9 @@ export async function graph_setup(
async function stops_json_node_import(
session: Neo4j.Session,
stops: LocationNode[],
options: { provider: string },
) {
const { provider } = options;
for (const node of stops) {
await session.run(
`
@ -60,18 +74,19 @@ async function stops_json_node_import(
n.originalId = $originalId,
n.latitude = $lat,
n.longitude = $lng,
n.source = 'OCCT'
n.source = $provider
ON MATCH SET
n.originalId = $originalId,
n.latitude = $lat,
n.longitude = $lng,
n.source = 'OCCT'
n.source = $provider
`,
{
id: `OCCT_${node.id}`,
id: `${provider}_${node.id}`,
originalId: node.id,
lat: node.lat,
lng: node.lng,
provider,
},
);
}
@ -80,6 +95,8 @@ async function stops_json_node_import(
async function stops_gtfs_node_import(
session: Neo4j.Session,
stops: GTFSStop[],
// default provider is to avoid breaking upstream
options: { provider: string },
) {
// Add GTFS stops to Neo4j
for (const stop of stops) {
@ -89,7 +106,7 @@ async function stops_gtfs_node_import(
) {
continue;
}
const { provider } = options;
// Use MERGE to update existing nodes or create new ones
await session.run(
`
@ -98,23 +115,20 @@ async function stops_gtfs_node_import(
s.name = $name,
s.latitude = $lat,
s.longitude = $lng,
s.locationType = $locationType,
s.parentStation = $parentStation,
s.zoneId = $zoneId,
s.url = $url,
s.source = 'BCT'
s.originalId = $originalId,
s.source = $provider
ON MATCH SET
s.name = $name,
s.latitude = $lat,
s.longitude = $lng,
s.locationType = $locationType,
s.parentStation = $parentStation,
s.zoneId = $zoneId,
s.url = $url,
s.source = 'BCT'
s.originalId = $originalId,
s.source = $provider
`,
{
id: "BCT_" + stop.stop_id,
id: `${provider}_` + stop.stop_id,
originalId: stop.stop_id,
name: stop.stop_name,
lat: parseFloat(stop.stop_lat),
lng: parseFloat(stop.stop_lon),
@ -122,6 +136,7 @@ async function stops_gtfs_node_import(
parentStation: stop.parent_station || null,
zoneId: stop.zone_id || null,
url: stop.stop_url || null,
provider,
},
);
}
@ -168,3 +183,69 @@ async function parse_gtfs_stops(txt: string): Promise<GTFSStop[]> {
return validStops;
}
async function parse_gtfs_stop_times(
fileContents: string,
): Promise<GTFSStopTime[]> {
const records = await parseCSV(fileContents, {
skipFirstRow: true,
columns: true,
});
const stopTimes: GTFSStopTime[] = [];
for (const row of records as Record<string, string>[]) {
stopTimes.push({
trip_id: row.trip_id,
arrival_time: row.arrival_time,
departure_time: row.departure_time,
stop_id: row.stop_id,
stop_sequence: parseInt(row.stop_sequence, 10),
});
}
return stopTimes;
}
async function gtfs_edge_import(
session: neo4j.Session,
stop_times: GTFSStopTime[],
): Promise<void> {
const groupedByTrip = new Map<string, GTFSStopTime[]>();
// Group by trip_id
for (const stopTime of stop_times) {
if (!groupedByTrip.has(stopTime.trip_id)) {
groupedByTrip.set(stopTime.trip_id, []);
}
groupedByTrip.get(stopTime.trip_id)!.push(stopTime);
}
for (const [tripId, stops] of groupedByTrip.entries()) {
// Sort by stop_sequence
stops.sort((a, b) => a.stop_sequence - b.stop_sequence);
for (let i = 0; i < stops.length - 1; i++) {
const from = stops[i];
const to = stops[i + 1];
await session.run(
`
MATCH (from:TransportNode {originalId: $fromId}), (to:TransportNode {originalId: $toId})
MERGE (from)-[:DEPARTS_TO {
tripId: $tripId,
departureTime: $departureTime,
arrivalTime: $arrivalTime
}]->(to)
`,
{
fromId: from.stop_id,
toId: to.stop_id,
tripId,
departureTime: from.departure_time,
arrivalTime: to.arrival_time,
},
);
}
}
}

View file

@ -7,11 +7,10 @@ import { assert } from "@std/assert";
import neo4j from "https://deno.land/x/neo4j_driver_lite@5.28.1/mod.ts";
import { graph_setup } from "./graph.ts";
const usingDocker = !Deno.env.has("DISABLE_DOCKER");
const usingDocker = true;
const root_url: string = usingDocker
? Deno.env.get("ROOT_URL") as string
: "http://localhost:8080/api";
console.log(`Running in docker configuration? ${usingDocker}`);
assert(root_url, "ROOT_URL env var not defined");
// bind values we're actually using
@ -24,13 +23,13 @@ const substitute_base_name = substitute_base_name_.bind(
const { hostname, port } = { hostname: "0.0.0.0", port: 80 };
const neo4jHost = usingDocker ? "neo4j" : "127.0.0.1"; // localhost does NOT work
//const graph_driver = neo4j.driver(
// `neo4j://${neo4jHost}:7687`,
// neo4j.auth.basic("neo4j", "your_password"),
//);
//console.log("initializing graph with static data");
//await graph_setup(graph_driver);
//console.log("graph initialization complete");
const graph_driver = neo4j.driver(
`neo4j://${neo4jHost}:7687`,
neo4j.auth.basic("neo4j", "your_password"),
);
console.log("initializing graph with static data");
await graph_setup(graph_driver);
console.log("graph initialization complete");
const db = new DatabaseSync(":memory:");
db_setup(db);
@ -78,7 +77,7 @@ router.get(
"/stops",
async (_ctx) => {
try {
const bytes = await Deno.readFile(`GET_STOPS.json`);
const bytes = await Deno.readFile(`bct_stops.json`);
const headers = new Headers();
json_mime_add(headers);
cors_add(headers);

View file

@ -1,5 +0,0 @@
@echo off
echo Start Neo4J Desktop
pause
set DISABLE_DOCKER=true
deno run -A index.ts

View file

@ -1,9 +0,0 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/
pubspec.lock
client-dir
server-data
root-pubspec.yaml
path.log
notes.md

View file

@ -1,3 +0,0 @@
## 1.0.0
- Initial version.

View file

@ -1,42 +0,0 @@
FROM alpine:3.21
RUN mkdir /client
RUN apk add bash curl file git unzip which zip gcompat wget tar xz
#ENTRYPOINT bash
WORKDIR /client
RUN wget -O flutter.tar.xz https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.29.0-stable.tar.xz
RUN tar xf flutter.tar.xz
ENV PATH="/client/flutter/bin/:${PATH}"
RUN git config --global --add safe.directory /client/flutter
RUN flutter upgrade
RUN flutter doctor
COPY root-pubspec.yaml /client/pubspec.yaml
COPY ./ /client/shared
RUN mkdir /client/client
COPY ./client-dir/* /client/client
RUN mkdir /client/server
COPY server-data /client/server/data
RUN dart pub get
WORKDIR /client/shared
EXPOSE 80/tcp
CMD ["dart", "bin/server.dart"]

View file

@ -1,2 +0,0 @@
A sample command-line application with an entrypoint in `bin/`, library code
in `lib/`, and example unit test in `test/`.

View file

@ -1,11 +0,0 @@
import "package:shared/generator.dart";
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.routes.generate();
}
}

View file

@ -1,25 +0,0 @@
// 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, "0.0.0.0", 8001);
print("Listening on 0.0.0.0:8001");
}

View file

@ -1,18 +0,0 @@
// 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,4 +0,0 @@
export "src/data/provider.dart";
export "src/data/route.dart";
export "src/data/stop.dart";
export "src/data/utils.dart";

View file

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

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

View file

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

View file

@ -1,14 +0,0 @@
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;
@override
String toString() => humanName;
}

View file

@ -1,68 +0,0 @@
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(Provider.occt, stopID.toString()),
];
Route.fromBcCsv(CsvRow csv) :
id = RouteID.fromBcCsv(csv),
provider = Provider.bc,
shortName = csv[2],
fullName = "${csv[2]}) ${csv[3]}",
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
Json toJson() => {
"id": id,
"provider": provider.id,
"full_name": fullName,
"short_name": shortName,
"stops": stops,
};
}

View file

@ -1,79 +0,0 @@
import "utils.dart";
import "route.dart";
import "provider.dart";
extension type StopID._(String id) {
StopID(Provider provider, Object value) : id = "${provider.id}_$value";
StopID.fromJson(dynamic value) : id = value.toString();
}
class Stop with Encodable {
final StopID id;
final String name;
final String? description;
final Coordinates coordinates;
final Provider provider;
final Set<String> routes;
final Set<RouteID> routeIDs;
Stop({
required this.id,
required this.name,
required this.description,
required this.coordinates,
required this.provider,
}) : routes = {}, routeIDs = {};
Stop.fromJson(Json json) :
id = StopID.fromJson(json["id"]),
name = json["name"],
description = json["description"],
coordinates = (lat: json["latitude"], long: json["longitude"]),
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(Provider.occt, json["id"]),
name = json["name"],
description = null,
coordinates = (lat: json["lat"], long: json["lng"]),
provider = Provider.occt,
routes = <String>{},
routeIDs = <RouteID>{};
@override
String toString() => name;
@override
Map<String, dynamic> toJson() => {
"id": id.id,
"name": name,
"description": description,
"latitude": coordinates.lat,
"longitude": coordinates.long,
"provider": provider.id,
"routes": routes.toList(),
"route_ids": routeIDs.toList(),
};
String get summary {
final buffer = StringBuffer();
if (description != null) {
buffer.writeln(description);
}
if (routes.isNotEmpty) {
buffer.writeln("Routes:");
routes.forEach(buffer.writeln);
}
return buffer.toString();
}
void addRoute(RouteID id, String name) {
routeIDs.add(id);
routes.add(name);
}
}

View file

@ -1,61 +0,0 @@
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.
Iterable<(K, V)> get records => entries.map((entry) => (entry.key, entry.value));
}
/// Zips two lists, like Python
Iterable<(E1, E2)> zip<E1, E2>(List<E1> list1, List<E2> list2) sync* {
if (list1.length != list2.length) throw ArgumentError("Trying to zip lists of different lengths");
for (var index = 0; index < list1.length; index++) {
yield (list1[index], list2[index]);
}
}
/// Extensions on lists
extension ListUtils<E> on List<E> {
/// Iterates over a pair of indexes and elements, like Python
Iterable<(int, E)> get enumerate sync* {
for (var i = 0; i < length; i++) {
yield (i, this[i]);
}
}
}
extension IterableUtils<E> on Iterable<E> {
E max(num Function(E) count) => reduce((a, b) {
final numA = count(a);
final numB = count(b);
return numA > numB ? a : b;
});
E min(num Function(E) count) => reduce((a, b) {
final numA = count(a);
final numB = count(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

@ -1,45 +0,0 @@
import "dart:convert";
import "dart:io";
import "package:shared/generator.dart";
class Generator<ID, T extends Encodable> extends Parser<ID, T> {
final Parser<ID, T> bc;
final Parser<ID, T> occt;
final File outputFile;
Generator({
required this.bc,
required this.occt,
required this.outputFile,
});
@override
Future<Map<ID, T>> parse() async => {
...await bc.parse(),
...await occt.parse(),
};
Future<void> generate() async {
final allData = await parse();
final result = [
for (final data in allData.values)
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

@ -1,47 +0,0 @@
import "dart:io";
import "utils.dart";
import "stops_bc.dart";
typedef InOutTrips = (TripID, TripID);
class BcRoutesParser extends Parser<RouteID, 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<Map<RouteID, 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]!;
log("Chose $longestTrip as the longest trip for $routeID");
route.stops.addAll(stops);
}
return routes;
}
}

View file

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

View file

@ -1,80 +0,0 @@
import "dart:io";
import "package:csv/csv.dart";
import "utils.dart";
class BcStopParser extends Parser<StopID, Stop> {
static final tripsFile = File(bcDataDir / "stop_times.txt");
static final routesFile = File(bcDataDir / "trips.txt");
static final stopsFile = File(bcDataDir / "stops.txt");
static final routeNamesFile = File(bcDataDir / "routes.txt");
static final converter = CsvCodec(shouldParseNumbers: false).decoder;
Future<Map<TripID, List<StopID>>> getTrips() async {
final result = <TripID, List<StopID>>{};
for (final row in await readCsv(tripsFile)) {
result.addToList(TripID(row[0]), StopID(Provider.bc, row[3]));
}
return result;
}
Future<Map<TripID, RouteID>> getRoutes() async => {
for (final row in await readCsv(routesFile))
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(Provider.bc, row[0]): "${row[2]}) ${row[3]}",
};
Future<Map<StopID, Stop>> getStops() async {
final result = <StopID, Stop>{};
for (final row in await readCsv(stopsFile)) {
final stopID = StopID(Provider.bc, row[0]);
final name = row[2];
final description = row[3];
final latitude = double.parse(row[4]);
final longitude = double.parse(row[5]);
final stop = Stop(
id: stopID,
name: name,
description: description,
coordinates: (lat: latitude, long: longitude),
provider: Provider.bc,
);
result[stopID] = stop;
}
return result;
}
void findRoutesForStops({
required Iterable<Stop> stops,
required Map<TripID, List<StopID>> trips,
required Map<TripID, RouteID> routes,
required Map<RouteID, String> routeNames,
}) {
for (final stop in stops) {
for (final (tripID, trip) in trips.records) {
if (!trip.contains(stop.id)) continue;
final routeID = routes[tripID]!;
final routeName = routeNames[routeID];
if (routeName == null) continue; // old route
stop.addRoute(routeID, routeName);
}
}
}
@override
Future<Map<StopID, Stop>> parse() async {
final trips = await getTrips();
final routes = await getRoutes();
final stops = await getStops();
final routeNames = await getRouteNames();
findRoutesForStops(stops: stops.values, trips: trips, routes: routes, routeNames: routeNames);
return stops;
}
}

View file

@ -1,51 +0,0 @@
import "dart:io";
import "utils.dart";
class OcctStopParser extends Parser<StopID, Stop> {
/// Taken from: https://binghamtonupublic.etaspot.net/service.php?service=get_stops&token=TESTING
static final stopsFile = File(occtDataDir / "stops.json");
/// Taken from: https://binghamtonupublic.etaspot.net/service.php?service=get_routes&token=TESTING
static final routesFile = File(occtDataDir / "routes.json");
Future<Map<StopID, Stop>> getStops() async {
final result = <StopID, Stop>{};
for (final stopJson in await readJson(stopsFile)) {
final id = StopID(Provider.occt, stopJson["id"]);
result[id] ??= Stop.fromOcctJson(stopJson);
}
return result;
}
Future<Map<StopID, List<RouteID>>> getRoutes() async {
final result = <StopID, List<RouteID>>{};
for (final json in await readJson(stopsFile)) {
final stopID = StopID(Provider.occt, json["id"]);
final routeID = RouteID(Provider.occt, json["rid"]);
result.addToList(stopID, routeID);
}
return result;
}
Future<Map<RouteID, String>> getRouteNames() async => {
for (final json in await readJson(routesFile))
RouteID(Provider.occt, json["id"]): json["name"],
};
@override
Future<Map<StopID, Stop>> parse() async {
final stops = await getStops();
final routes = await getRoutes();
final routeNames = await getRouteNames();
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.addRoute(routeID, routeName);
}
}
return stops;
}
}

View file

@ -1,50 +0,0 @@
import "dart:convert";
import "dart:io";
import "package:csv/csv.dart";
import "package:shared/data.dart";
export "package:shared/data.dart";
extension type TripID(String id) {
TripID.fromJson(dynamic value) : id = value.toString();
}
extension DirectoryUtils on Directory {
String operator /(String child) => "$path/$child";
}
extension MapListUtils<K, V> on Map<K, List<V>> {
void addToList(K key, V value) {
this[key] ??= [];
this[key]!.add(value);
}
}
extension MapSetUtils<K, V> on Map<K, Set<V>> {
void addToSet(K key, V value) {
this[key] ??= {};
this[key]!.add(value);
}
}
abstract class Parser<ID, T> {
Future<Map<ID, T>> parse();
}
final csvConverter = CsvCodec(shouldParseNumbers: false).decoder;
Future<Iterable<CsvRow>> readCsv(File file) async {
final contents = await file.readAsString();
final csv = csvConverter.convert(contents);
return csv.skip(1).map<CsvRow>((row) => row.cast<String>());
}
Future<List<Json>> readJson(File file) async {
final contents = await file.readAsString();
final json = jsonDecode(contents);
return (json as List).cast<Json>();
}
final serverDir = Directory("../server");
final _dataDir = Directory(serverDir / "data");
final occtDataDir = Directory(_dataDir / "OCCT");
final bcDataDir = Directory(_dataDir / "BCT");

View file

@ -1,60 +0,0 @@
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,177 +0,0 @@
import "package:a_star/a_star.dart";
import "package:shared/data.dart";
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<StopID, Stop> stops;
static Iterable<Stop> findStopsNear(Coordinates location) {
const numStops = 5;
log("Finding stops near $location...");
final stopDistances = [
for (final stop in stops.values)
(stop, stop.coordinates.distanceTo(location)),
];
stopDistances.sort((a, b) => a.$2.compareTo(b.$2));
return stopDistances.take(numStops).map((pair) => pair.$1);
}
final StopID stopID;
final StopID goalID;
final RouteID routeID;
final double distanceWalked;
final SearchMethod method;
StopState({
required this.stopID,
required this.goalID,
required this.routeID,
required this.distanceWalked,
required this.method,
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
String hash() {
final stop = stops[stopID];
final goal = stops[goalID];
final route = routes[routeID];
return "${method.name}-$stop-$goal-$route";
}
@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);
if (stopIndex == -1) return [];
final result = <StopState>[];
// 1): Go forward one stop on the same route
final nextIndex = stopIndex + 1;
if (nextIndex < route.stops.length) {
final nextStopID = route.stops[nextIndex];
final state = StopState(
stopID: nextStopID,
goalID: goalID,
routeID: routeID,
depth: depth + 1,
distanceWalked: distanceWalked,
method: SearchMethod.bus,
);
result.add(state);
}
// 2) Go back one stop on the same route
final prevIndex = stopIndex - 1;
if (prevIndex > 0) {
final prevStopID = route.stops[prevIndex];
final state = StopState(
stopID: prevStopID,
goalID: goalID,
routeID: routeID,
depth: depth + 1,
distanceWalked: distanceWalked,
method: SearchMethod.bus,
);
result.add(state);
}
// 3) Make any available transfers
for (final otherRouteID in stop.routeIDs.difference({routeID})) {
final state = StopState(
stopID: stopID,
goalID: goalID,
routeID: otherRouteID,
depth: depth + 2,
distanceWalked: distanceWalked,
method: SearchMethod.transfer,
);
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;
}
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

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

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

@ -1,15 +0,0 @@
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);
}

View file

@ -1,22 +0,0 @@
name: shared
description: A sample command-line application.
version: 1.0.0
# repository: https://github.com/my_org/my_repo
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:
lints: ^5.0.0
test: ^1.24.0
very_good_analysis: ^7.0.0