many changes

This commit is contained in:
Nicholas Orlowsky 2025-09-12 19:08:22 -04:00
parent 4777f46a38
commit be177af6cd
No known key found for this signature in database
GPG key ID: A9F3BA4C0AA7A70B
25 changed files with 2059 additions and 47 deletions

View file

@ -0,0 +1,67 @@
name: Create and publish a Docker image
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
push:
branches: ['main']
repository_dispatch:
env:
REGISTRY: git.nickorlow.com
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Log in to the Container registry
uses: docker/login-action@v1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.RUNNER_PKG_TOK }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v3
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Setup Python
uses: actions/setup-python@v4.0.0
with:
python-version: 3.12
- uses: paulhatch/semantic-version@v5.0.2
id: vnum
with:
# The prefix to use to identify tags
tag_prefix: ""
major_pattern: "(MAJOR)"
minor_pattern: "(MINOR)"
version_format: "${major}.${minor}.${patch}-${increment}"
bump_each_commit: true
- name: Build and push Docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.vnum.outputs.version_tag }}
${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM rust:1.86.0 as build
ENV PKG_CONFIG_ALLOW_CROSS=1
WORKDIR .
COPY ./api ./api
COPY ./libseptastic/ ./libseptastic/
RUN cd api && cargo install --path .
ENV RUST_LOG=info
ENV EXPOSE_PORT=80
EXPOSE 80
ENTRYPOINT ["./api/septastic_api"]

1
api/.envrc Normal file
View file

@ -0,0 +1 @@
use nix

858
api/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -11,3 +11,11 @@ env_logger = "0.11.8"
log = "0.4.27" log = "0.4.27"
serde_json = "1.0.140" serde_json = "1.0.140"
sqlx = { version = "0.8.6", features = [ "runtime-async-std", "postgres" ] } sqlx = { version = "0.8.6", features = [ "runtime-async-std", "postgres" ] }
libseptastic = { path = "../libseptastic/" }
askama = "0.14.0"
actix-files = "0.6.6"
serde = "1.0.219"
chrono = "0.4.41"
chrono-tz = "0.10.4"
actix-cors = "0.7.1"
reqwest = { version = "0.12.22", features = [ "json" ] }

134
api/assets/style.css Normal file
View file

@ -0,0 +1,134 @@
* {
font-family: mono;
}
th {
text-align: left;
}
td {
text-align: left;
}
table, th, td {
border: 1px solid black;
}
body {
padding: 0 !important;
margin: 0 !important;
}
.body {
background-color: #ffffff;
margin: 10px auto;
max-width: 750px;
width: 95%;
}
a {
text-decoration: none;
color: #114488;
}
p, h1, h2, h3, h4, h5, h6 {
margin: 0;
}
img {
max-width: 100%;
}
.flag-img {
height: 30px;
}
.nav-link {
white-space: nowrap;
}
.metro-container {
font-size: 1.5em;
padding: 0.3em;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
width: max-content;
height: max-content;
aspect-ratio: 1 / 1;
line-height: 1;
}
.rr-container {
background-color: #4c748c;
color: #ffffff;
font-size: 1.5em;
padding: 0.3em;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
width: max-content;
height: max-content;
line-height: 1;
}
.bg-B1, .bg-B2, .bg-B3 {
background-color: #FF661C;
color: #ffffff;
}
.bg-L1 {
background-color: #009BDE;
color: #ffffff;
}
.bg-M1 {
background-color: #552B9D;
color: #ffffff;
}
.bg-D1, .bg-D2 {
background-color: #EA4379;
color: #ffffff;
}
.bg-G1 {
background-color: #FFD500;
color: #000000;
}
.bg-T1, .bg-T2, .bg-T3, .bg-T4, .bg-T5 {
background-color: #6EA516;
color: #ffffff;
}
.bus-container {
display: inline-block;
padding: 0.2em 0.5em; /* scales with font size */
font-size: 1em; /* or inherit */
font-weight: bold;
border-radius: 9999px; /* full pill shape */
border: 2px solid #000000;
background-color: #ffffff;
color: #000000;
width: max-content;
line-height: 1;
}
.tscroll {
width: 100%;
overflow: scroll;
margin-bottom: 10px;
}
.tscroll table td:first-child, .tscroll table th:first-child {
position: sticky;
left: 0;
background-color: #ddd;
box-shadow: inset 0 0.5px 0 #000000,inset 0 -0.5px 0 #000000,inset 1px 0 0 #000000,inset -1px 0 0 #000000;
border-width:0;
}
.tscroll td, .tscroll th {
}

65
api/assets/test.html Normal file
View file

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Canvas Curve Full Width</title>
<style>
body { margin: 0; }
canvas { display: block; background: white; }
</style>
</head>
<body>
<canvas id="curveCanvas" height="180"></canvas>
<script>
const canvas = document.getElementById('curveCanvas');
const ctx = canvas.getContext('2d');
// Set canvas width to window width
function resizeCanvas() {
canvas.width = window.innerWidth;
const rightEnd = canvas.width;
const flatStart = 0;
const flatEnd = 450;
const curveStart = flatEnd;
const curveControl1 = 475;
const curveControl2 = 525;
const curvePeak = 550;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Blue stripe (top)
ctx.beginPath();
ctx.moveTo(flatStart, 90);
ctx.lineTo(flatEnd, 90);
ctx.bezierCurveTo(curveControl1, 90, curveControl2, 30, curvePeak, 30);
ctx.lineTo(rightEnd, 30);
ctx.lineTo(rightEnd, 60);
ctx.lineTo(curvePeak, 60);
ctx.bezierCurveTo(curveControl2, 60, curveControl1, 120, flatEnd, 120);
ctx.lineTo(flatStart, 120);
ctx.closePath();
ctx.fillStyle = '#007ac2';
ctx.fill();
// Orange stripe (bottom)
ctx.beginPath();
ctx.moveTo(flatStart, 120);
ctx.lineTo(flatEnd, 120);
ctx.bezierCurveTo(curveControl1, 120, curveControl2, 60, curvePeak, 60);
ctx.lineTo(rightEnd, 60);
ctx.lineTo(rightEnd, 90);
ctx.lineTo(curvePeak, 90);
ctx.bezierCurveTo(curveControl2, 90, curveControl1, 150, flatEnd, 150);
ctx.lineTo(flatStart, 150);
ctx.closePath();
ctx.fillStyle = '#f15a22';
ctx.fill();
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
</script>
</body>
</html>

8
api/shell.nix Normal file
View file

@ -0,0 +1,8 @@
with import <nixpkgs> {};
stdenv.mkDerivation {
name = "env";
nativeBuildInputs = [ pkg-config ];
buildInputs = [
cryptsetup
];
}

298
api/src/database.rs Normal file
View file

@ -0,0 +1,298 @@
use std::{collections::HashMap, hash::Hash};
use actix_web::Route;
use libseptastic::{direction::CardinalDirection, route::RouteType};
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, Transaction};
pub async fn get_route_by_id(
id: String,
transaction: &mut Transaction<'_, Postgres>,
) -> ::anyhow::Result<libseptastic::route::Route> {
let row = sqlx::query!(
r#"SELECT
id,
name,
short_name,
color_hex,
route_type as "route_type: libseptastic::route::RouteType"
FROM
septa_routes
WHERE
id = $1
;"#,
id
)
.fetch_one(&mut **transaction)
.await?;
return Ok(libseptastic::route::Route {
name: row.name,
short_name: row.short_name,
color_hex: row.color_hex,
route_type: row.route_type,
id: row.id,
});
}
pub async fn get_direction_by_route_id(
id: String,
transaction: &mut Transaction<'_, Postgres>,
) -> ::anyhow::Result<Vec<libseptastic::direction::Direction>> {
let rows = sqlx::query!(
r#"SELECT
route_id,
direction_id,
direction as "direction: libseptastic::direction::CardinalDirection",
direction_destination
FROM
septa_directions
WHERE
route_id = $1
;"#,
id
)
.fetch_all(&mut **transaction)
.await?;
let mut res = Vec::new();
for row in rows {
res.push(libseptastic::direction::Direction{
route_id: row.route_id,
direction_id: row.direction_id,
direction: row.direction,
direction_destination: row.direction_destination
});
}
return Ok(res);
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StopSchedule {
pub route_id: String,
pub stop_name: String,
pub trip_id: String,
pub service_id: String,
pub direction_id: i64,
pub arrival_time: i64,
pub stop_id: i64,
pub stop_sequence: i64
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trip {
pub route_id: String,
pub trip_id: String,
pub direction_id: i64,
pub schedule: Vec<StopSchedule>
}
pub async fn get_schedule_by_route_id(
id: String,
transaction: &mut Transaction<'_, Postgres>,
) -> ::anyhow::Result<Vec<Trip>> {
let rows = sqlx::query!(
r#"SELECT
septa_stop_schedules.route_id,
septa_stops.name as stop_name,
trip_id,
service_id,
septa_stop_schedules.direction_id,
septa_directions.direction as "direction: libseptastic::direction::CardinalDirection",
arrival_time,
stop_id,
stop_sequence
FROM
septa_stop_schedules
INNER JOIN septa_directions
ON
septa_directions.direction_id = septa_stop_schedules.direction_id
AND
septa_directions.route_id = septa_stop_schedules.route_id
INNER JOIN septa_stops
ON septa_stops.id = septa_stop_schedules.stop_id
WHERE
septa_stop_schedules.route_id = $1
AND
service_id IN (SELECT service_id FROM septa_schedule_days WHERE date = '20250707')
;"#,
id
)
.fetch_all(&mut **transaction)
.await?;
let mut sched_groups: HashMap<String, Vec<StopSchedule>> = HashMap::new();
for row in rows {
let mut arr = match sched_groups.get_mut(&row.trip_id) {
Some(x) => x,
None => {
sched_groups.insert(row.trip_id.clone(), Vec::new());
sched_groups.get_mut(&row.trip_id).unwrap()
}
};
arr.push(StopSchedule {
route_id: row.route_id,
stop_name: row.stop_name,
trip_id: row.trip_id,
service_id: row.service_id,
direction_id: row.direction_id,
arrival_time: row.arrival_time,
stop_id: row.stop_id,
stop_sequence: row.stop_sequence
});
}
let mut res = Vec::new();
for group in sched_groups {
res.push(Trip{
trip_id: group.0,
route_id: group.1[0].route_id.clone(),
schedule: group.1.clone(),
direction_id: group.1[0].direction_id.clone()
});
}
return Ok(res);
}
#[derive(Serialize,Deserialize,Clone)]
pub struct NTALive {
delay: i64,
cancelled: bool,
next_stop: Option<String>
}
#[derive(Serialize,Deserialize)]
pub struct LiveData {
route_id: String,
service_id: String,
trip_id: String,
trip_headsign: String,
direction_id: i64,
block_id: String,
start_time: String,
end_time: String,
delay: i64,
status: String,
lat: Option<String>,
lon: Option<String>,
heading: Option<String>,
next_stop_id: Option<i64>,
next_stop_name: Option<String>,
next_stop_sequence: Option<i64>,
seat_availability: String,
vehicle_id: String,
timestamp: i64
}
#[derive(Serialize,Deserialize)]
pub struct NTAEntry {
route_id: String,
route_type: RouteType,
route_name: String,
color_hex: String,
trip_id: String,
arrival_time: i64,
direction: CardinalDirection,
direction_destination: String,
live: Option<NTALive>
}
#[derive(Serialize,Deserialize)]
pub struct NTAResult {
station_name: String,
arrivals: Vec<NTAEntry>
}
pub async fn get_nta_by_stop_id(
ids: Vec<i64>,
start_time: chrono::DateTime<chrono::Utc>,
end_time: chrono::DateTime<chrono::Utc>,
transaction: &mut Transaction<'_, Postgres>,
) -> ::anyhow::Result<NTAResult> {
let local_start = start_time.with_timezone(&chrono_tz::America::New_York);
let local_end = end_time.with_timezone(&chrono_tz::America::New_York);
let local_midnight = chrono::Utc::now().with_timezone(&chrono_tz::America::New_York).date().and_hms(0,0,0);
let start_secs = local_start.signed_duration_since(local_midnight).num_seconds();
let end_secs = local_end.signed_duration_since(local_midnight).num_seconds();
let name_row = sqlx::query!("SELECT name FROM septa_stops WHERE id = $1", ids[0]).fetch_one(&mut **transaction).await?;
let stop_name = name_row.name;
let rows: Vec<(String, RouteType, String, String, i64, CardinalDirection, String, String,)> = sqlx::query_as(
r#"SELECT
septa_stop_schedules.route_id,
route_type as "route_type: libseptastic::route::RouteType",
septa_routes.color_hex,
trip_id,
arrival_time,
septa_directions.direction as "direction: libseptastic::direction::CardinalDirection",
septa_directions.direction_destination,
septa_routes.name
FROM
septa_stop_schedules
INNER JOIN septa_directions
ON
septa_directions.direction_id = septa_stop_schedules.direction_id
AND
septa_directions.route_id = septa_stop_schedules.route_id
INNER JOIN septa_stops
ON septa_stops.id = septa_stop_schedules.stop_id
INNER JOIN septa_routes
ON septa_routes.id = septa_stop_schedules.route_id
WHERE
(septa_stops.id = $1 OR septa_stops.id = $2)
AND
service_id IN (SELECT service_id FROM septa_schedule_days WHERE date = '20250707')
AND
septa_stop_schedules.arrival_time > $3
AND
septa_stop_schedules.arrival_time < $4
ORDER BY arrival_time
;"#)
.bind(&ids[0])
.bind(&ids.get(1).unwrap_or(&0))
.bind(&start_secs)
.bind(&end_secs)
.fetch_all(&mut **transaction)
.await?;
let mut ntas: Vec<NTAEntry> = Vec::new();
let mut live_map: HashMap<String, NTALive> = HashMap::new();
let lives: Vec<LiveData> = reqwest::get("https://www3.septa.org/api/v2/trips/?route_id=AIR,CHW,LAN,NOR,TRE,WIL,WAR,MED,PAO,FOX,WTR,CYN").await?.json().await?;
for live in lives {
live_map.insert(live.route_id, NTALive { delay: live.delay, cancelled: live.status == "CANCELLED", next_stop: live.next_stop_name });
}
for row in rows {
ntas.push(NTAEntry {
route_id: row.0.clone(),
route_type: row.1,
color_hex: row.2,
trip_id: row.3,
arrival_time: row.4,
direction: row.5,
direction_destination: row.6,
route_name: row.7,
live: match live_map.get(&row.0) {
Some(x) => Some(x.clone()),
None => None
}
});
}
return Ok(NTAResult{
station_name: stop_name,
arrivals: ntas
});
}

View file

@ -1,12 +1,229 @@
use actix_web::{get, App, HttpResponse, HttpServer, Responder}; use actix_web::{get, web::{self, Data}, App, HttpResponse, HttpServer, Responder};
use chrono::TimeDelta;
use database::{get_direction_by_route_id, get_nta_by_stop_id, get_schedule_by_route_id};
use env_logger::Env; use env_logger::Env;
use libseptastic::{direction::Direction};
use database::{Trip, StopSchedule};
use log::*; use log::*;
use dotenv::dotenv; use dotenv::dotenv;
use serde_json::json; use std::{cmp::Ordering, collections::BTreeMap, sync::Arc};
use askama::Template;
use serde::{Serialize};
mod database;
struct AppState {
database: ::sqlx::postgres::PgPool
}
async fn get_route_by_id(id: String, state: Data<Arc<AppState>>) -> ::anyhow::Result<libseptastic::route::Route> {
Ok(database::get_route_by_id(id, &mut state.database.begin().await?).await?)
}
#[derive(Debug, Serialize)]
pub struct TimetableStopRow {
pub stop_id: i64,
pub stop_name: String,
pub stop_sequence: i64,
pub times: Vec<Option<i64>>, // one per trip, None if trip doesn't stop
}
#[derive(Debug, Serialize)]
pub struct TimetableDirection {
pub direction: Direction,
pub trip_ids: Vec<String>, // column headers
pub rows: Vec<TimetableStopRow>, // one per unique stop
}
pub fn build_timetables(
directions: &[Direction],
trips: &[Trip],
) -> Vec<TimetableDirection> {
let mut results = Vec::new();
for direction in directions {
let mut direction_trips: Vec<&Trip> = trips
.iter()
.filter(|trip| trip.direction_id == direction.direction_id)
.collect();
direction_trips.sort_by_key(|trip| {
trip.schedule
.iter()
.filter_map(|s| Some(s.arrival_time))
.min()
.unwrap_or(i64::MAX)
});
let trip_ids: Vec<String> = direction_trips
.iter()
.map(|t| t.trip_id.clone())
.collect();
// Map of stop_id -> (stop_sequence, Vec<Option<arrival_time>>)
let mut stop_map: BTreeMap<i64, (i64, String, Vec<Option<i64>>)> = BTreeMap::new();
for (trip_index, trip) in direction_trips.iter().enumerate() {
for stop in &trip.schedule {
let entry = stop_map
.entry(stop.stop_id)
.or_insert((stop.stop_sequence, stop.stop_name.clone(), vec![None; direction_trips.len()]));
// If this stop_id appears in multiple trips with different sequences, keep the lowest
entry.0 = entry.0.max(stop.stop_sequence);
entry.1 = stop.stop_name.clone();
entry.2[trip_index] = Some(stop.arrival_time);
}
}
let mut rows: Vec<TimetableStopRow> = stop_map
.into_iter()
.map(|(stop_id, (stop_sequence, stop_name, times))| TimetableStopRow {
stop_id,
stop_sequence,
stop_name,
times,
})
.collect();
rows.sort_by(| a, b| {
if a.stop_sequence < b.stop_sequence {
Ordering::Less
} else {
Ordering::Greater
}
});
results.push(TimetableDirection {
direction: direction.clone(),
trip_ids,
rows,
});
}
results
}
mod filters {
pub fn format_time(
seconds: &i64,
_: &dyn askama::Values,
) -> askama::Result<String> {
let total_minutes = seconds / 60;
let (hours, ampm) = {
let hrs = total_minutes / 60;
if hrs > 12 {
(hrs - 12, "PM")
} else {
(hrs, "AM")
}
};
let minutes = total_minutes % 60;
Ok(format!("{}:{:02} {}", hours, minutes, ampm))
}
}
#[derive(askama::Template)]
#[template(path = "layout.html")]
struct ContentTemplate<T: askama::Template> {
content: T,
page_title: Option<String>,
page_desc: Option<String>,
}
#[derive(askama::Template)]
#[template(path = "route.html")]
struct RouteTemplate {
route: libseptastic::route::Route,
directions: Vec<libseptastic::direction::Direction>,
timetables: Vec<TimetableDirection>
}
#[derive(askama::Template)]
#[template(path = "routes.html")]
struct RoutesTemplate {
}
#[derive(askama::Template)]
#[template(path = "index.html")]
struct IndexTemplate {
}
#[get("/routes")]
async fn get_routes() -> impl Responder {
HttpResponse::Ok().body(ContentTemplate {
page_title: None,
page_desc: None,
content: RoutesTemplate {}
}.render().unwrap())
}
#[get("/")] #[get("/")]
async fn hello() -> impl Responder { async fn get_index() -> impl Responder {
HttpResponse::Ok().json("{}")
HttpResponse::Ok().body(ContentTemplate {
page_title: None,
page_desc: None,
content: IndexTemplate {}
}.render().unwrap())
}
#[get("/route/{route_id}")]
async fn get_route(state: Data<Arc<AppState>>, path: web::Path<(String)>) -> impl Responder {
let route_id = path.into_inner();
let route_r = get_route_by_id(route_id.clone(), state.clone()).await;
let directions = get_direction_by_route_id(route_id.clone(), &mut state.database.begin().await.unwrap()).await.unwrap();
let trips = get_schedule_by_route_id(route_id, &mut state.database.begin().await.unwrap()).await.unwrap();
if let Ok(route) = route_r {
HttpResponse::Ok().body(ContentTemplate {
page_title: None,
page_desc: None,
content: RouteTemplate {
route,
directions: directions.clone(),
timetables: build_timetables(directions.as_slice(), trips.as_slice())
}
}.render().unwrap())
} else {
HttpResponse::InternalServerError().body("Error")
}
}
#[get("/api/route/{route_id}")]
async fn api_get_route(state: Data<Arc<AppState>>, path: web::Path<(String)>) -> impl Responder {
let route_id = path.into_inner();
let route_r = get_route_by_id(route_id, state).await;
if let Ok(route) = route_r {
HttpResponse::Ok().json(route)
} else {
HttpResponse::InternalServerError().body("Error")
}
}
#[get("/api/route/{route_id}/schedule")]
async fn api_get_schedule(state: Data<Arc<AppState>>, path: web::Path<(String)>) -> impl Responder {
let route_id = path.into_inner();
let route_r = get_schedule_by_route_id(route_id, &mut state.database.begin().await.unwrap()).await;
if let Ok(route) = route_r {
HttpResponse::Ok().json(route)
} else {
HttpResponse::InternalServerError().body("Error")
}
}
#[get("/api/stop/{stop_id}/nta")]
async fn api_get_nta(state: Data<Arc<AppState>>, path: web::Path<(String)>) -> impl Responder {
let route_id = path.into_inner().split(',') .map(|s| s.parse::<i64>())
.collect::<Result<Vec<i64>, _>>().unwrap();
let route_r = get_nta_by_stop_id(route_id, chrono::Utc::now(), chrono::Utc::now() + TimeDelta::minutes(30), &mut state.database.begin().await.unwrap()).await;
if let Ok(route) = route_r {
HttpResponse::Ok().json(route)
} else {
HttpResponse::InternalServerError().body(format!("Error {:?}", route_r.err()))
}
} }
#[actix_web::main] #[actix_web::main]
@ -27,12 +244,23 @@ async fn main() -> ::anyhow::Result<()> {
.connect(&connection_string) .connect(&connection_string)
.await?; .await?;
let mut transaction = pool.begin().await?; let state = Arc::new(AppState {
HttpServer::new(|| { database: pool
});
HttpServer::new(move || {
App::new() App::new()
.service(hello) .wrap(actix_cors::Cors::permissive())
.app_data(Data::new(state.clone()))
.service(api_get_route)
.service(api_get_schedule)
.service(api_get_nta)
.service(get_route)
.service(get_routes)
.service(get_index)
.service(actix_files::Files::new("/assets", "./assets"))
}) })
.bind(("127.0.0.1", 8080))? .bind(("0.0.0.0", 8080))?
.run() .run()
.await?; .await?;

13
api/templates/index.html Normal file
View file

@ -0,0 +1,13 @@
<h1>SEPTASTIC!</h1>
<p><i>A fantastic way to ride SEPTA</i></p>
<p style="margin-top: 25px;">
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.
</p>
<p style="margin-top: 25px; margin-bottom: 25px;">
Currently, all this website has is <a href="/routes">timetables for every
SEPTA route</a>. More to come soon!
</p>

58
api/templates/layout.html Normal file
View file

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% if let Some(title) = page_title %}
<title>{{ title }}</title>
{% else %}
<title>SEPTASTIC</title>
{% endif %}
{% if let Some(desc) = page_desc %}
<meta name="{{ desc }}" />
{% else %}
<meta name="SEPTASTIC" />
{% endif %}
<link rel="stylesheet" href="/assets/style.css">
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<style>
.silverliner-svg {
display: block;
width: 100%;
height: 200px; /* Fixed height matching the viewBox */
}
</style>
<body>
<div class="body">
<nav>
<div style="display: flex; justify-content: space-between;">
<div>
<a class="nav-link" href="/">[ Home ]</a>
<a class="nav-link" href="/routes">[ Routes ]</a>
</div>
<div>
</div>
</div>
</nav>
<hr/>
{{ content|safe }}
<footer>
<hr />
<div style="display: flex; justify-content: space-between;">
<div>
<p style="margin-bottom: 0px; margin-top:0px;"><b>SEPTASTIC!</b></p>
<p style="margin-bottom: 0px;margin-top: 0px;">
<small>Copyright &#169; <a href="https://nickorlow.com">Nicholas Orlowsky</a> 2025</small>
</p>
</div>
</div>
</footer>
</div>
</body>
</html>

111
api/templates/route.html Normal file
View file

@ -0,0 +1,111 @@
{%- import "route_symbol.html" as scope -%}
<style>
.train-direction-table {
width: 100%;
border-collapse: collapse;
font-family: sans-serif;
font-size: 14px;
}
.train-direction-table th,
.train-direction-table td {
border: 1px solid #000;
padding: 4px 8px;
text-align: left;
cursor: pointer;
}
.train-direction-table th {
background-color: #f0f0f0;
font-weight: bold;
}
.highlight-row td,
.highlight-row th {
background-color: #d0ebff !important;
}
.highlight-col {
background-color: #d0ebff !important;
}
</style>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".train-direction-table").forEach((table) => {
table.addEventListener("click", (e) => {
const cell = e.target.closest("td, th");
if (!cell) return;
// Clear previous highlights
table.querySelectorAll("tr").forEach(row => row.classList.remove("highlight-row"));
table.querySelectorAll("td, th").forEach(c => c.classList.remove("highlight-col"));
const row = cell.parentNode;
const colIndex = Array.from(cell.parentNode.children).indexOf(cell);
// If it's the first column (row header)
if (cell.cellIndex === 0 && cell.tagName === "TD") {
row.classList.add("highlight-row");
}
// If it's a column header
else if (row.parentNode.tagName === "THEAD") {
table.querySelectorAll("tr").forEach(r => {
const cell = r.children[colIndex];
if (cell) cell.classList.add("highlight-col");
});
}
// If it's a center cell
else {
row.classList.add("highlight-row");
table.querySelectorAll("tr").forEach(r => {
const cell = r.children[colIndex];
if (cell) cell.classList.add("highlight-col");
});
}
});
});
});
</script>
<div style="background-color: #ff0000; color: #ffffff; padding: 15px; margin-bottom: 15px; margin-top: 15px;">
This website is not run by SEPTA. As such, schedules may not be
completely accurate.
</div>
<div style="display: flex; align-items: center;">
{% call scope::route_symbol(route) %}
<h1 style="margin-left: 15px;">{{ route.name }}</h1>
</div>
{% for timetable in timetables %}
<h2>{{ timetable.direction.direction | capitalize }} to {{ timetable.direction.direction_destination }}</h2>
<div class="tscroll">
<table class="train-direction-table" style="margin-top: 5px;">
<thead>
<tr>
<th>Stop</th>
{% for trip_id in timetable.trip_ids %}
<th>{{ trip_id }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in timetable.rows %}
<tr>
<td>{{ row.stop_name }}</td>
{% for time in row.times %}
<td>
{% if let Some(t) = time %}
{{ t | format_time }}
{% else %}
--
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}

View file

@ -0,0 +1,19 @@
{% macro route_symbol(route) %}
{% match route.route_type %}
{% when libseptastic::route::RouteType::Trolley | libseptastic::route::RouteType::SubwayElevated %}
<div class="metro-container bg-{{ route.id }}">
{{ route.id }}
</div>
{% endwhen %}
{% when libseptastic::route::RouteType::RegionalRail %}
<div class="rr-container">
{{ route.id }}
</div>
{% endwhen %}
{% when libseptastic::route::RouteType::Bus | libseptastic::route::RouteType::TracklessTrolley %}
<div class="bus-container">
{{ route.id }}
</div>
{% endwhen %}
{% endmatch %}
{% endmacro %}

83
api/templates/routes.html Normal file
View file

@ -0,0 +1,83 @@
<h1>Routes</h1>
<p>Click on a route to see details and a schedule. Schedules in prevailing local time.</p>
<fieldset>
<legend><h2>Regional Rail</h2></legend>
<p style="margin-top: 10px; margin-bottom: 10px;">For infrequent rail service to suburban locations</p>
<!--<h3 class="line-link">[ Pennsy ]</h3>-->
<p class="line-link"><a href="/route/TRE">[ <b>TRE:</b> Trenton (NJT to New York) ]</a></p>
<p class="line-link"><a href="/route/PAO">[ <b>PAO:</b> Paoli/Thorndale ]</a></p>
<p class="line-link"><a href="/route/CYN">[ <b>CYN:</b> Cynwyd ]</a></p>
<p class="line-link"><a href="/route/AIR">[ <b>AIR:</b> Airport ]</a></p>
<p class="line-link"><a href="/route/WIL">[ <b>WIL:</b> Wilmington/Newark ]</a></p>
<p class="line-link"><a href="/route/CHW">[ <b>CHW:</b> Chestnut Hill West ]</a></p>
<p class="line-link"><a href="/route/WAW">[ <b>WAW:</b> Media/Wawa ]</a></p>
<!--<h3 class="line-link">[ Reading ]</h3>-->
<p class="line-link"><a href="/route/LAN">[ <b>LAN:</b> Lansdale/Doylestown ]</a></p>
<p class="line-link"><a href="/route/NOR">[ <b>NOR:</b> Manayunk/Norristown ]</a></p>
<p class="line-link"><a href="/route/CHE">[ <b>CHE:</b> Chestnut Hill East ]</a></p>
<p class="line-link"><a href="/route/FOX">[ <b>FOX:</b> Fox Chase ]</a></p>
<p class="line-link"><a href="/route/WTF">[ <b>WTR:</b> West Trenton ]</a></p>
<p class="line-link"><a href="/route/WAR">[ <b>WAR:</b> Warminster ]</a></p>
</fieldset>
<fieldset>
<legend><h2>Metro</h2></legend>
<p style="margin-top: 10px; margin-bottom: 10px;">For frequent rail service within Philadelphia and suburban locations</p>
<p class="lines-label" style="font-weight: bold;">[ Subway/Elevated ]</p>
<p class="line-link"><a href="/route/B1">[ <b>B1:</b> Broad Street Line Local ]</a></p>
<p class="line-link"><a href="/route/B2">[ <b>B2:</b> Broad Street Line Express ]</a></p>
<p class="line-link"><a href="/route/B3">[ <b>B3:</b> Broad Ridge Spur ]</a></p>
<p class="line-link"><a href="/route/L1">[ <b>L1:</b> Market-Frankford Line ]</a></p>
<p class="line-link"><a href="/route/S1">[ <b>S1:</b> Norristown-Airport Line ]</a></p>
<p class="line-link"><a href="/route/S2">[ <b>S2:</b> Media-Chestnut Hill Line ]</a></p>
<p class="line-link"><a href="/route/S3">[ <b>S3:</b> Paoli-Fox Chase Line ]</a></p>
<p class="lines-label" style="font-weight: bold;">[ Urban Trolley ]</p>
<p class="line-link"><a href="/route/T1">[ <b>T1:</b> Lancaster Avenue Trolley ]</a></p>
<p class="line-link"><a href="/route/T2">[ <b>T2:</b> Baltimore Avenue Trolley ]</a></p>
<p class="line-link"><a href="/route/T3">[ <b>T3:</b> Chester Avenue Trolley ]</a></p>
<p class="line-link"><a href="/route/T4">[ <b>T4:</b> Woodland Avenue Trolley ]</a></p>
<p class="line-link"><a href="/route/T5">[ <b>T5:</b> Elmwood Avenue Trolley ]</a></p>
<p class="line-link"><a href="/route/G1">[ <b>G1:</b> Girard Avenue Trolley ]</a></p>
<p class="lines-label" style="font-weight: bold;">[ Suburban Trolley ]</p>
<p class="line-link"><a href="/route/D1">[ <b>D1:</b> Media Line ]</a></p>
<p class="line-link"><a href="/route/D2">[ <b>D2:</b> Sharon Hill Line ]</a></p>
<p class="line-link"><a href="/route/M1">[ <b>M1:</b> Norristown High Speed Line ]</a></p>
</fieldset>
<fieldset>
<legend><h2>Bus</h2></legend>
<p style="margin-top: 10px; margin-bottom: 10px;">For service of varying frequency within SEPTA's entire service area</p>
<p class="lines-label" style="font-weight: bold;">[ Subway/Elevated ]</p>
<p class="line-link"><a href="/route/B1">[ <b>B1:</b> Broad Street Line Local ]</a></p>
<p class="line-link"><a href="/route/B2">[ <b>B2:</b> Broad Street Line Express ]</a></p>
<p class="line-link"><a href="/route/B3">[ <b>B3:</b> Broad Ridge Spur ]</a></p>
<p class="line-link"><a href="/route/L1">[ <b>L1:</b> Market-Frankford Line ]</a></p>
<p class="lines-label" style="font-weight: bold;">[ Urban Trolley ]</p>
<p class="line-link"><a href="/route/T1">[ <b>T1:</b> Lancaster Avenue Trolley ]</a></p>
<p class="line-link"><a href="/route/T2">[ <b>T2:</b> Baltimore Avenue Trolley ]</a></p>
<p class="line-link"><a href="/route/T3">[ <b>T3:</b> Chester Avenue Trolley ]</a></p>
<p class="line-link"><a href="/route/T4">[ <b>T4:</b> Woodland Avenue Trolley ]</a></p>
<p class="line-link"><a href="/route/T5">[ <b>T5:</b> Elmwood Avenue Trolley ]</a></p>
<p class="line-link"><a href="/route/G1">[ <b>G1:</b> Girard Avenue Trolley ]</a></p>
<p class="lines-label" style="font-weight: bold;">[ Suburban Trolley ]</p>
<p class="line-link"><a href="/route/D1">[ <b>D1:</b> Media Line ]</a></p>
<p class="line-link"><a href="/route/D2">[ <b>D2:</b> Sharon Hill Line ]</a></p>
<p class="line-link"><a href="/route/M1">[ <b>M1:</b> Norristown High Speed Line ]</a></p>
</fieldset>
<style>
.line-link, .lines-label {
white-space: pre;
margin-top: 3px;
margin-bottom: 3px;
}
.lines-label {
color: #ffffff;
background-color: #000000;
width: max-content;
}
</style>

@ -1 +1 @@
Subproject commit d577568fec8b1b7356cd0bb0520a20283318096a Subproject commit f7e9e7903b55ed5353b0ea3946dcb4b131557468

View file

@ -538,6 +538,8 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
name = "libseptastic" name = "libseptastic"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"serde",
"serde_json",
"sqlx", "sqlx",
] ]

View file

@ -4,4 +4,6 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
serde = "1.0.219"
serde_json = "1.0.140"
sqlx = "0.8.6" sqlx = "0.8.6"

View file

@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
#[derive(sqlx::Type, Serialize, Deserialize, PartialEq, Debug, Clone)]
#[sqlx(type_name = "septa_direction_type", rename_all = "snake_case")]
pub enum CardinalDirection {
Northbound,
Southbound,
Eastbound,
Westbound,
Inbound,
Outbound,
Loop
}
#[derive(::sqlx::Decode, Serialize, Deserialize, Debug, Clone)]
pub struct Direction {
pub route_id: String,
pub direction_id: i64,
pub direction: CardinalDirection,
pub direction_destination: String
}
impl std::fmt::Display for CardinalDirection {
fn fmt (&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let output = match self {
CardinalDirection::Northbound => "Northbound",
CardinalDirection::Southbound => "Southbound",
CardinalDirection::Eastbound => "Eastbound",
CardinalDirection::Westbound => "Westbound",
CardinalDirection::Inbound => "Inbound",
CardinalDirection::Outbound => "Outbound",
CardinalDirection::Loop => "Loop"
};
std::write!(f, "{}", output)
}
}

View file

@ -3,3 +3,5 @@ pub mod stop;
pub mod route_stop; pub mod route_stop;
pub mod stop_schedule; pub mod stop_schedule;
pub mod schedule_day; pub mod schedule_day;
pub mod direction;
pub mod ridership;

View file

@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
use crate::direction::CardinalDirection;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ridership {
pub route_id: String,
pub stop_id: i64,
pub direction: CardinalDirection,
pub exp_ons: i64,
pub exp_offs: i64,
pub ons: i64,
pub offs: i64,
pub year: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineRidership {
pub route_id: String,
pub unlinked_trips: i64
}

View file

@ -1,4 +1,6 @@
#[derive(sqlx::Type, PartialEq, Debug, Clone)] use serde::{Deserialize, Serialize};
#[derive(sqlx::Type, Serialize, Deserialize, PartialEq, Debug, Clone)]
#[sqlx(type_name = "septa_route_type", rename_all = "snake_case")] #[sqlx(type_name = "septa_route_type", rename_all = "snake_case")]
pub enum RouteType { pub enum RouteType {
Trolley, Trolley,
@ -8,34 +10,11 @@ pub enum RouteType {
TracklessTrolley TracklessTrolley
} }
#[derive(sqlx::Type, PartialEq, Debug, Clone)] #[derive(::sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
#[sqlx(type_name = "septa_direction_type", rename_all = "snake_case")]
pub enum CardinalDirection {
Northbound,
Southbound,
Eastbound,
Westbound // (and down)
}
#[derive(Debug, Clone)]
pub struct Directional {
pub direction: CardinalDirection,
pub direction_destination: String
}
#[derive(Debug, Clone)]
pub struct RouteDirectional {
pub primary: Directional, // 0
pub secondary: Directional, // 1
}
#[derive(Debug, Clone)]
pub struct Route { pub struct Route {
pub name: String, pub name: String,
pub short_name: String, pub short_name: String,
pub color_hex: String, pub color_hex: String,
pub route_type: RouteType, pub route_type: RouteType,
pub id: String, pub id: String
pub directional: RouteDirectional
} }

View file

@ -1,9 +1,7 @@
use super::route::CardinalDirection;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RouteStop { pub struct RouteStop {
pub route_id: String, pub route_id: String,
pub stop_id: i64, pub stop_id: i64,
pub direction: CardinalDirection, pub direction_id: i64,
pub stop_sequence: i64 pub stop_sequence: i64
} }

View file

@ -1,12 +1,20 @@
use super::route::CardinalDirection; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StopSchedule { pub struct StopSchedule {
pub route_id: String, pub route_id: String,
pub trip_id: String, pub trip_id: String,
pub service_id: String, pub service_id: String,
pub direction: CardinalDirection, pub direction_id: i64,
pub arrival_time: i64, pub arrival_time: i64,
pub stop_id: i64, pub stop_id: i64,
pub stop_sequence: i64 pub stop_sequence: i64
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trip {
pub route_id: String,
pub trip_id: String,
pub direction_id: i64,
pub schedule: Vec<StopSchedule>
}

8
shell.nix Normal file
View file

@ -0,0 +1,8 @@
with import <nixpkgs> {};
stdenv.mkDerivation {
name = "septastic_env";
nativeBuildInputs = [ pkg-config ];
buildInputs = [
cryptsetup
];
}