- SEPTASTIC is a website and (a soon to be) mobile app. Its purpose is to provide
- information about how to ride SEPTA (and connecting transit authorities) in a
- quick and information-rich manner.
-
-
-
- {% else %}
-
- {% endif %}
-
- This website is not run by SEPTA. Data may be inaccurate.
-
-
-
-
-
- {{ content|safe }}
-
-
-
-
-
diff --git a/api/templates/route.html b/api/templates/route.html
deleted file mode 100644
index 1684adf..0000000
--- a/api/templates/route.html
+++ /dev/null
@@ -1,142 +0,0 @@
-{%- import "route_symbol.html" as scope -%}
-
-
-
-
-
- {% call scope::route_symbol(route) %}
-
{{ route.name }}
-
-
-{% for timetable in timetables %}
-
-
-
-
{{ timetable.direction.direction | capitalize }} to
- {{ timetable.direction.direction_destination }}
-
-
-
-
-{% endfor %}
diff --git a/api/templates/route_symbol.html b/api/templates/route_symbol.html
deleted file mode 100644
index aca4ed4..0000000
--- a/api/templates/route_symbol.html
+++ /dev/null
@@ -1,19 +0,0 @@
-{% macro route_symbol(route) %}
- {% match route.route_type %}
- {% when libseptastic::route::RouteType::Trolley | libseptastic::route::RouteType::SubwayElevated %}
-
- {{ route.short_name }}
-
- {% endwhen %}
- {% when libseptastic::route::RouteType::RegionalRail %}
-
- {{ route.short_name }}
-
- {% endwhen %}
- {% when libseptastic::route::RouteType::Bus | libseptastic::route::RouteType::TracklessTrolley %}
-
- {{ route.short_name }}
-
- {% endwhen %}
- {% endmatch %}
-{% endmacro %}
diff --git a/api/templates/routes.html b/api/templates/routes.html
deleted file mode 100644
index 2c8d711..0000000
--- a/api/templates/routes.html
+++ /dev/null
@@ -1,60 +0,0 @@
-
Routes
-
-
Click on a route to see details and a schedule. Schedules in prevailing local time.
-
-
-
-
-
-
-
-
diff --git a/api/templates/stop.html b/api/templates/stop.html
deleted file mode 100644
index 2f6c132..0000000
--- a/api/templates/stop.html
+++ /dev/null
@@ -1,28 +0,0 @@
-{%- import "route_symbol.html" as scope -%}
-{%- import "stop_table.html" as stop_table -%}
-
-
-
{{ stop.name }}
-
-
-
With service available on:
-
- {% for route in routes %}
-
- {% call scope::route_symbol(route) %}
-
- {% endfor %}
-
-
-{#{% if let libseptastic::stop::StopType::MultiPlatform(platforms) = stop.platforms %}
-
-
Platforms at this station:
- {% for platform in platforms %}
-
{{ platform.name }}
- {% endfor %}
-
-{% endif %}#}
-
-
- {% call stop_table::stop_table(trips, current_time) %}
-
diff --git a/api/templates/stop_table.html b/api/templates/stop_table.html
deleted file mode 100644
index 71ff6b1..0000000
--- a/api/templates/stop_table.html
+++ /dev/null
@@ -1,51 +0,0 @@
-{%- import "route_symbol.html" as scope -%}
-
-{% macro stop_table(trips, current_time) %}
-
-
- | ROUTE |
- DESTINATION |
- BOARDING AREA |
- TIME |
- VEHICLE |
-
-{% for trip in trips %}
-
- |
- {% call scope::route_symbol(trip.trip.route) %}
- |
-
- {{ trip.trip.direction.direction_destination }}
- |
-
- {{ trip.perspective_stop.platform.name }}
- |
- {% if let Tracked(tracked_trip) = trip.trip.tracking_data %}
-
- {{ &trip.perspective_stop.get_arrival_time(&tracked_trip) | format_time }}
- {{ ( trip.perspective_stop.get_arrival_time(&tracked_trip) - current_time) / 60 }} mins
- |
- {% else %}
-
- {{ trip.perspective_stop.arrival_time | format_time }}
- {{ (trip.perspective_stop.arrival_time - current_time) / 60 }} mins
- |
- {% endif %}
- {% if let Tracked(tracked_trip) = trip.trip.tracking_data %}
-
- {{ tracked_trip.vehicle_ids.join(", ") }}
- |
- {% else %}
-
- -
- |
- {% endif %}
-
-{% endfor %}
-
- |
- Updated at: {{ current_time | format_time_with_seconds }}
- |
-
-
-{% endmacro %}
diff --git a/api/templates/stop_table_impl.html b/api/templates/stop_table_impl.html
deleted file mode 100644
index e230ec6..0000000
--- a/api/templates/stop_table_impl.html
+++ /dev/null
@@ -1,3 +0,0 @@
-{%- import "stop_table.html" as stop_table -%}
-
-{% call stop_table::stop_table(trips, current_time) %}
diff --git a/api/templates/stops.html b/api/templates/stops.html
deleted file mode 100644
index e1bf30a..0000000
--- a/api/templates/stops.html
+++ /dev/null
@@ -1,27 +0,0 @@
-
Stops
-
-
Click on a route to see details and a schedule. Schedules in prevailing local time.
-
-
-
-
diff --git a/example.env b/example.env
deleted file mode 100644
index 93d1f56..0000000
--- a/example.env
+++ /dev/null
@@ -1 +0,0 @@
-DB_CONNSTR=
diff --git a/libseptastic/src/agency.rs b/libseptastic/src/agency.rs
index 1d1697e..ffb19be 100644
--- a/libseptastic/src/agency.rs
+++ b/libseptastic/src/agency.rs
@@ -1,8 +1,7 @@
use serde::{Deserialize, Serialize};
-
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Agency {
pub id: String,
- pub name: String
+ pub name: String,
}
diff --git a/libseptastic/src/direction.rs b/libseptastic/src/direction.rs
index 88bbc54..ff3f9f1 100644
--- a/libseptastic/src/direction.rs
+++ b/libseptastic/src/direction.rs
@@ -1,7 +1,8 @@
-
use serde::{Deserialize, Serialize};
-#[derive(sqlx::Type, Serialize, Deserialize, PartialEq, Debug, Clone, Copy, Eq)]
+#[derive(
+ sqlx::Type, Serialize, Deserialize, PartialEq, Debug, Clone, Copy, Eq, PartialOrd, Ord,
+)]
#[sqlx(type_name = "septa_direction_type", rename_all = "snake_case")]
pub enum CardinalDirection {
Northbound,
@@ -10,17 +11,17 @@ pub enum CardinalDirection {
Westbound,
Inbound,
Outbound,
- Loop
+ Loop,
}
#[derive(::sqlx::Decode, Serialize, Deserialize, Debug, Clone)]
pub struct Direction {
pub direction: CardinalDirection,
- pub direction_destination: String
+ pub direction_destination: String,
}
impl std::fmt::Display for CardinalDirection {
- fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let output = match self {
CardinalDirection::Northbound => "Northbound",
CardinalDirection::Southbound => "Southbound",
@@ -28,7 +29,7 @@ impl std::fmt::Display for CardinalDirection {
CardinalDirection::Westbound => "Westbound",
CardinalDirection::Inbound => "Inbound",
CardinalDirection::Outbound => "Outbound",
- CardinalDirection::Loop => "Loop"
+ CardinalDirection::Loop => "Loop",
};
std::write!(f, "{}", output)
}
diff --git a/libseptastic/src/lib.rs b/libseptastic/src/lib.rs
index 29bdfe8..921fa32 100644
--- a/libseptastic/src/lib.rs
+++ b/libseptastic/src/lib.rs
@@ -1,8 +1,8 @@
-pub mod route;
pub mod agency;
-pub mod stop;
-pub mod route_stop;
-pub mod stop_schedule;
-pub mod schedule_day;
pub mod direction;
pub mod ridership;
+pub mod route;
+pub mod route_stop;
+pub mod schedule_day;
+pub mod stop;
+pub mod stop_schedule;
diff --git a/libseptastic/src/ridership.rs b/libseptastic/src/ridership.rs
index 84bc2cd..a5af70f 100644
--- a/libseptastic/src/ridership.rs
+++ b/libseptastic/src/ridership.rs
@@ -17,5 +17,5 @@ pub struct Ridership {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineRidership {
pub route_id: String,
- pub unlinked_trips: i64
+ pub unlinked_trips: i64,
}
diff --git a/libseptastic/src/route.rs b/libseptastic/src/route.rs
index 4189f71..62d04e4 100644
--- a/libseptastic/src/route.rs
+++ b/libseptastic/src/route.rs
@@ -1,3 +1,5 @@
+use std::cmp::Ordering;
+
use serde::{Deserialize, Serialize};
#[derive(sqlx::Type, Serialize, Deserialize, PartialEq, Debug, Clone)]
@@ -7,7 +9,7 @@ pub enum RouteType {
SubwayElevated,
RegionalRail,
Bus,
- TracklessTrolley
+ TracklessTrolley,
}
#[derive(::sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
@@ -16,13 +18,33 @@ pub struct Route {
pub short_name: String,
pub color_hex: String,
pub route_type: RouteType,
- pub id: String
+ pub id: String,
+ pub directions: Vec
,
}
+impl PartialEq for Route {
+ fn eq(&self, other: &Self) -> bool {
+ self.id == other.id
+ }
+}
+
+impl Eq for Route {}
+
+impl Ord for Route {
+ fn cmp(&self, other: &Self) -> Ordering {
+ self.id.cmp(&other.id)
+ }
+}
+
+impl PartialOrd for Route {
+ fn partial_cmp(&self, other: &Self) -> Option {
+ Some(self.id.cmp(&other.id))
+ }
+}
#[derive(::sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
pub struct InterlinedRoute {
pub interline_id: String,
pub interline_name: String,
- pub interlined_routes: Vec
+ pub interlined_routes: Vec,
}
diff --git a/libseptastic/src/route_stop.rs b/libseptastic/src/route_stop.rs
index 8d9d4b4..6ad1700 100644
--- a/libseptastic/src/route_stop.rs
+++ b/libseptastic/src/route_stop.rs
@@ -3,5 +3,5 @@ pub struct RouteStop {
pub route_id: String,
pub stop_id: i64,
pub direction_id: i64,
- pub stop_sequence: i64
+ pub stop_sequence: i64,
}
diff --git a/libseptastic/src/schedule_day.rs b/libseptastic/src/schedule_day.rs
index b91409a..9d2f988 100644
--- a/libseptastic/src/schedule_day.rs
+++ b/libseptastic/src/schedule_day.rs
@@ -1,5 +1,5 @@
#[derive(Debug, Clone)]
pub struct ScheduleDay {
pub date: String,
- pub service_id: String
+ pub service_id: String,
}
diff --git a/libseptastic/src/stop.rs b/libseptastic/src/stop.rs
index 4520120..e5099e1 100644
--- a/libseptastic/src/stop.rs
+++ b/libseptastic/src/stop.rs
@@ -1,4 +1,7 @@
-use std::{hash::{Hash, Hasher}, sync::Arc};
+use std::{
+ hash::{Hash, Hasher},
+ sync::Arc,
+};
use serde::{Deserialize, Serialize};
@@ -7,13 +10,13 @@ use serde::{Deserialize, Serialize};
pub enum PlatformLocationType {
FarSide,
MiddleBlockNearSide,
- Normal
+ Normal,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum StopType {
SinglePlatform(Arc),
- MultiPlatform(Vec>)
+ MultiPlatform(Vec>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -22,12 +25,12 @@ pub struct Platform {
pub name: String,
pub lat: f64,
pub lng: f64,
- pub platform_location: PlatformLocationType
+ pub platform_location: PlatformLocationType,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Stop {
- pub id: String,
+ pub id: String,
pub name: String,
pub platforms: StopType,
}
diff --git a/libseptastic/src/stop_schedule.rs b/libseptastic/src/stop_schedule.rs
index 44c11ab..7ff3e7e 100644
--- a/libseptastic/src/stop_schedule.rs
+++ b/libseptastic/src/stop_schedule.rs
@@ -1,7 +1,7 @@
use std::sync::Arc;
-use chrono::{Datelike, Days, TimeZone, Weekday};
-use serde::{Deserialize, Serialize};
+use chrono::{Datelike, Days, Weekday};
+use serde::{Deserialize, Serialize, Serializer};
use crate::{direction::Direction, route::Route, stop::Platform};
@@ -10,11 +10,11 @@ pub struct StopSchedule {
pub arrival_time: i64,
pub stop_sequence: i64,
pub stop: Arc,
- pub platform: Arc
+ pub platform: Arc,
}
-impl StopSchedule {
- pub fn get_arrival_time(&self, live_info: &LiveTrip) -> i64 {
+impl StopSchedule {
+ pub fn get_arrival_time(&self, live_info: &LiveTrip) -> i64 {
return self.arrival_time + (live_info.delay * 60.0 as f64) as i64;
}
}
@@ -22,34 +22,45 @@ impl StopSchedule {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trip {
pub service_id: String,
- pub route: Arc,
+ pub route: Arc,
pub trip_id: String,
pub direction: Direction,
pub tracking_data: TripTracking,
pub schedule: Vec,
- pub calendar_day: Arc
+ pub calendar_day: Arc,
}
-impl Trip {
+impl Trip {
pub fn is_active_on(&self, datetime: &chrono::NaiveDateTime) -> bool {
- if !self.calendar_day.is_calendar_active_for_date(&datetime.date()) {
+ if !self
+ .calendar_day
+ .is_calendar_active_for_date(&datetime.date())
+ {
return false;
}
- let time_trip_start = chrono::NaiveTime::from_num_seconds_from_midnight_opt(self.schedule.first().unwrap().arrival_time as u32 % (60*60*24), 0).unwrap();
+ let time_trip_start = chrono::NaiveTime::from_num_seconds_from_midnight_opt(
+ self.schedule.first().unwrap().arrival_time as u32 % (60 * 60 * 24),
+ 0,
+ )
+ .unwrap();
let mut dt_trip_start = chrono::NaiveDateTime::new(datetime.date(), time_trip_start);
-
- if self.schedule.first().unwrap().arrival_time > (60*60*24) {
+
+ if self.schedule.first().unwrap().arrival_time > (60 * 60 * 24) {
dt_trip_start = dt_trip_start.checked_add_days(Days::new(1)).unwrap();
}
-
- let time_trip_end = chrono::NaiveTime::from_num_seconds_from_midnight_opt(self.schedule.last().unwrap().arrival_time as u32 % (60*60*24), 0).unwrap();
+
+ let time_trip_end = chrono::NaiveTime::from_num_seconds_from_midnight_opt(
+ self.schedule.last().unwrap().arrival_time as u32 % (60 * 60 * 24),
+ 0,
+ )
+ .unwrap();
let mut dt_trip_end = chrono::NaiveDateTime::new(datetime.date(), time_trip_end);
- if self.schedule.last().unwrap().arrival_time > (60*60*24) {
+ if self.schedule.last().unwrap().arrival_time > (60 * 60 * 24) {
dt_trip_end = dt_trip_end.checked_add_days(Days::new(1)).unwrap();
}
-
+
return *datetime >= dt_trip_start && *datetime <= dt_trip_end;
}
}
@@ -58,7 +69,7 @@ impl Trip {
pub enum TripTracking {
Tracked(LiveTrip),
Untracked,
- Cancelled
+ Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -72,7 +83,7 @@ pub struct CalendarDay {
pub saturday: bool,
pub sunday: bool,
pub start_date: chrono::NaiveDate,
- pub end_date: chrono::NaiveDate
+ pub end_date: chrono::NaiveDate,
}
impl CalendarDay {
@@ -93,6 +104,88 @@ impl CalendarDay {
}
}
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum SeatAvailability {
+ Full = 4,
+ CrushedStandingRoomOnly = 3,
+ FewSeats = 2,
+ ManySeats = 1,
+ Empty = 0,
+}
+
+impl<'de> Deserialize<'de> for SeatAvailability {
+ fn deserialize(deserializer: D) -> Result
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let string = String::deserialize(deserializer)?;
+ return match SeatAvailability::from_string(&string) {
+ Some(x) => Ok(x),
+ None => Err(serde::de::Error::custom("")),
+ };
+ }
+}
+
+impl Serialize for SeatAvailability {
+ fn serialize(&self, serializer: S) -> Result
+ where
+ S: Serializer,
+ {
+ serializer.serialize_str(self.to_string().as_str())
+ }
+}
+
+impl SeatAvailability {
+ pub fn iter() -> Vec {
+ vec![
+ Self::Empty,
+ Self::ManySeats,
+ Self::FewSeats,
+ Self::CrushedStandingRoomOnly,
+ Self::Full,
+ ]
+ }
+
+ pub fn to_string(&self) -> String {
+ String::from(match &self {
+ Self::Full => "FULL",
+ Self::CrushedStandingRoomOnly => "CRUSHED_STANDING_ROOM_ONLY",
+ Self::FewSeats => "FEW_SEATS_AVAILABLE",
+ Self::ManySeats => "MANY_SEATS_AVAILABLE",
+ Self::Empty => "EMPTY",
+ })
+ }
+
+ pub fn to_human_string(&self) -> String {
+ String::from(match &self {
+ Self::Full => "Full",
+ Self::CrushedStandingRoomOnly => "Sardines",
+ Self::FewSeats => "Few seats",
+ Self::ManySeats => "Many seats",
+ Self::Empty => "Empty",
+ })
+ }
+
+ pub fn from_string(str: &String) -> Option {
+ match str.as_str() {
+ "FULL" => Some(Self::Full),
+ "CRUSHED_STANDING_ROOM_ONLY" => Some(Self::CrushedStandingRoomOnly),
+ "FEW_SEATS_AVAILABLE" => Some(Self::FewSeats),
+ "MANY_SEATS_AVAILABLE" => Some(Self::ManySeats),
+ "EMPTY" => Some(Self::Empty),
+ _ => None,
+ }
+ }
+
+ pub fn from_opt_string(opt_str: &Option) -> Option {
+ if let Some(str) = &opt_str {
+ Self::from_string(str)
+ } else {
+ None
+ }
+ }
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiveTrip {
pub delay: f64,
@@ -102,7 +195,7 @@ pub struct LiveTrip {
pub latitude: Option,
pub longitude: Option,
pub heading: Option,
- pub seat_availability: Option,
+ pub seat_availability: Option,
pub trip_id: String,
pub route_id: String,
}
diff --git a/shell.nix b/shell.nix
index ea8ae70..1bdf27b 100644
--- a/shell.nix
+++ b/shell.nix
@@ -1,7 +1,14 @@
with import {};
stdenv.mkDerivation {
name = "env";
- nativeBuildInputs = [ pkg-config postgresql_14 ];
+ nativeBuildInputs = [
+ pkg-config
+ postgresql_14
+ rustfmt
+ cargo
+ djlint
+ ];
+
buildInputs = [
cryptsetup
protobuf
diff --git a/api/.gitignore b/web/.gitignore
similarity index 65%
rename from api/.gitignore
rename to web/.gitignore
index 14ee500..08b70ca 100644
--- a/api/.gitignore
+++ b/web/.gitignore
@@ -1,2 +1,3 @@
target/
.env
+.sqlx/
diff --git a/api/Cargo.lock b/web/Cargo.lock
similarity index 99%
rename from api/Cargo.lock
rename to web/Cargo.lock
index 3b088e5..31daf09 100644
--- a/api/Cargo.lock
+++ b/web/Cargo.lock
@@ -387,11 +387,11 @@ dependencies = [
[[package]]
name = "askama"
-version = "0.14.0"
+version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
+checksum = "08e1676b346cadfec169374f949d7490fd80a24193d37d2afce0c047cf695e57"
dependencies = [
- "askama_derive",
+ "askama_macros",
"itoa",
"percent-encoding",
"serde",
@@ -400,9 +400,9 @@ dependencies = [
[[package]]
name = "askama_derive"
-version = "0.14.0"
+version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
+checksum = "7661ff56517787343f376f75db037426facd7c8d3049cef8911f1e75016f3a37"
dependencies = [
"askama_parser",
"basic-toml",
@@ -416,14 +416,24 @@ dependencies = [
]
[[package]]
-name = "askama_parser"
-version = "0.14.0"
+name = "askama_macros"
+version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
+checksum = "713ee4dbfd1eb719c2dab859465b01fa1d21cb566684614a713a6b7a99a4e47b"
dependencies = [
- "memchr",
+ "askama_derive",
+]
+
+[[package]]
+name = "askama_parser"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d62d674238a526418b30c0def480d5beadb9d8964e7f38d635b03bf639c704c"
+dependencies = [
+ "rustc-hash",
"serde",
"serde_derive",
+ "unicode-ident",
"winnow",
]
@@ -2769,7 +2779,7 @@ dependencies = [
]
[[package]]
-name = "septastic_api"
+name = "septastic_web"
version = "0.1.0"
dependencies = [
"actix-cors",
@@ -2792,6 +2802,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
+ "serde_qs",
"serde_yaml",
"sqlx",
"sqlx-cli",
@@ -2831,6 +2842,20 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serde_qs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac22439301a0b6f45a037681518e3169e8db1db76080e2e9600a08d1027df037"
+dependencies = [
+ "actix-web",
+ "futures",
+ "itoa",
+ "percent-encoding",
+ "ryu",
+ "serde",
+]
+
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
diff --git a/api/Cargo.toml b/web/Cargo.toml
similarity index 87%
rename from api/Cargo.toml
rename to web/Cargo.toml
index 141b61b..3d6363d 100644
--- a/api/Cargo.toml
+++ b/web/Cargo.toml
@@ -1,5 +1,5 @@
[package]
-name = "septastic_api"
+name = "septastic_web"
version = "0.1.0"
edition = "2024"
@@ -12,7 +12,7 @@ log = "0.4.27"
serde_json = "1.0.140"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres"] }
libseptastic = { path = "../libseptastic/" }
-askama = "0.14.0"
+askama = "0.15.4"
actix-files = "0.6.6"
serde = "1.0.219"
chrono = "0.4.41"
@@ -29,3 +29,4 @@ gtfs-realtime = "0.2.0"
prost = "0.14.1"
futures = "0.3.31"
tokio = "1.48.0"
+serde_qs = { version = "1.0.0", features = ["actix4"] }
diff --git a/api/assets/style.css b/web/assets/style.css
similarity index 97%
rename from api/assets/style.css
rename to web/assets/style.css
index cb86bf6..3210184 100644
--- a/api/assets/style.css
+++ b/web/assets/style.css
@@ -151,8 +151,6 @@ img {
color: #000000;
width: max-content;
height: max-content;
- aspect-ratio: 3/2;
- line-height: 1;
}
.tscroll {
width: 100%;
@@ -174,3 +172,9 @@ img {
details summary > * {
display: inline;
}
+
+details {
+ margin-top: 5px;
+ margin-bottom: 5px;
+ padding: 5px;
+}
diff --git a/api/config.yaml b/web/config.yaml
similarity index 82%
rename from api/config.yaml
rename to web/config.yaml
index e24345b..3f82198 100644
--- a/api/config.yaml
+++ b/web/config.yaml
@@ -5,9 +5,13 @@ gtfs_zips:
- uri: "https://www3.septa.org/developer/gtfs_public.zip"
prefix: "SEPTABUS"
subzip: "google_bus.zip"
-# - uri: "https://www.njtransit.com/rail_data.zip"
-# - uri: "https://www.njtransit.com/bus_data.zip"
annotations:
+ parent_stop_blacklist:
+ - 'SEPTABUS_32993'
+ - 'SEPTABUS_31032'
+ stop_rename_rules:
+ - pattern: '(.*) Transportation Center'
+ replace: '\1 Transit Center'
multiplatform_stops:
- id: 'WTC'
name: 'Wissahickon Transit Center'
@@ -20,6 +24,7 @@ annotations:
- 'SEPTABUS_32990'
- 'SEPTABUS_32992'
- 'SEPTABUS_32993'
+ - 'SEPTARAIL_90220'
- id: 'STC'
name: "Susquehanna Transit Center"
platform_station_ids:
@@ -34,5 +39,3 @@ annotations:
- 'SEPTABUS_2687'
- 'SEPTABUS_18451'
- 'SEPTABUS_17170'
- synthetic_routes:
- - id: 'NYC'
diff --git a/web/src/controllers/index.rs b/web/src/controllers/index.rs
new file mode 100644
index 0000000..6a2cd49
--- /dev/null
+++ b/web/src/controllers/index.rs
@@ -0,0 +1,10 @@
+use crate::{
+ session_middleware::{SessionResponder, SessionResponse},
+ templates::IndexTemplate,
+};
+use actix_web::{Responder, get};
+
+#[get("/")]
+async fn get_index_html(resp: SessionResponse) -> impl Responder {
+ resp.respond("Home", "SEPTASTIC Home Page", IndexTemplate {})
+}
diff --git a/api/src/controllers/mod.rs b/web/src/controllers/mod.rs
similarity index 65%
rename from api/src/controllers/mod.rs
rename to web/src/controllers/mod.rs
index a83d8c0..5b25864 100644
--- a/api/src/controllers/mod.rs
+++ b/web/src/controllers/mod.rs
@@ -1,2 +1,3 @@
+pub mod index;
pub mod route;
pub mod stop;
diff --git a/web/src/controllers/route.rs b/web/src/controllers/route.rs
new file mode 100644
index 0000000..3e0f188
--- /dev/null
+++ b/web/src/controllers/route.rs
@@ -0,0 +1,137 @@
+use crate::{
+ AppState,
+ session_middleware::{SessionResponder, SessionResponse},
+};
+use actix_web::{
+ HttpResponse, Responder, get,
+ web::{self, Data},
+};
+use libseptastic::{route::RouteType, stop_schedule::Trip};
+use serde::{Deserialize, Serialize};
+use std::{collections::HashSet, sync::Arc};
+
+#[derive(Debug, Deserialize, Clone)]
+struct RouteQueryParams {
+ #[serde(default)] // Optional: handle missing parameters with a default value
+ stops: Option,
+}
+
+#[derive(Serialize, Deserialize)]
+struct RouteResponse {
+ pub route: libseptastic::route::Route,
+ pub directions: Vec,
+ pub schedule: Vec,
+}
+
+async fn get_route_info(
+ route_id: String,
+ state: Data>,
+) -> ::anyhow::Result {
+ let route = state.gtfs_service.get_route(route_id.clone())?;
+ let mut trips = state.gtfs_service.get_schedule(route_id)?;
+
+ let mut seen = HashSet::new();
+ let directions: Vec<_> = trips
+ .iter()
+ .map(|x| x.direction.clone())
+ .filter(|dir| seen.insert(dir.direction.to_string()))
+ .collect();
+
+ state.trip_tracking_service.annotate_trips(&mut trips).await;
+
+ Ok(RouteResponse {
+ route,
+ directions,
+ schedule: trips,
+ })
+}
+
+#[get("/routes")]
+async fn get_routes_html(state: Data>, resp: SessionResponse) -> impl Responder {
+ let all_routes: Vec = state.gtfs_service.get_routes();
+ let rr_routes = all_routes
+ .clone()
+ .into_iter()
+ .filter(|x| x.route_type == RouteType::RegionalRail)
+ .collect();
+ let subway_routes = all_routes
+ .clone()
+ .into_iter()
+ .filter(|x| x.route_type == RouteType::SubwayElevated)
+ .collect();
+ let trolley_routes = all_routes
+ .clone()
+ .into_iter()
+ .filter(|x| x.route_type == RouteType::Trolley)
+ .collect();
+ let bus_routes = all_routes
+ .into_iter()
+ .filter(|x| x.route_type == RouteType::TracklessTrolley || x.route_type == RouteType::Bus)
+ .collect();
+
+ resp.respond(
+ "Routes",
+ "All routes",
+ crate::templates::RoutesTemplate {
+ rr_routes,
+ subway_routes,
+ trolley_routes,
+ bus_routes,
+ },
+ )
+}
+
+#[get("/routes.json")]
+async fn get_routes_json(state: Data>) -> impl Responder {
+ let all_routes: Vec = state.gtfs_service.get_routes();
+ HttpResponse::Ok().json(all_routes)
+}
+
+#[get("/route/{route_id}")]
+async fn get_route_html(
+ state: Data>,
+ info: web::Query,
+ path: web::Path,
+ resp: SessionResponse,
+) -> impl Responder {
+ let mut filters: Option> = None;
+ if let Some(stops_v) = info.stops.clone() {
+ let mut items = Vec::new();
+
+ for sid in stops_v.split(",") {
+ items.push(String::from(sid));
+ }
+ filters = Some(items);
+ }
+
+ let route_id = path;
+ let route_info_r = get_route_info(route_id.clone(), state.clone()).await;
+
+ if let Ok(route_info) = route_info_r {
+ let timetables =
+ crate::templates::build_timetables(route_info.directions, route_info.schedule);
+
+ resp.respond(
+ format!("Schedules for {}", route_id.clone()).as_str(),
+ format!("Schedule information for {}", route_id.clone()).as_str(),
+ crate::templates::RouteTemplate {
+ route: route_info.route,
+ timetables,
+ filter_stops: filters.clone(),
+ },
+ )
+ } else {
+ HttpResponse::InternalServerError().body("")
+ }
+}
+
+#[get("/route/{route_id}.json")]
+async fn get_route_json(state: Data>, path: web::Path) -> impl Responder {
+ let route_id = path.into_inner();
+ let route_info_r = get_route_info(route_id, state).await;
+ if let Ok(route_info) = route_info_r {
+ HttpResponse::Ok().json(route_info)
+ } else {
+ HttpResponse::InternalServerError().body("Error")
+ }
+}
diff --git a/web/src/controllers/stop.rs b/web/src/controllers/stop.rs
new file mode 100644
index 0000000..5321509
--- /dev/null
+++ b/web/src/controllers/stop.rs
@@ -0,0 +1,313 @@
+use crate::{
+ AppState,
+ session_middleware::{SessionResponder, SessionResponse},
+ templates::TripPerspective,
+};
+use actix_web::{
+ HttpResponse, Responder, get, post, web::{self, Data}
+};
+use askama::Template;
+use chrono::{TimeDelta, Timelike};
+use chrono_tz::America::New_York;
+use libseptastic::{stop::Stop, stop_schedule::{SeatAvailability, Trip, TripTracking}};
+use serde::{Deserialize, Serialize};
+use serde_qs::actix::QsQuery;
+use std::{
+ collections::{BTreeSet, HashSet},
+ sync::Arc,
+};
+
+async fn get_trip_perspective_for_stop(
+ state: &Data>,
+ stop: &libseptastic::stop::Stop,
+ filter: &StopFilter,
+) -> Vec {
+ let routes: Vec = state
+ .gtfs_service
+ .get_routes_at_stop(&stop.id)
+ .iter()
+ .filter_map(|route| match state.gtfs_service.get_route(route.clone()) {
+ Ok(route) => Some(route),
+ Err(_) => None,
+ })
+ .collect();
+
+ let route_ids: HashSet = routes.iter().map(|route| route.id.clone()).collect();
+
+ let mut trips = state
+ .gtfs_service
+ .get_all_trips()
+ .iter()
+ .filter_map(|trip| {
+ if route_ids.contains(trip.0) {
+ Some(trip.1.clone())
+ } else {
+ None
+ }
+ })
+ .flatten()
+ .collect();
+
+ state.trip_tracking_service.annotate_trips(&mut trips).await;
+
+ let now_utc = chrono::Utc::now();
+ let now = now_utc.with_timezone(&New_York);
+ let naive_time = now.time();
+ let cur_time = i64::from(naive_time.num_seconds_from_midnight());
+
+ let mut filtered_trips: Vec = trips
+ .iter()
+ .filter_map(|trip| {
+ // poor midnight handling? -- going to offset by 4 hours, assume next 'schedule day'
+ // starts at 4a. Still may miss some trips. Oh well!
+ if !trip.calendar_day.is_calendar_active_for_date(
+ &now.naive_local()
+ .checked_add_signed(TimeDelta::hours(-4))?
+ .date(),
+ ) {
+ return None;
+ }
+
+ let stop_sched: Vec<_> = trip
+ .schedule
+ .iter()
+ .filter(|stop_schedule| {
+ if stop_schedule.stop.id != stop.id {
+ return false;
+ }
+
+ match &trip.tracking_data {
+ libseptastic::stop_schedule::TripTracking::Tracked(live) => {
+ let actual_arrival_time = stop_schedule.get_arrival_time(&live);
+ return (actual_arrival_time - cur_time) > -(1 * 60)
+ && (actual_arrival_time - cur_time) < (60 * 60);
+ }
+ libseptastic::stop_schedule::TripTracking::Untracked => {
+ return (stop_schedule.arrival_time - cur_time) > -(3 * 60)
+ && (stop_schedule.arrival_time - cur_time) < (60 * 60);
+ }
+ libseptastic::stop_schedule::TripTracking::Cancelled => {
+ return false;
+ }
+ }
+ })
+ .filter_map(|ss| Some(ss.clone()))
+ .collect();
+
+ if stop_sched.len() > 0 && filter.trip_matches(trip) {
+ Some(TripPerspective {
+ perspective_stop: stop_sched.first().unwrap().clone(),
+ trip: trip.clone(),
+ })
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ filtered_trips.sort_by_key(|f| match &f.trip.tracking_data {
+ TripTracking::Tracked(live) => f.perspective_stop.get_arrival_time(&live),
+ _ => f.perspective_stop.arrival_time,
+ });
+
+ filtered_trips
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct StopFilter {
+ pub routes: Option>,
+ pub live_tracked: Option,
+ pub scheduled: Option,
+ pub crowding: Option>,
+ pub unknown_crowding: Option,
+}
+
+impl StopFilter {
+ pub fn trip_matches(&self, trip: &Trip) -> bool {
+ let unspecified = self.live_tracked == None && self.scheduled == None;
+ let unknown_crowding = self.unknown_crowding.unwrap_or(true);
+
+ if (Some(false) == self.scheduled || (!unspecified && self.scheduled == None))
+ && match trip.tracking_data {
+ TripTracking::Untracked => true,
+ _ => false,
+ }
+ {
+ return false;
+ }
+
+ if (Some(false) == self.live_tracked || (!unspecified && self.live_tracked == None))
+ && match trip.tracking_data {
+ TripTracking::Tracked(_) => true,
+ _ => false,
+ }
+ {
+ return false;
+ }
+
+ if let Some(routes) = &self.routes {
+ let route_str = format!("{},{}", trip.route.id, trip.direction.direction);
+ if !routes.contains(&route_str) {
+ return false;
+ }
+ }
+
+ if let Some(crowding) = &self.crowding {
+ if let TripTracking::Tracked(live_trip) = &trip.tracking_data {
+ if let Some(seat_availability) = &live_trip.seat_availability {
+ if !crowding.contains(seat_availability) {
+ return false;
+ }
+ } else {
+ return unknown_crowding;
+ }
+ } else {
+ return unknown_crowding;
+ }
+ }
+
+ return true;
+ }
+}
+
+#[get("/stops")]
+async fn get_stops_html(state: Data>, resp: SessionResponse) -> impl Responder {
+ let stops = state
+ .gtfs_service
+ .get_all_stops()
+ .iter()
+ .filter_map(|f| {
+ if f.1.id.contains("ANNOTATED") {
+ Some(libseptastic::stop::Stop::clone(f.1))
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ resp.respond(
+ "Stops",
+ "Stops",
+ crate::templates::StopsTemplate { tc_stops: stops },
+ )
+}
+
+#[derive(Deserialize)]
+struct StringSearch {
+ search: String
+}
+
+#[post("/stops/search")]
+async fn search_stops_html(state: Data>, params: web::Form) -> impl Responder {
+ let results_limit = 25;
+ let search_str = params.search.to_lowercase();
+ let stops: Vec = state
+ .gtfs_service
+ .get_all_stops()
+ .iter()
+ .filter_map(|f| {
+ // Non-ideal
+ if f.1.name.to_lowercase().contains(&search_str) ||
+ f.1.id.to_lowercase().contains(&search_str) {
+ Some(libseptastic::stop::Stop::clone(f.1))
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ HttpResponse::Ok().body(crate::templates::StopSearchResults {
+ results: if stops.len() > results_limit {
+ stops[..results_limit].to_vec()
+ } else {
+ stops
+ }
+ }.render().unwrap())
+}
+
+#[get("/stop/{stop_id}/table")]
+async fn get_stop_table_html(
+ state: Data>,
+ path: web::Path,
+ query: QsQuery,
+) -> impl Responder {
+ let stop_id = path;
+
+ if let Some(stop) = state.gtfs_service.get_stop_by_id(&stop_id) {
+ let filtered_trips = get_trip_perspective_for_stop(&state, &stop, &query).await;
+
+ let now_utc = chrono::Utc::now();
+ let now = now_utc.with_timezone(&New_York);
+ let naive_time = now.time();
+ let cur_time = i64::from(naive_time.num_seconds_from_midnight());
+ let query_str = serde_qs::Config::new()
+ .array_format(serde_qs::ArrayFormat::Unindexed)
+ .serialize_string(&query.0.clone())
+ .unwrap();
+
+ HttpResponse::Ok()
+ .append_header((
+ "HX-Replace-Url",
+ format!("/stop/{}?{}", stop_id, &query_str).as_str(),
+ ))
+ .body(
+ crate::templates::StopTableTemplate {
+ trips: filtered_trips,
+ current_time: cur_time,
+ query_str,
+ stop_id: stop_id.to_string(),
+ }
+ .render()
+ .unwrap(),
+ )
+ } else {
+ HttpResponse::InternalServerError().body("Error")
+ }
+}
+
+#[get("/stop/{stop_id}")]
+async fn get_stop_html(
+ state: Data>,
+ path: web::Path,
+ query: QsQuery,
+ resp: SessionResponse,
+) -> impl Responder {
+ let stop_id = path;
+
+ if let Some(stop) = state.gtfs_service.get_stop_by_id(&stop_id) {
+ let routes: Vec = state
+ .gtfs_service
+ .get_routes_at_stop(&stop.id)
+ .iter()
+ .filter_map(|route| match state.gtfs_service.get_route(route.clone()) {
+ Ok(route) => Some(route),
+ Err(_) => None,
+ })
+ .collect();
+
+ let filtered_trips = get_trip_perspective_for_stop(&state, &stop, &query).await;
+
+ let now_utc = chrono::Utc::now();
+ let now = now_utc.with_timezone(&New_York);
+ let naive_time = now.time();
+ let cur_time = i64::from(naive_time.num_seconds_from_midnight());
+
+ resp.respond(
+ stop.name.as_str(),
+ "Stop information",
+ crate::templates::StopTemplate {
+ stop: stop.clone(),
+ routes: BTreeSet::from_iter(routes.into_iter()),
+ trips: filtered_trips,
+ current_time: cur_time,
+ filters: Some(query.0.clone()),
+ query_str: serde_qs::Config::new()
+ .array_format(serde_qs::ArrayFormat::Unindexed)
+ .serialize_string(&query.0)
+ .unwrap(),
+ },
+ )
+ } else {
+ HttpResponse::InternalServerError().body("Error")
+ }
+}
diff --git a/api/src/database.rs b/web/src/database.rs
similarity index 100%
rename from api/src/database.rs
rename to web/src/database.rs
diff --git a/web/src/main.rs b/web/src/main.rs
new file mode 100644
index 0000000..fe13eb5
--- /dev/null
+++ b/web/src/main.rs
@@ -0,0 +1,67 @@
+use actix_web::{App, HttpServer, web::Data};
+use dotenv::dotenv;
+use env_logger::Env;
+use log::*;
+use services::gtfs_pull;
+use std::{fs::File, io::Read, sync::Arc};
+
+mod controllers;
+mod services;
+mod session_middleware;
+mod templates;
+
+pub struct AppState {
+ gtfs_service: services::gtfs_pull::GtfsPullService,
+ trip_tracking_service: services::trip_tracking::TripTrackingService,
+}
+
+#[tokio::main]
+async fn main() -> ::anyhow::Result<()> {
+ env_logger::init_from_env(Env::default().default_filter_or("septastic_web=info"));
+ dotenv().ok();
+
+ let version: &str = option_env!("CARGO_PKG_VERSION").expect("Expected package version");
+ info!(
+ "Starting the SEPTASTIC Server v{} (commit: {})",
+ version, "NONE"
+ );
+
+ let mut file = File::open("./config.yaml")?;
+ let mut file_contents = String::new();
+ file.read_to_string(&mut file_contents)?;
+
+ let config_file = serde_yaml::from_str::(file_contents.as_str())?;
+
+ let tt_service = services::trip_tracking::TripTrackingService::new().await;
+ tt_service.start();
+
+ let svc = gtfs_pull::GtfsPullService::new(config_file);
+ svc.start();
+ svc.wait_for_ready();
+
+ let state = Arc::new(AppState {
+ gtfs_service: svc,
+ trip_tracking_service: tt_service,
+ });
+
+ HttpServer::new(move || {
+ App::new()
+ .wrap(actix_cors::Cors::permissive())
+ .app_data(Data::new(state.clone()))
+ .service(controllers::route::get_route_html)
+ .service(controllers::route::get_route_json)
+ .service(controllers::route::get_routes_html)
+ .service(controllers::route::get_routes_json)
+ .service(controllers::stop::get_stops_html)
+ .service(controllers::stop::get_stop_html)
+ .service(controllers::stop::search_stops_html)
+ .service(controllers::stop::get_stop_table_html)
+ .service(controllers::index::get_index_html)
+ .service(actix_files::Files::new("/assets", "./assets"))
+ })
+ .bind(("0.0.0.0", 8080))?
+ .run()
+ .await?;
+
+ Ok(())
+}
diff --git a/api/src/routing.rs b/web/src/routing.rs
similarity index 100%
rename from api/src/routing.rs
rename to web/src/routing.rs
diff --git a/web/src/services/gtfs_pull.rs b/web/src/services/gtfs_pull.rs
new file mode 100644
index 0000000..b6fe525
--- /dev/null
+++ b/web/src/services/gtfs_pull.rs
@@ -0,0 +1,629 @@
+use anyhow::anyhow;
+use libseptastic::{stop::Platform, stop_schedule::CalendarDay};
+use log::{error, info, warn};
+use serde::{Deserialize, Serialize};
+use std::{
+ cmp::Ordering,
+ collections::{HashMap, HashSet, hash_map::Entry},
+ env,
+ io::Cursor,
+ path::PathBuf,
+ sync::{Arc, Mutex, MutexGuard},
+ thread,
+ time::Duration,
+};
+use zip::ZipArchive;
+
+macro_rules! make_global_id {
+ ($prefix: expr, $id: expr) => {
+ format!("{}_{}", $prefix, $id)
+ };
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
+pub struct GtfsSource {
+ pub uri: String,
+ pub subzip: Option,
+ pub prefix: String,
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
+pub struct MultiplatformStopConfig {
+ pub id: String,
+ pub name: String,
+ pub platform_station_ids: Vec,
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
+pub struct StopRenameRule {
+ pub pattern: String,
+ pub replace: String,
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
+pub struct Annotations {
+ pub multiplatform_stops: Vec,
+ pub parent_stop_blacklist: Vec,
+ pub stop_rename_rules: Vec
+
+}
+
+#[derive(Serialize, Deserialize, PartialEq, Debug)]
+pub struct Config {
+ pub gtfs_zips: Vec,
+ pub annotations: Annotations,
+}
+
+#[derive(Clone)]
+pub struct GtfsFile {
+ pub source: GtfsSource,
+}
+
+pub struct TransitData {
+ pub routes: HashMap>,
+ pub agencies: HashMap,
+ pub trips: HashMap>,
+ pub stops: HashMap>,
+ pub platforms: HashMap>,
+ pub calendar_days: HashMap>,
+ pub directions: HashMap>>,
+
+ // extended lookup methods
+ pub route_id_by_stops: HashMap>,
+ pub stops_by_route_id: HashMap>,
+ pub stops_by_platform_id: HashMap>,
+}
+
+pub struct GtfsPullServiceState {
+ pub gtfs_files: Vec,
+ pub tmp_dir: PathBuf,
+ pub ready: bool,
+ pub annotations: Annotations,
+ pub transit_data: TransitData,
+}
+
+pub struct GtfsPullService {
+ state: Arc>,
+}
+
+impl TransitData {
+ pub fn new() -> Self {
+ return TransitData {
+ routes: HashMap::new(),
+ agencies: HashMap::new(),
+ trips: HashMap::new(),
+ stops: HashMap::new(),
+ platforms: HashMap::new(),
+ route_id_by_stops: HashMap::new(),
+ stops_by_route_id: HashMap::new(),
+ stops_by_platform_id: HashMap::new(),
+ calendar_days: HashMap::new(),
+ directions: HashMap::new(),
+ };
+ }
+}
+
+impl GtfsPullService {
+ const UPDATE_SECONDS: u64 = 3600 * 24;
+ const READYSTATE_CHECK_MILLISECONDS: u64 = 500;
+
+ pub fn new(config: Config) -> Self {
+ Self {
+ state: Arc::new(Mutex::new(GtfsPullServiceState {
+ gtfs_files: config
+ .gtfs_zips
+ .iter()
+ .map(|f| GtfsFile { source: f.clone() })
+ .collect(),
+ tmp_dir: env::temp_dir(),
+ annotations: config.annotations.clone(),
+ ready: false,
+ transit_data: TransitData::new(),
+ })),
+ }
+ }
+
+ pub fn wait_for_ready(&self) {
+ while !(self.state.lock().unwrap()).ready {
+ thread::sleep(Duration::from_millis(Self::READYSTATE_CHECK_MILLISECONDS));
+ }
+ }
+
+ pub fn start(&self) {
+ let cloned_state = Arc::clone(&self.state);
+ thread::spawn(move || {
+ loop {
+ let recloned_state = Arc::clone(&cloned_state);
+ let res = Self::update_gtfs_data(recloned_state);
+
+ match res {
+ Err(err) => {
+ error!("{}", err);
+ }
+ _ => {}
+ }
+
+ thread::sleep(Duration::from_secs(Self::UPDATE_SECONDS));
+ }
+ });
+ }
+
+ pub fn get_routes(&self) -> Vec {
+ let l_state = self.state.lock().unwrap();
+ l_state
+ .transit_data
+ .routes
+ .iter()
+ .map(|r| libseptastic::route::Route::clone(r.1))
+ .collect()
+ }
+
+ pub fn get_route(&self, route_id: String) -> anyhow::Result {
+ let l_state = self.state.lock().unwrap();
+ if let Some(route) = l_state.transit_data.routes.get(&route_id) {
+ Ok(libseptastic::route::Route::clone(route))
+ } else {
+ Err(anyhow!(""))
+ }
+ }
+
+ pub fn get_all_routes(&self) -> HashMap {
+ let l_state = self.state.lock().unwrap();
+ l_state
+ .transit_data
+ .routes
+ .iter()
+ .map(|r| (r.0.clone(), libseptastic::route::Route::clone(r.1)))
+ .collect()
+ }
+
+ pub fn get_all_stops(&self) -> HashMap> {
+ let l_state = self.state.lock().unwrap();
+ l_state.transit_data.stops.clone()
+ }
+
+ pub fn get_all_trips(&self) -> HashMap> {
+ let l_state = self.state.lock().unwrap();
+ l_state.transit_data.trips.clone()
+ }
+
+ pub fn get_routes_at_stop(&self, id: &String) -> HashSet {
+ let l_state = self.state.lock().unwrap();
+ l_state
+ .transit_data
+ .route_id_by_stops
+ .get(id)
+ .unwrap_or(&HashSet::new())
+ .clone()
+ }
+
+ pub fn get_stops_by_route(&self, id: &String) -> HashSet {
+ let l_state = self.state.lock().unwrap();
+ l_state
+ .transit_data
+ .stops_by_route_id
+ .get(id)
+ .unwrap_or(&HashSet::new())
+ .clone()
+ }
+
+ pub fn get_stop_by_id(&self, id: &String) -> Option {
+ let l_state = self.state.lock().unwrap();
+ match l_state.transit_data.stops.get(id) {
+ Some(stop) => Some(libseptastic::stop::Stop::clone(stop)),
+ None => None,
+ }
+ }
+
+ pub fn get_schedule(
+ &self,
+ route_id: String,
+ ) -> anyhow::Result> {
+ let l_state = self.state.lock().unwrap();
+ if let Some(trips) = l_state.transit_data.trips.get(&route_id) {
+ Ok(trips.clone())
+ } else {
+ Err(anyhow!(""))
+ }
+ }
+
+ fn postprocess_stops(state: &mut MutexGuard<'_, GtfsPullServiceState>) -> anyhow::Result<()> {
+ for annotated_stop in state.annotations.multiplatform_stops.clone() {
+ let global_id = make_global_id!("ANNOTATED", annotated_stop.id.clone());
+ let stop = Arc::new(libseptastic::stop::Stop {
+ id: global_id.clone(),
+ name: annotated_stop.name.clone(),
+ platforms: libseptastic::stop::StopType::MultiPlatform(
+ annotated_stop
+ .platform_station_ids
+ .iter()
+ .map(|platform_id| {
+ info!(
+ "Folding {} stop into stop {} as platform",
+ platform_id.clone(),
+ annotated_stop.id.clone()
+ );
+ let platform = match state
+ .transit_data
+ .stops
+ .remove(platform_id)
+ .unwrap()
+ .platforms
+ .clone()
+ {
+ libseptastic::stop::StopType::SinglePlatform(plat) => Ok(plat),
+ _ => Err(anyhow!("")),
+ }
+ .unwrap();
+
+ state
+ .transit_data
+ .stops_by_platform_id
+ .remove(&platform.id)
+ .unwrap();
+
+ platform
+ })
+ .collect(),
+ ),
+ });
+
+ state
+ .transit_data
+ .stops
+ .insert(global_id.clone(), stop.clone());
+ match &stop.platforms {
+ libseptastic::stop::StopType::MultiPlatform(platforms) => {
+ for platform in platforms {
+ state
+ .transit_data
+ .stops_by_platform_id
+ .insert(platform.id.clone(), stop.clone());
+ }
+ Ok(())
+ }
+ _ => Err(anyhow!("")),
+ }?
+ }
+
+ Ok(())
+ }
+
+ fn populate_stops(
+ state: &mut MutexGuard<'_, GtfsPullServiceState>,
+ prefix: &String,
+ gtfs: >fs_structures::Gtfs,
+ ) -> anyhow::Result<()> {
+ let mut map: HashMap>= HashMap::new();
+ for stop in >fs.stops {
+ let global_id = make_global_id!(prefix, stop.1.id.clone());
+ let platform = Arc::new(Platform {
+ id: global_id.clone(),
+ name: stop.1.name.clone().unwrap(),
+ lat: stop.1.latitude.unwrap(),
+ lng: stop.1.longitude.unwrap(),
+ platform_location: libseptastic::stop::PlatformLocationType::Normal,
+ });
+
+ if let Some(parent) = &stop.1.parent_station {
+ let parent_global_id = make_global_id!(prefix, parent);
+ if !state.annotations.parent_stop_blacklist.contains(&parent_global_id) {
+ map.entry(parent_global_id)
+ .or_insert(vec![]).push(global_id.clone());
+ }
+ }
+
+ let stop = Arc::new(libseptastic::stop::Stop {
+ id: global_id.clone(),
+ name: stop.1.name.clone().unwrap(),
+ platforms: libseptastic::stop::StopType::SinglePlatform(platform.clone()),
+ });
+
+
+ state
+ .transit_data
+ .stops
+ .insert(global_id.clone(), stop.clone());
+ state
+ .transit_data
+ .platforms
+ .insert(global_id.clone(), platform.clone());
+ state
+ .transit_data
+ .stops_by_platform_id
+ .insert(global_id.clone(), stop.clone());
+ }
+
+ for pair in &map {
+ let parent_stop = state.transit_data.stops.get(pair.0).unwrap().clone();
+ //let child_stop: Vec = pair.1.iter().map(|stop_id| {
+ // state.transit_data.stops.get(stop_id).unwrap().clone()
+ //}).collect();
+ state.annotations.multiplatform_stops.push(
+ MultiplatformStopConfig { id: parent_stop.id.clone(), name: parent_stop.name.clone(), platform_station_ids: pair.1.clone() }
+ );
+ }
+
+ Ok(())
+ }
+
+ fn populate_routes(
+ state: &mut MutexGuard<'_, GtfsPullServiceState>,
+ prefix: &String,
+ gtfs: >fs_structures::Gtfs,
+ ) -> anyhow::Result<()> {
+ for route in >fs.routes {
+ let global_rt_id = make_global_id!(prefix, route.1.id);
+
+ let rt_name = match route.1.long_name.clone() {
+ Some(x) => x,
+ _ => String::from("Unknown"),
+ };
+
+ let dirs = match state.transit_data.directions.get(&global_rt_id) {
+ Some(x) => x
+ .iter()
+ .map(|f| libseptastic::direction::Direction::clone(f))
+ .collect(),
+ None => {
+ warn!("Excluding {} because it has no directions", global_rt_id);
+ continue;
+ }
+ };
+
+ state.transit_data.routes.insert(
+ global_rt_id.clone(),
+ Arc::new(libseptastic::route::Route {
+ name: rt_name,
+ directions: dirs,
+ short_name: match route.1.short_name.clone() {
+ Some(x) => x,
+ _ => String::from("unknown"),
+ },
+ color_hex: match route.1.color {
+ Some(x) => x.to_string(),
+ _ => String::from("unknown"),
+ },
+ id: global_rt_id,
+ route_type: match route.1.route_type {
+ gtfs_structures::RouteType::Bus => libseptastic::route::RouteType::Bus,
+ gtfs_structures::RouteType::Rail => {
+ libseptastic::route::RouteType::RegionalRail
+ }
+ gtfs_structures::RouteType::Subway => {
+ libseptastic::route::RouteType::SubwayElevated
+ }
+ gtfs_structures::RouteType::Tramway => {
+ libseptastic::route::RouteType::Trolley
+ }
+ _ => libseptastic::route::RouteType::TracklessTrolley,
+ },
+ }),
+ );
+ }
+
+ Ok(())
+ }
+
+ fn populate_directions(
+ state: &mut MutexGuard<'_, GtfsPullServiceState>,
+ prefix: &String,
+ gtfs: >fs_structures::Gtfs,
+ ) -> anyhow::Result<()> {
+ for trip in >fs.trips {
+ let global_rt_id = make_global_id!(prefix, trip.1.route_id);
+
+ let dir = libseptastic::direction::Direction {
+ direction: match trip.1.direction_id.unwrap() {
+ gtfs_structures::DirectionType::Outbound => {
+ libseptastic::direction::CardinalDirection::Outbound
+ }
+ gtfs_structures::DirectionType::Inbound => {
+ libseptastic::direction::CardinalDirection::Inbound
+ }
+ },
+ direction_destination: trip.1.trip_headsign.clone().unwrap(),
+ };
+
+ match state.transit_data.directions.entry(global_rt_id) {
+ Entry::Vacant(e) => {
+ e.insert(vec![Arc::new(dir)]);
+ }
+ Entry::Occupied(mut e) => {
+ if e.get()
+ .iter()
+ .filter(|x| x.direction == dir.direction)
+ .count()
+ == 0
+ {
+ e.get_mut().push(Arc::new(dir));
+ }
+ }
+ }
+ }
+
+ for dir in &mut state.transit_data.directions {
+ dir.1.sort_by(|x, y| {
+ if x.direction > y.direction {
+ Ordering::Greater
+ } else {
+ Ordering::Less
+ }
+ });
+ }
+
+ Ok(())
+ }
+
+ fn populate_trips(
+ state: &mut MutexGuard<'_, GtfsPullServiceState>,
+ prefix: &String,
+ gtfs: >fs_structures::Gtfs,
+ ) -> anyhow::Result<()> {
+ for trip in >fs.trips {
+ let global_rt_id = make_global_id!(prefix, trip.1.route_id);
+ let sched = trip
+ .1
+ .stop_times
+ .iter()
+ .map(|s| {
+ let global_stop_id = make_global_id!(prefix, s.stop.id);
+
+ let stop = state
+ .transit_data
+ .stops_by_platform_id
+ .get(&global_stop_id)
+ .unwrap()
+ .clone();
+ let platform = state
+ .transit_data
+ .platforms
+ .get(&global_stop_id)
+ .unwrap()
+ .clone();
+
+ state
+ .transit_data
+ .route_id_by_stops
+ .entry(stop.id.clone())
+ .or_insert(HashSet::new())
+ .insert(global_rt_id.clone());
+ state
+ .transit_data
+ .stops_by_route_id
+ .entry(global_rt_id.clone())
+ .or_insert(HashSet::new())
+ .insert(stop.id.clone());
+
+ state
+ .transit_data
+ .route_id_by_stops
+ .entry(platform.id.clone())
+ .or_insert(HashSet::new())
+ .insert(global_rt_id.clone());
+ state
+ .transit_data
+ .stops_by_route_id
+ .entry(global_rt_id.clone())
+ .or_insert(HashSet::new())
+ .insert(platform.id.clone());
+
+ libseptastic::stop_schedule::StopSchedule {
+ arrival_time: i64::from(s.arrival_time.unwrap()),
+ stop_sequence: i64::from(s.stop_sequence),
+ stop,
+ platform,
+ }
+ })
+ .collect();
+
+ if let Some(calendar_day) = state
+ .transit_data
+ .calendar_days
+ .get(&trip.1.service_id.clone())
+ {
+ let trip = libseptastic::stop_schedule::Trip {
+ trip_id: trip.1.id.clone(),
+ route: state
+ .transit_data
+ .routes
+ .get(&make_global_id!(prefix, trip.1.route_id))
+ .unwrap()
+ .clone(),
+ direction: libseptastic::direction::Direction {
+ direction: match trip.1.direction_id.unwrap() {
+ gtfs_structures::DirectionType::Outbound => {
+ libseptastic::direction::CardinalDirection::Outbound
+ }
+ gtfs_structures::DirectionType::Inbound => {
+ libseptastic::direction::CardinalDirection::Inbound
+ }
+ },
+ direction_destination: trip.1.trip_headsign.clone().unwrap(),
+ },
+ tracking_data: libseptastic::stop_schedule::TripTracking::Untracked,
+ schedule: sched,
+ service_id: trip.1.service_id.clone(),
+ calendar_day: calendar_day.clone(),
+ };
+
+ if let Some(trip_arr) = state.transit_data.trips.get_mut(&global_rt_id) {
+ trip_arr.push(trip);
+ } else {
+ state.transit_data.trips.insert(global_rt_id, vec![trip]);
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ pub fn update_gtfs_data(state: Arc>) -> anyhow::Result<()> {
+ let mut l_state = state.lock().unwrap();
+ let files = l_state.gtfs_files.clone();
+ l_state.transit_data = TransitData::new();
+
+ let mut gtfses = Vec::new();
+
+ for gtfs_file in files.iter() {
+ let gtfs = if let Some(subzip) = gtfs_file.source.subzip.clone() {
+ info!(
+ "Reading GTFS file at {} (subzip {})",
+ gtfs_file.source.uri, subzip
+ );
+ let res = reqwest::blocking::get(gtfs_file.source.uri.clone())?;
+ let outer_archive = res.bytes()?;
+ let mut archive = ZipArchive::new(Cursor::new(outer_archive))?;
+ archive.extract(l_state.tmp_dir.clone())?;
+
+ let mut file_path = l_state.tmp_dir.clone();
+ file_path.push(subzip.clone());
+
+ info!("Downloaded, parsing");
+
+ gtfs_structures::Gtfs::new(file_path.to_str().unwrap())?
+ } else {
+ info!("Reading GTFS file at {}", gtfs_file.source.uri);
+ gtfs_structures::Gtfs::new(gtfs_file.source.uri.as_str())?
+ };
+
+ gtfses.push((gtfs, gtfs_file.source.prefix.clone()));
+ }
+
+ info!("Data loaded, processing...");
+
+ for (gtfs, prefix) in >fses {
+ GtfsPullService::populate_directions(&mut l_state, &prefix, >fs)?;
+ GtfsPullService::populate_routes(&mut l_state, &prefix, >fs)?;
+ GtfsPullService::populate_stops(&mut l_state, &prefix, >fs)?;
+ for calendar in >fs.calendar {
+ l_state.transit_data.calendar_days.insert(
+ calendar.1.id.clone(),
+ Arc::new(CalendarDay {
+ id: calendar.1.id.clone(),
+ monday: calendar.1.monday,
+ tuesday: calendar.1.tuesday,
+ wednesday: calendar.1.wednesday,
+ thursday: calendar.1.thursday,
+ friday: calendar.1.friday,
+ saturday: calendar.1.saturday,
+ sunday: calendar.1.sunday,
+ start_date: calendar.1.start_date,
+ end_date: calendar.1.end_date,
+ }),
+ );
+ }
+ }
+
+ GtfsPullService::postprocess_stops(&mut l_state)?;
+
+ for (gtfs, prefix) in >fses {
+ GtfsPullService::populate_trips(&mut l_state, &prefix, >fs)?;
+ }
+
+ l_state.ready = true;
+ info!("Finished initial sync, ready state is true");
+
+ Ok(())
+ }
+}
diff --git a/web/src/services/mod.rs b/web/src/services/mod.rs
new file mode 100644
index 0000000..57730cb
--- /dev/null
+++ b/web/src/services/mod.rs
@@ -0,0 +1,2 @@
+pub mod gtfs_pull;
+pub mod trip_tracking;
diff --git a/api/src/services/trip_tracking.rs b/web/src/services/trip_tracking.rs
similarity index 57%
rename from api/src/services/trip_tracking.rs
rename to web/src/services/trip_tracking.rs
index 6189160..dc54e8d 100644
--- a/api/src/services/trip_tracking.rs
+++ b/web/src/services/trip_tracking.rs
@@ -1,14 +1,14 @@
use chrono::Utc;
-use serde_json::Value;
-use serde::de;
-use sqlx::{Postgres, QueryBuilder, Transaction};
-use std::sync::{Arc};
use futures::lock::Mutex;
-use std::collections::HashMap;
-use std::time::Duration;
+use libseptastic::stop_schedule::{LiveTrip, SeatAvailability, TripTracking};
use log::{error, info};
-use serde::{Serialize, Deserialize, Deserializer};
-use libseptastic::stop_schedule::{LiveTrip, TripTracking};
+use serde::de;
+use serde::{Deserialize, Deserializer, Serialize};
+use serde_json::Value;
+use sqlx::{Postgres, QueryBuilder, Transaction};
+use std::collections::HashMap;
+use std::sync::Arc;
+use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiveTripJson {
@@ -34,18 +34,18 @@ pub struct LiveTripJson {
pub next_stop_sequence: Option,
pub seat_availability: Option,
pub vehicle_id: Option,
- pub timestamp: i64
+ pub timestamp: i64,
}
const HOST: &str = "https://www3.septa.org";
struct TripTrackingServiceState {
- pub tracking_data: HashMap::,
- pub database: ::sqlx::postgres::PgPool
+ pub tracking_data: HashMap,
+ pub database: ::sqlx::postgres::PgPool,
}
pub struct TripTrackingService {
- state: Arc>
+ state: Arc>,
}
impl TripTrackingService {
@@ -53,10 +53,9 @@ impl TripTrackingService {
pub async fn log_delay(
transaction: &mut Transaction<'_, Postgres>,
- tracking_data: &HashMap::,
- timestamp: i64
+ tracking_data: &HashMap,
+ timestamp: i64,
) -> ::anyhow::Result<()> {
-
let mut query_builder: QueryBuilder = QueryBuilder::new(
"INSERT INTO
live_tracking
@@ -73,10 +72,10 @@ impl TripTrackingService {
trip_id,
route_id
)
- VALUES"
+ VALUES",
);
- let mut separated = query_builder.separated(", ");
+ let mut separated = query_builder.separated(", ");
for trip in tracking_data {
if let TripTracking::Tracked(live_data) = trip.1 {
separated.push("(");
@@ -87,7 +86,10 @@ impl TripTrackingService {
separated.push_bind(live_data.latitude);
separated.push_bind(live_data.longitude);
separated.push_bind(live_data.heading);
- separated.push_bind(live_data.seat_availability.clone());
+ separated.push_bind(match &live_data.seat_availability {
+ Some(s) => Some(s.to_string()),
+ None => None,
+ });
separated.push_bind(live_data.vehicle_ids.clone());
separated.push_bind(live_data.trip_id.clone());
separated.push_bind(live_data.route_id.clone());
@@ -108,17 +110,21 @@ impl TripTrackingService {
let pool = ::sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&connection_string)
- .await.unwrap();
+ .await
+ .unwrap();
Self {
- state: Arc::new(Mutex::new(TripTrackingServiceState{ tracking_data: HashMap::new(), database: pool}))
+ state: Arc::new(Mutex::new(TripTrackingServiceState {
+ tracking_data: HashMap::new(),
+ database: pool,
+ })),
}
}
pub fn start(&self) {
info!("Starting live tracking service");
let cloned_state = Arc::clone(&self.state);
- tokio::spawn( async move {
+ tokio::spawn(async move {
loop {
let clonedx_state = Arc::clone(&cloned_state);
let res = Self::update_live_trips(clonedx_state).await;
@@ -131,23 +137,34 @@ impl TripTrackingService {
}
tokio::time::sleep(Duration::from_secs(Self::UPDATE_SECONDS)).await;
- }
+ }
});
}
pub async fn annotate_trips(&self, trips: &mut Vec) {
for trip in trips {
- trip.tracking_data = match self.state.lock().await.tracking_data.get(&trip.trip_id.clone()){
+ trip.tracking_data = match self
+ .state
+ .lock()
+ .await
+ .tracking_data
+ .get(&trip.trip_id.clone())
+ {
Some(x) => x.clone(),
- None => TripTracking::Untracked
+ None => TripTracking::Untracked,
};
}
}
- async fn update_live_trips(service: Arc>) -> anyhow::Result<()> {
- let mut new_map: HashMap = HashMap::new();
- let live_tracks = reqwest::get(format!("{}/api/v2/trips/", HOST)).await?.json::>().await?;
-
+ async fn update_live_trips(
+ service: Arc>,
+ ) -> anyhow::Result<()> {
+ let mut new_map: HashMap = HashMap::new();
+ let live_tracks = reqwest::get(format!("{}/api/v2/trips/", HOST))
+ .await?
+ .json::>()
+ .await?;
+
for live_track in live_tracks {
let track: TripTracking = {
if live_track.status == "NO GPS" {
@@ -155,48 +172,50 @@ impl TripTrackingService {
} else if live_track.status == "CANCELED" {
TripTracking::Cancelled
} else {
- TripTracking::Tracked(
- LiveTrip {
- trip_id: live_track.trip_id.clone(),
- route_id: live_track.route_id,
- delay: live_track.delay,
- seat_availability: live_track.seat_availability,
- heading: match live_track.heading {
- Some(hdg) => if hdg != "" { Some(hdg.parse::()?)} else {None},
- None => None
- },
- latitude: match live_track.lat {
- Some(lat) => Some(lat.parse::()?),
- None => None
- },
- longitude: match live_track.lon {
- Some(lon) => Some(lon.parse::()?),
- None => None
- },
- next_stop_id: match live_track.next_stop_id {
- Some(x) => match x.parse() {
- Ok(y) => Some(y),
- Err(_) => None
- },
- None => None
- },
- timestamp: live_track.timestamp,
- vehicle_ids: match live_track.vehicle_id {
- Some(x) => x.split(",").map(|f| String::from(f)).collect(),
- None => vec![]
+ TripTracking::Tracked(LiveTrip {
+ trip_id: live_track.trip_id.clone(),
+ route_id: live_track.route_id,
+ delay: live_track.delay,
+ seat_availability: SeatAvailability::from_opt_string(
+ &live_track.seat_availability,
+ ),
+ heading: match live_track.heading {
+ Some(hdg) => {
+ if hdg != "" {
+ Some(hdg.parse::()?)
+ } else {
+ None
+ }
}
- }
- )
+ None => None,
+ },
+ latitude: match live_track.lat {
+ Some(lat) => Some(lat.parse::()?),
+ None => None,
+ },
+ longitude: match live_track.lon {
+ Some(lon) => Some(lon.parse::()?),
+ None => None,
+ },
+ next_stop_id: match live_track.next_stop_id {
+ Some(x) => match x.parse() {
+ Ok(y) => Some(y),
+ Err(_) => None,
+ },
+ None => None,
+ },
+ timestamp: live_track.timestamp,
+ vehicle_ids: match live_track.vehicle_id {
+ Some(x) => x.split(",").map(|f| String::from(f)).collect(),
+ None => vec![],
+ },
+ })
}
};
- if let TripTracking::Cancelled = track {
- }
+ if let TripTracking::Cancelled = track {}
- new_map.insert(
- live_track.trip_id.clone(),
- track
- );
+ new_map.insert(live_track.trip_id.clone(), track);
}
let mut svc = service.lock().await;
@@ -214,24 +233,34 @@ impl TripTrackingService {
fn de_numstr<'de, D: Deserializer<'de>>(deserializer: D) -> Result {
Ok(match Value::deserialize(deserializer)? {
Value::String(s) => s,
- Value::Number(num) => num.as_i64().ok_or(de::Error::custom("Invalid number"))?.to_string(),
- _ => return Err(de::Error::custom("wrong type"))
+ Value::Number(num) => num
+ .as_i64()
+ .ok_or(de::Error::custom("Invalid number"))?
+ .to_string(),
+ _ => return Err(de::Error::custom("wrong type")),
})
}
fn de_numstro<'de, D: Deserializer<'de>>(deserializer: D) -> Result