many changes
This commit is contained in:
parent
4777f46a38
commit
be177af6cd
25 changed files with 2059 additions and 47 deletions
67
.gitea/workflows/main.yaml
Normal file
67
.gitea/workflows/main.yaml
Normal 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
16
Dockerfile
Normal 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
1
api/.envrc
Normal file
|
@ -0,0 +1 @@
|
|||
use nix
|
858
api/Cargo.lock
generated
858
api/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -11,3 +11,11 @@ env_logger = "0.11.8"
|
|||
log = "0.4.27"
|
||||
serde_json = "1.0.140"
|
||||
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
134
api/assets/style.css
Normal 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
65
api/assets/test.html
Normal 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
8
api/shell.nix
Normal file
|
@ -0,0 +1,8 @@
|
|||
with import <nixpkgs> {};
|
||||
stdenv.mkDerivation {
|
||||
name = "env";
|
||||
nativeBuildInputs = [ pkg-config ];
|
||||
buildInputs = [
|
||||
cryptsetup
|
||||
];
|
||||
}
|
298
api/src/database.rs
Normal file
298
api/src/database.rs
Normal 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
|
||||
});
|
||||
}
|
244
api/src/main.rs
244
api/src/main.rs
|
@ -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 libseptastic::{direction::Direction};
|
||||
use database::{Trip, StopSchedule};
|
||||
use log::*;
|
||||
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("/")]
|
||||
async fn hello() -> impl Responder {
|
||||
HttpResponse::Ok().json("{}")
|
||||
async fn get_index() -> impl Responder {
|
||||
|
||||
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]
|
||||
|
@ -27,12 +244,23 @@ async fn main() -> ::anyhow::Result<()> {
|
|||
.connect(&connection_string)
|
||||
.await?;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
HttpServer::new(|| {
|
||||
let state = Arc::new(AppState {
|
||||
database: pool
|
||||
});
|
||||
|
||||
HttpServer::new(move || {
|
||||
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()
|
||||
.await?;
|
||||
|
||||
|
|
13
api/templates/index.html
Normal file
13
api/templates/index.html
Normal 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
58
api/templates/layout.html
Normal 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 © <a href="https://nickorlow.com">Nicholas Orlowsky</a> 2025</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
111
api/templates/route.html
Normal file
111
api/templates/route.html
Normal 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 %}
|
19
api/templates/route_symbol.html
Normal file
19
api/templates/route_symbol.html
Normal 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
83
api/templates/routes.html
Normal 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
|
2
libseptastic/Cargo.lock
generated
2
libseptastic/Cargo.lock
generated
|
@ -538,6 +538,8 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
|
|||
name = "libseptastic"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
]
|
||||
|
||||
|
|
|
@ -4,4 +4,6 @@ version = "0.1.0"
|
|||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = "1.0.219"
|
||||
serde_json = "1.0.140"
|
||||
sqlx = "0.8.6"
|
||||
|
|
37
libseptastic/src/direction.rs
Normal file
37
libseptastic/src/direction.rs
Normal 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)
|
||||
}
|
||||
}
|
|
@ -3,3 +3,5 @@ pub mod stop;
|
|||
pub mod route_stop;
|
||||
pub mod stop_schedule;
|
||||
pub mod schedule_day;
|
||||
pub mod direction;
|
||||
pub mod ridership;
|
||||
|
|
21
libseptastic/src/ridership.rs
Normal file
21
libseptastic/src/ridership.rs
Normal 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
|
||||
}
|
|
@ -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")]
|
||||
pub enum RouteType {
|
||||
Trolley,
|
||||
|
@ -8,34 +10,11 @@ pub enum RouteType {
|
|||
TracklessTrolley
|
||||
}
|
||||
|
||||
#[derive(sqlx::Type, PartialEq, 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)]
|
||||
#[derive(::sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Route {
|
||||
pub name: String,
|
||||
pub short_name: String,
|
||||
pub color_hex: String,
|
||||
pub route_type: RouteType,
|
||||
pub id: String,
|
||||
pub directional: RouteDirectional
|
||||
pub id: String
|
||||
}
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
use super::route::CardinalDirection;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouteStop {
|
||||
pub route_id: String,
|
||||
pub stop_id: i64,
|
||||
pub direction: CardinalDirection,
|
||||
pub direction_id: i64,
|
||||
pub stop_sequence: i64
|
||||
}
|
||||
|
|
|
@ -1,12 +1,20 @@
|
|||
use super::route::CardinalDirection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StopSchedule {
|
||||
pub route_id: String,
|
||||
pub trip_id: String,
|
||||
pub service_id: String,
|
||||
pub direction: CardinalDirection,
|
||||
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>
|
||||
}
|
||||
|
|
8
shell.nix
Normal file
8
shell.nix
Normal file
|
@ -0,0 +1,8 @@
|
|||
with import <nixpkgs> {};
|
||||
stdenv.mkDerivation {
|
||||
name = "septastic_env";
|
||||
nativeBuildInputs = [ pkg-config ];
|
||||
buildInputs = [
|
||||
cryptsetup
|
||||
];
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue