init
This commit is contained in:
commit
3d3754b116
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
2550
Cargo.lock
generated
Normal file
2550
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "unisuedzeiger"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.39", features = ["serde"] }
|
||||
html-escape = "0.2.13"
|
||||
reqwest = "0.12.9"
|
||||
rocket = "0.5.1"
|
||||
serde = { version = "1.0.215", features = ["derive"] }
|
||||
serde_json = "1.0.133"
|
||||
tokio = { version = "1.42.0", features = ["sync"] }
|
357
src/api.rs
Normal file
357
src/api.rs
Normal file
@ -0,0 +1,357 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use chrono::DateTime;
|
||||
use reqwest::get;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct ApiClient {
|
||||
pub swu_stop_number: u32,
|
||||
pub swu_api_limit: u8,
|
||||
pub departures: Vec<Departure>,
|
||||
pub directions: HashMap<String, HashMap<String, Option<(Instant, i32, u8)>>>,
|
||||
pub directions_last_save: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new() -> Self {
|
||||
Self::new_custom(1240)
|
||||
}
|
||||
pub fn new_custom(swu_stop_number: u32) -> Self {
|
||||
Self::new_custom_with_limit(swu_stop_number, 30)
|
||||
}
|
||||
pub fn new_custom_with_limit(swu_stop_number: u32, swu_api_limit: u8) -> Self {
|
||||
Self {
|
||||
swu_stop_number,
|
||||
swu_api_limit,
|
||||
departures: vec![],
|
||||
directions: HashMap::new(),
|
||||
directions_last_save: None,
|
||||
}
|
||||
}
|
||||
pub async fn api_get(&mut self) -> Result<(), String> {
|
||||
// get departures
|
||||
let departures = self.api_get_departures().await?;
|
||||
|
||||
let mut should_save = false;
|
||||
|
||||
// fetch trip data (-> direction) for vehicles which have departed and cache it for next vehicles
|
||||
for departure in self.departures.iter() {
|
||||
if let Some(route_name) = &departure.route_name {
|
||||
if let Some(departure_direction_text) = &departure.departure_direction_text {
|
||||
if let Some(vehicle_number) = departure.vehicle_number {
|
||||
if departures.iter().all(|dep| {
|
||||
dep.route_name.as_ref().is_none_or(|v| v != route_name)
|
||||
|| dep.vehicle_number.is_none_or(|v| v != vehicle_number)
|
||||
|| dep
|
||||
.departure_direction_text
|
||||
.as_ref()
|
||||
.is_none_or(|v| v != departure_direction_text)
|
||||
}) {
|
||||
// vehicle has just departed, check direction via api
|
||||
let dir_value = self
|
||||
.directions
|
||||
.entry(route_name.clone())
|
||||
.or_insert_with(HashMap::new)
|
||||
.entry(departure_direction_text.clone())
|
||||
.or_insert(None);
|
||||
if dir_value.is_none_or(|(when, _, success_rate)| {
|
||||
when.elapsed().as_secs()
|
||||
>= (success_rate * 15).saturating_sub(5) as u64
|
||||
}) {
|
||||
match Self::api_get_trip(vehicle_number).await {
|
||||
Ok(res) => {
|
||||
if res.route_number == departure.route_number
|
||||
&& res
|
||||
.departure_direction_text
|
||||
.as_ref()
|
||||
.is_some_and(|ddt| ddt == departure_direction_text)
|
||||
{
|
||||
if let Some(dir) = res.direction {
|
||||
if let Some((when, prev, success_rate)) = dir_value
|
||||
{
|
||||
*when = Instant::now();
|
||||
should_save = true;
|
||||
if dir == *prev {
|
||||
*success_rate =
|
||||
success_rate.saturating_add(1).min(60);
|
||||
eprintln!("[CACHE/dirs] {route_name} -> {departure_direction_text} => d{dir} (confirmed, success now {})", *success_rate);
|
||||
} else {
|
||||
if *success_rate <= 2 {
|
||||
eprintln!("[CACHE/dirs] {route_name} -> {departure_direction_text} => d{dir} (changed from {})", *prev);
|
||||
*prev = dir;
|
||||
*success_rate = 1;
|
||||
} else {
|
||||
*success_rate /= 2;
|
||||
eprintln!("[CACHE/dirs] {route_name} -> {departure_direction_text} => d{dir} (contradicted, success now {})", *success_rate);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!("[CACHE/dirs] {route_name} -> {departure_direction_text} => d{dir} (new info)");
|
||||
*dir_value = Some((Instant::now(), dir, 1));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ignore cuz is wrong route or wrong direction
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_save
|
||||
&& self
|
||||
.directions_last_save
|
||||
.is_none_or(|t| t.elapsed() > Duration::from_secs(60 * 60))
|
||||
{
|
||||
if let Err(e) = self.directions_save().await {
|
||||
eprintln!("Couldn't save directions data: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
self.departures = departures;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn api_get_departures(&self) -> Result<Vec<Departure>, String> {
|
||||
match get(format!(
|
||||
"https://api.swu.de/mobility/v1/stop/passage/Departures?StopNumber={}&Limit={}",
|
||||
self.swu_stop_number, self.swu_api_limit
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(response) => match response.text().await {
|
||||
Ok(response) => match serde_json::from_str::<ResDepartures>(&response) {
|
||||
Ok(response) => Ok(response.stop_passage.departure_data),
|
||||
Err(e) => Err(format!("{}\n{}", e, response)),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_get_trip(vehicle_number: u32) -> Result<JourneyData, String> {
|
||||
match get(format!(
|
||||
"https://api.swu.de/mobility/v1/vehicle/trip/Trip?VehicleNumber={vehicle_number}"
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(response) => match response.text().await {
|
||||
Ok(response) => match serde_json::from_str::<ResTrip>(&response) {
|
||||
Ok(trip) => Ok(trip.vehicle_trip.trip_data.journey_data),
|
||||
Err(e) => Err(format!("{}\n{}", e, response)),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn bahnhof_get_departures() -> Result<String, String> {
|
||||
match get(
|
||||
"https://www.bahnhof.de/api/boards/departures?evaNumbers=8000170&filterTransports=HIGH_SPEED_TRAIN&filterTransports=INTERCITY_TRAIN&filterTransports=INTER_REGIONAL_TRAIN&filterTransports=REGIONAL_TRAIN&filterTransports=CITY_TRAIN&filterTransports=UNKNOWN&duration=120&locale=de",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => match response.text().await {
|
||||
Ok(response) => match serde_json::from_str::<BahnhofResDepartures>(&response) {
|
||||
Ok(response) => {
|
||||
let mut o = format!("<div class=\"dbflex\" style=\"display:flex;flex-wrap:wrap;padding:1%;\">\n");
|
||||
for departure in response.entries.into_iter().flat_map(|v| v.into_iter()) {
|
||||
if let Some(line_name) = &departure.line_name {
|
||||
o.push_str(if departure.canceled {
|
||||
r#"<div style="text-decoration: line-through wavy DarkRed; color:gray;">"#
|
||||
} else {
|
||||
r#"<div>"#
|
||||
});
|
||||
o.push_str("<b>");
|
||||
html_escape::encode_safe_to_string(line_name, &mut o);
|
||||
o.push_str("</b>");
|
||||
if let Some(departure_time) =
|
||||
departure.time_delayed.or(departure.time_schedule)
|
||||
{
|
||||
o.push_str(" ");
|
||||
o.push_str(
|
||||
&departure_time.naive_local().format("%H:%M").to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(destination) =
|
||||
departure.destination.as_ref().and_then(|v| v.name.as_ref())
|
||||
{
|
||||
o.push_str("<br>");
|
||||
html_escape::encode_safe_to_string(destination, &mut o);
|
||||
}
|
||||
o.push_str("</div>\n");
|
||||
}
|
||||
}
|
||||
o.push_str("</div>");
|
||||
Ok(o)
|
||||
}
|
||||
Err(e) => Err(format!("{e}\n{response}")),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub async fn directions_save(&mut self) -> Result<(), String> {
|
||||
let file_name = format!("unisuedzeiger_directions_{}.txt", self.swu_stop_number);
|
||||
let mut out = String::new();
|
||||
for (line, directions) in &self.directions {
|
||||
out.push_str(&format!("[{line}]\n"));
|
||||
for (direction_text, value) in directions {
|
||||
if let Some((_, direction_number, success_rate)) = value {
|
||||
out.push_str(&format!(
|
||||
"> {direction_text}={direction_number}/{success_rate}\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::fs::write(&file_name, out)
|
||||
.await
|
||||
.map_err(|e| format!("Couldn't save file {file_name}: {e}"))?;
|
||||
self.directions_last_save = Some(Instant::now());
|
||||
Ok(())
|
||||
}
|
||||
pub async fn directions_load(&mut self) -> Result<(), String> {
|
||||
let prev_directions = std::mem::replace(&mut self.directions, HashMap::new());
|
||||
match self.directions_load_impl().await {
|
||||
Ok(v) => {
|
||||
dbg!(&self.directions);
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
self.directions = prev_directions;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
pub async fn directions_load_impl(&mut self) -> Result<(), String> {
|
||||
let now = Instant::now();
|
||||
self.directions_last_save = Some(now);
|
||||
let file_name = format!("unisuedzeiger_directions_{}.txt", self.swu_stop_number);
|
||||
let text = tokio::fs::read_to_string(&file_name)
|
||||
.await
|
||||
.map_err(|e| format!("Couldn't read file {file_name}: {e}"))?;
|
||||
let mut line_name = String::new();
|
||||
for line in text.lines() {
|
||||
if line.starts_with('[') && line.ends_with(']') {
|
||||
line_name = line[1..line.len() - 1].to_owned();
|
||||
if !self.directions.contains_key(&line_name) {
|
||||
self.directions.insert(line_name.to_owned(), HashMap::new());
|
||||
}
|
||||
} else if line.starts_with("> ") {
|
||||
if let Some((direction_text, data_text)) = line[2..].rsplit_once('=') {
|
||||
if let Some((direction_number, success_rate)) = data_text.split_once('/') {
|
||||
if let (Some(direction_number), Some(success_rate)) =
|
||||
(direction_number.parse().ok(), success_rate.parse().ok())
|
||||
{
|
||||
let line_map = self.directions.get_mut(&line_name).unwrap();
|
||||
if line_map
|
||||
.insert(
|
||||
direction_text.to_owned(),
|
||||
Some((now, direction_number, success_rate)),
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
return Err(format!(
|
||||
"Duplicate direction texts for line {line_name}: {direction_text}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResDepartures {
|
||||
#[serde(rename = "StopPassage")]
|
||||
pub stop_passage: ResStopPassage,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ResStopPassage {
|
||||
#[serde(rename = "DepartureData")]
|
||||
pub departure_data: Vec<Departure>,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Departure {
|
||||
#[serde(default, rename = "RouteName")]
|
||||
pub route_name: Option<String>,
|
||||
#[serde(default, rename = "RouteNumber")]
|
||||
pub route_number: Option<i32>,
|
||||
#[serde(default, rename = "DepartureDirectionText")]
|
||||
pub departure_direction_text: Option<String>,
|
||||
#[serde(default, rename = "VehicleNumber")]
|
||||
pub vehicle_number: Option<u32>,
|
||||
// #[serde(default, rename = "DepartureCountdown")]
|
||||
// pub departure_countdown: Option<isize>,
|
||||
#[serde(default, rename = "DepartureTimeActual")]
|
||||
pub departure_time_actual: Option<DateTime<chrono::Local>>,
|
||||
#[serde(default, rename = "DepartureTimeScheduled")]
|
||||
pub departure_time_scheduled: Option<DateTime<chrono::Local>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResTrip {
|
||||
#[serde(rename = "VehicleTrip")]
|
||||
pub vehicle_trip: ResVehicleTrip,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ResVehicleTrip {
|
||||
#[serde(rename = "TripData")]
|
||||
trip_data: ResTripData,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ResTripData {
|
||||
#[serde(rename = "JourneyData")]
|
||||
journey_data: JourneyData,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct JourneyData {
|
||||
#[serde(default, rename = "RouteNumber")]
|
||||
pub route_number: Option<i32>,
|
||||
#[serde(default, rename = "DepartureDirectionText")]
|
||||
pub departure_direction_text: Option<String>,
|
||||
#[serde(default, rename = "Direction")]
|
||||
pub direction: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BahnhofResDepartures {
|
||||
entries: Vec<Vec<BahnhofResDeparture>>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct BahnhofResDeparture {
|
||||
#[serde(default, rename = "timeSchedule")]
|
||||
time_schedule: Option<DateTime<chrono::Local>>,
|
||||
#[serde(default, rename = "timeDelayed")]
|
||||
time_delayed: Option<DateTime<chrono::Local>>,
|
||||
#[serde(default, rename = "canceled")]
|
||||
canceled: bool,
|
||||
// #[serde(default, rename = "platform")]
|
||||
// platform: Option<String>,
|
||||
#[serde(default, rename = "lineName")]
|
||||
line_name: Option<String>,
|
||||
#[serde(default, rename = "destination")]
|
||||
destination: Option<BahnhofResDepartureDestination>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct BahnhofResDepartureDestination {
|
||||
#[serde(default, rename = "name")]
|
||||
name: Option<String>,
|
||||
}
|
128
src/cache.rs
Normal file
128
src/cache.rs
Normal file
@ -0,0 +1,128 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::DateTime;
|
||||
use tokio::{sync::Mutex, time::Instant};
|
||||
|
||||
use crate::api::{bahnhof_get_departures, ApiClient};
|
||||
|
||||
pub struct Cache {
|
||||
pub index: String,
|
||||
pub nojs: String,
|
||||
pub data: Mutex<Data>,
|
||||
pub api_client: Mutex<ApiClient>,
|
||||
pub api_next_update: Mutex<Instant>,
|
||||
pub bahnhof_html: Mutex<String>,
|
||||
pub bahnhof_next_update: Mutex<Instant>,
|
||||
}
|
||||
pub struct Data {
|
||||
pub seq: u8,
|
||||
pub departures: Vec<Departure>,
|
||||
// pub changes: [Vec<Change>; 10],
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct Departure {
|
||||
pub route_name: Option<String>,
|
||||
pub departure_direction_text: Option<String>,
|
||||
pub vehicle_number: Option<u32>,
|
||||
pub departure_time: Option<DateTime<chrono::Local>>,
|
||||
pub scheduled_time: Option<DateTime<chrono::Local>>,
|
||||
pub direction: Option<i32>,
|
||||
}
|
||||
impl Cache {
|
||||
pub fn new(index: String, nojs: String, swu_stop_number: Option<u32>) -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
index,
|
||||
nojs,
|
||||
data: Mutex::new(Data {
|
||||
seq: 0,
|
||||
departures: vec![],
|
||||
}),
|
||||
api_client: Mutex::new(if let Some(swu_stop_number) = swu_stop_number {
|
||||
ApiClient::new_custom(swu_stop_number)
|
||||
} else {
|
||||
ApiClient::new()
|
||||
}),
|
||||
api_next_update: Mutex::new(now),
|
||||
bahnhof_html: Mutex::new(String::new()),
|
||||
bahnhof_next_update: Mutex::new(now),
|
||||
}
|
||||
}
|
||||
pub async fn update_swu(&self) -> Duration {
|
||||
let mut next_update = self.api_next_update.lock().await;
|
||||
let now = Instant::now();
|
||||
if now >= *next_update {
|
||||
let delay = Duration::from_secs(14);
|
||||
let mut api = self.api_client.lock().await;
|
||||
match api.api_get().await {
|
||||
Ok(()) => {
|
||||
// TODO: find changes
|
||||
// increment sequence number thingy (ze counting boi)
|
||||
let mut data = self.data.lock().await;
|
||||
data.seq += 1;
|
||||
if data.seq >= 10 {
|
||||
data.seq = 0;
|
||||
}
|
||||
// store departures
|
||||
let departures = api
|
||||
.departures
|
||||
.iter()
|
||||
.map(|departure| {
|
||||
let direction = departure
|
||||
.route_name
|
||||
.as_ref()
|
||||
.and_then(|rn| {
|
||||
departure.departure_direction_text.as_ref().and_then(|dt| {
|
||||
api.directions
|
||||
.get(rn)
|
||||
.and_then(|v| v.get(dt).and_then(|v| v.as_ref()))
|
||||
})
|
||||
})
|
||||
.filter(|(_, _, success)| *success > 2)
|
||||
.map(|(_, dir, _)| *dir);
|
||||
Departure {
|
||||
route_name: departure.route_name.clone(),
|
||||
departure_direction_text: departure
|
||||
.departure_direction_text
|
||||
.clone(),
|
||||
vehicle_number: departure.vehicle_number,
|
||||
departure_time: departure
|
||||
.departure_time_actual
|
||||
.as_ref()
|
||||
.or(departure.departure_time_scheduled.as_ref())
|
||||
.cloned(),
|
||||
scheduled_time: departure.departure_time_scheduled.clone(),
|
||||
direction,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
data.departures = departures;
|
||||
}
|
||||
Err(e) => eprintln!("Couldn't get departures from swu: {e}"),
|
||||
}
|
||||
*next_update = Instant::now() + delay;
|
||||
delay
|
||||
} else {
|
||||
*next_update - now
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_bahn(&self) -> Duration {
|
||||
let mut next_update = self.bahnhof_next_update.lock().await;
|
||||
let now = Instant::now();
|
||||
if now >= *next_update {
|
||||
let delay = Duration::from_secs(59);
|
||||
// bahnhof api request
|
||||
match bahnhof_get_departures().await {
|
||||
Ok(v) => {
|
||||
*self.bahnhof_html.lock().await = v;
|
||||
}
|
||||
Err(e) => eprintln!("Couldn't get departures from bahnhof: {e}"),
|
||||
}
|
||||
*next_update = Instant::now() + delay;
|
||||
delay
|
||||
} else {
|
||||
*next_update - now
|
||||
}
|
||||
}
|
||||
}
|
BIN
src/favicon.ico
Normal file
BIN
src/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
221
src/index.html
Normal file
221
src/index.html
Normal file
@ -0,0 +1,221 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>ulm öpnv abfahrten</title>
|
||||
<style>
|
||||
.dbflex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dbflex > * {
|
||||
white-space: nowrap;
|
||||
color: silver;
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
margin-left: 0.7em;
|
||||
padding-left: 0.3em;
|
||||
margin-right: 1em;
|
||||
border-left: solid gray;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a id="nojslink" href="./nojs" style="position:fixed"><br>no javascript? use the nojs version</a>
|
||||
<div style="background:darkviolet; width: 100%; height:4px;" id="untilNextUpdateProgressBarBackground"><span style="display:block; background:purple; width:100%; height: 100%;" id="untilNextUpdateProgressBar"></span></div>
|
||||
<table style="width:99vw; height: 72vh; max-height: 72vh; display: block; overflow: hidden;" id="routesTable"></table>
|
||||
<div style="width:99vw; height: 24vh; max-height: 24vh; display:block; overflow: hidden;" id="bahnhofPart"></div>
|
||||
<script>
|
||||
// https://stackoverflow.com/questions/951021/what-is-the-javascript-version-of-sleep
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
setInterval(doRepeatedFast, 500);
|
||||
fetchUniAllRepeated();
|
||||
async function doRepeatedFast() {
|
||||
await rebuildDepartureElements();
|
||||
}
|
||||
|
||||
function getDirectionColor(direction) {
|
||||
switch (direction) {
|
||||
case 0: return "LightPink";
|
||||
case 1: return "LightSkyBlue";
|
||||
case 2: return "LightGreen";
|
||||
case 3: return "LightPink";
|
||||
default: return "Silver";
|
||||
}
|
||||
}
|
||||
|
||||
function getRouteColumn(route) {
|
||||
const desiredId = "routeColumn" + route;
|
||||
const column = document.getElementById(desiredId);
|
||||
if (column) {
|
||||
return column;
|
||||
} else {
|
||||
const newColumnWrapper = document.createElement("td");
|
||||
const newColumn = document.createElement("table");
|
||||
const columnHeaderRow = document.createElement("tr");
|
||||
const columnHeaderData = document.createElement("td");
|
||||
columnHeaderData.innerText = "Linie " + route;
|
||||
columnHeaderData.style.fontSize = "3em";
|
||||
columnHeaderData.style.textDecoration = "underline gray";
|
||||
columnHeaderRow.appendChild(columnHeaderData);
|
||||
newColumn.appendChild(columnHeaderRow);
|
||||
newColumn.id = desiredId;
|
||||
const routesTable = document.getElementById("routesTable");
|
||||
newColumnWrapper.appendChild(newColumn);
|
||||
var beforeWhich = null;
|
||||
for (const otherColumn of routesTable.children) {
|
||||
const otherId = otherColumn.firstChild.id;
|
||||
if (otherId.length > desiredId.length || (otherId.length == desiredId.length && otherId > desiredId)) {
|
||||
beforeWhich = otherColumn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
routesTable.insertBefore(newColumnWrapper, beforeWhich);
|
||||
let columnWidthPercent = (99 / routesTable.childElementCount) + "vw";
|
||||
for (const column of routesTable.children) {
|
||||
column.style.width = columnWidthPercent;
|
||||
}
|
||||
return newColumn;
|
||||
}
|
||||
}
|
||||
function getTripRow(route, vehicle, scheduledTime) {
|
||||
const desiredId = "tripRow" + route + "#" + vehicle + "#" + scheduledTime;
|
||||
const tripRow = document.getElementById(desiredId);
|
||||
if (tripRow) {
|
||||
return tripRow;
|
||||
} else {
|
||||
const newTripRow = document.createElement("tr");
|
||||
const column = getRouteColumn(route);
|
||||
let isFirst = true;
|
||||
let beforeWhich = null;
|
||||
for (row of column.children) {
|
||||
if (!isFirst) {
|
||||
let split = row.id.split("#");
|
||||
if (split[split.length - 1] > scheduledTime) {
|
||||
beforeWhich = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
isFirst = false;
|
||||
}
|
||||
newTripRow.id = desiredId;
|
||||
column.insertBefore(newTripRow, beforeWhich);
|
||||
return newTripRow;
|
||||
}
|
||||
}
|
||||
|
||||
var currentSeq = 0;
|
||||
var resUniAll = [];
|
||||
var secondsUntilNextUpdate = 0;
|
||||
|
||||
async function fetchUniAllRepeated() {
|
||||
while (true) {
|
||||
var doneFetching = false;
|
||||
while (!doneFetching) {
|
||||
try {
|
||||
await fetchUniAll();
|
||||
doneFetching = true;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
console.log("Retrying in 5s...");
|
||||
await sleep(5000);
|
||||
}
|
||||
}
|
||||
if (!(secondsUntilNextUpdate >= 1)) {
|
||||
secondsUntilNextUpdate = 1;
|
||||
}
|
||||
const updateBarSteps = secondsUntilNextUpdate * 50;
|
||||
const progressBarFg = document.getElementById("untilNextUpdateProgressBar");
|
||||
const progressBarBg = document.getElementById("untilNextUpdateProgressBarBackground");
|
||||
const tempColor = progressBarFg.style.background;
|
||||
progressBarFg.style.background = progressBarBg.style.background;
|
||||
progressBarBg.style.background = tempColor;
|
||||
let startTime = performance.now();
|
||||
while (true) {
|
||||
let elapsed = Math.min(1000, (performance.now() - startTime) / secondsUntilNextUpdate);
|
||||
document.getElementById("untilNextUpdateProgressBar").style.width = (elapsed / 10) + "%";
|
||||
if (elapsed >= 1000) {
|
||||
break;
|
||||
}
|
||||
await sleep(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUniAll() {
|
||||
const resUniAllText = await (await fetch("./uni/all")).text();
|
||||
const prevResUniAll = resUniAll;
|
||||
resUniAll = [];
|
||||
var resUniThis = {};
|
||||
const now = new Date();
|
||||
for (const departure of prevResUniAll) {
|
||||
departure.elem.remove();
|
||||
}
|
||||
for (const resUniAllLine of resUniAllText.split("\n")) {
|
||||
if (resUniAllLine.startsWith("#")) {
|
||||
const furtherSplitLine = resUniAllLine.substring(1).split("#");
|
||||
currentSeq = parseInt(furtherSplitLine[0]);
|
||||
secondsUntilNextUpdate = parseInt(furtherSplitLine[1]);
|
||||
} else if (resUniAllLine.startsWith("-")) {
|
||||
resUniThis.elem = document.createElement("td");
|
||||
getTripRow(resUniThis.route, resUniThis.vehicle, resUniThis.scheduledTime).replaceChildren(resUniThis.elem);
|
||||
resUniAll.push(resUniThis);
|
||||
resUniThis = {};
|
||||
} else if (resUniAllLine.startsWith("R")) {
|
||||
resUniThis.route = resUniAllLine.substring(1);
|
||||
} else if (resUniAllLine.startsWith("D")) {
|
||||
resUniThis.target = resUniAllLine.substring(1);
|
||||
} else if (resUniAllLine.startsWith("d")) {
|
||||
resUniThis.direction = parseInt(resUniAllLine.substring(1));
|
||||
} else if (resUniAllLine.startsWith("v")) {
|
||||
resUniThis.vehicle = parseInt(resUniAllLine.substring(1));
|
||||
} else if (resUniAllLine.startsWith("t")) {
|
||||
resUniThis.time = Date.parse(resUniAllLine.substring(1));
|
||||
} else if (resUniAllLine.startsWith("s")) {
|
||||
resUniThis.scheduledTime = Date.parse(resUniAllLine.substring(1));
|
||||
} else {
|
||||
resUniThis[resUniAllLine.substring(0, 1)] = resUniAllLine.substring(1);
|
||||
}
|
||||
}
|
||||
for (const column of document.getElementById("routesTable").firstChild.children) {
|
||||
if (column.childElementCount <= 1) {
|
||||
column.remove();
|
||||
let columnWidthPercent = (99 / routesTable.childElementCount) + "vw";
|
||||
for (const column of routesTable.children) {
|
||||
column.style.width = columnWidthPercent;
|
||||
}
|
||||
}
|
||||
}
|
||||
let nojslink = document.getElementById("nojslink");
|
||||
if (nojslink) {
|
||||
nojslink.remove();
|
||||
}
|
||||
await rebuildDepartureElements();
|
||||
await updateBahnhofAll();
|
||||
}
|
||||
|
||||
async function updateBahnhofAll() {
|
||||
document.getElementById("bahnhofPart").innerHTML = await (await fetch("./bf/all")).text();
|
||||
}
|
||||
|
||||
async function rebuildDepartureElements() {
|
||||
const now = new Date();
|
||||
for (resUniThis of resUniAll) {
|
||||
const remainingSecondsTotal = Math.round((resUniThis.time - now) / 1000);
|
||||
const remainingSeconds = remainingSecondsTotal % 60;
|
||||
const remainingMinutes = Math.round((remainingSecondsTotal - remainingSeconds) / 60);
|
||||
resUniThis.elem.innerHTML = "<span></span><span style=\"color:gray;\"> | </span><span></span>";
|
||||
resUniThis.elem.children[0].innerText = resUniThis.target;
|
||||
resUniThis.elem.children[0].style.color = getDirectionColor(resUniThis.direction);
|
||||
resUniThis.elem.children[2].innerText = remainingSecondsTotal > 0 ? (remainingMinutes > 0 ? remainingMinutes + "m" : "") + (remainingMinutes < 5 ? (remainingSeconds < 10 ? "0" + remainingSeconds : remainingSeconds) + "s" : "") : "now";
|
||||
const timeScaled = Math.min(Math.max(remainingSecondsTotal, 0) / 900, 1);
|
||||
const timeScaledP = Math.sqrt(timeScaled);
|
||||
resUniThis.elem.children[2].style.color = "hsl(" + Math.round(100 * timeScaledP) + "," + Math.round(100 - 100 * timeScaledP) + "%,50%)";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
190
src/main.rs
Normal file
190
src/main.rs
Normal file
@ -0,0 +1,190 @@
|
||||
mod api;
|
||||
mod cache;
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc, time::Duration};
|
||||
|
||||
use cache::Cache;
|
||||
use rocket::{get, response::content::RawHtml, routes, State};
|
||||
|
||||
#[get("/")]
|
||||
fn index(cache: &State<Arc<Cache>>) -> RawHtml<&str> {
|
||||
RawHtml(&cache.index)
|
||||
}
|
||||
|
||||
#[get("/favicon.ico")]
|
||||
fn favicon() -> &'static [u8] {
|
||||
include_bytes!("favicon.ico")
|
||||
}
|
||||
|
||||
#[get("/uni/all")]
|
||||
async fn uni_all(cache: &State<Arc<Cache>>) -> RawHtml<String> {
|
||||
let seconds_until_next_update = cache.update_swu().await.as_secs() + 1;
|
||||
let data = cache.data.lock().await;
|
||||
let mut o = format!("#{}#{}", data.seq, seconds_until_next_update);
|
||||
for departure in data.departures.iter() {
|
||||
if let Some(route) = &departure.route_name {
|
||||
o.push_str("\nR");
|
||||
o.push_str(route);
|
||||
}
|
||||
if let Some(dir) = &departure.departure_direction_text {
|
||||
o.push_str("\nD");
|
||||
o.push_str(dir);
|
||||
}
|
||||
if let Some(dir) = departure.direction {
|
||||
o.push_str(&format!("\nd{dir}"));
|
||||
}
|
||||
if let Some(vehicle) = departure.vehicle_number {
|
||||
o.push_str(&format!("\nv{vehicle}"));
|
||||
}
|
||||
if let Some(time) = &departure.departure_time {
|
||||
o.push_str("\nt");
|
||||
o.push_str(&format!("{time}"));
|
||||
}
|
||||
if let Some(time) = &departure.scheduled_time {
|
||||
o.push_str("\ns");
|
||||
o.push_str(&format!("{time}"));
|
||||
}
|
||||
o.push_str("\n-");
|
||||
// v.route_name
|
||||
}
|
||||
RawHtml(o)
|
||||
}
|
||||
|
||||
#[get("/bf/all")]
|
||||
async fn bf_all(cache: &State<Arc<Cache>>) -> String {
|
||||
cache.update_bahn().await;
|
||||
cache.bahnhof_html.lock().await.clone()
|
||||
}
|
||||
|
||||
#[get("/nojs")]
|
||||
async fn nojs(cache: &State<Arc<Cache>>) -> RawHtml<String> {
|
||||
let mut html = cache.nojs.clone();
|
||||
html.push_str("<body>\n");
|
||||
cache.update_swu().await;
|
||||
let data = cache.data.lock().await;
|
||||
let mut routes = BTreeMap::<StrOrderedByLen, Vec<&'_ _>>::new();
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct StrOrderedByLen<'a>(&'a str);
|
||||
impl<'a> std::cmp::PartialOrd for StrOrderedByLen<'a> {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
impl<'a> std::cmp::Ord for StrOrderedByLen<'a> {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.0
|
||||
.len()
|
||||
.cmp(&other.0.len())
|
||||
.then_with(|| self.0.cmp(other.0))
|
||||
}
|
||||
}
|
||||
for departure in data.departures.iter() {
|
||||
if let Some(route_name) = &departure.route_name {
|
||||
if let Some(route) = routes.get_mut(&StrOrderedByLen(route_name.as_str())) {
|
||||
route.push(departure);
|
||||
} else {
|
||||
routes.insert(StrOrderedByLen(route_name.as_str()), vec![departure]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let now = chrono::Local::now().naive_local() + Duration::from_secs(10);
|
||||
for (route_name, departures) in routes {
|
||||
html.push_str("<h3>Linie ");
|
||||
html_escape::encode_safe_to_string(&route_name.0, &mut html);
|
||||
html.push_str("</h3>\n");
|
||||
for departure in departures {
|
||||
html.push_str(r#"<div class="dir"#);
|
||||
if let Some(direction) = departure.direction {
|
||||
html.push_str(&format!("{direction}"));
|
||||
} else {
|
||||
html.push_str("unknown");
|
||||
}
|
||||
html.push_str(r#""><span class="ddest">"#);
|
||||
html_escape::encode_safe_to_string(
|
||||
departure
|
||||
.departure_direction_text
|
||||
.as_ref()
|
||||
.map(|v| v.as_str())
|
||||
.unwrap_or("[?direction?]"),
|
||||
&mut html,
|
||||
);
|
||||
html.push_str(r#"</span><span class="dsep"> in </span><span class="dtime dtime"#);
|
||||
let time = departure
|
||||
.departure_time
|
||||
.or(departure.scheduled_time)
|
||||
.as_ref()
|
||||
.map(|v| {
|
||||
let time = v.naive_local();
|
||||
if time > now {
|
||||
let remaining_time = time.signed_duration_since(now);
|
||||
let mins = remaining_time.num_minutes();
|
||||
let secs = remaining_time.num_seconds() % 60;
|
||||
let soon = match (mins, secs) {
|
||||
(0, _) | (1, 0) => "1min",
|
||||
(1, _) | (2, 0) => "2min",
|
||||
(2..=4, _) | (5, 0) => "5min",
|
||||
(5..=9, _) | (10, 0) => "10min",
|
||||
(10..=14, _) | (15, 0) => "15min",
|
||||
(15.., _) => "future",
|
||||
(..=-1, _) => "now",
|
||||
};
|
||||
if mins > 0 {
|
||||
(soon, format!("{}m{}s ({})", mins, secs, time.time()))
|
||||
} else {
|
||||
(
|
||||
soon,
|
||||
format!("{}s ({})", remaining_time.num_seconds(), time.time()),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
("now", format!("now ({})", time.time()))
|
||||
}
|
||||
});
|
||||
html.push_str(time.as_ref().map(|v| v.0).unwrap_or("unknown"));
|
||||
html.push_str(r#"">"#);
|
||||
html_escape::encode_safe_to_string(
|
||||
time.map(|v| v.1).unwrap_or("[?time?]".to_owned()),
|
||||
&mut html,
|
||||
);
|
||||
html.push_str("</span></div>\n");
|
||||
}
|
||||
}
|
||||
drop(data);
|
||||
html.push_str("</body>\n");
|
||||
html.push_str("</html>\n");
|
||||
RawHtml(html)
|
||||
}
|
||||
|
||||
#[rocket::main]
|
||||
async fn main() -> Result<(), rocket::Error> {
|
||||
let cache = Arc::new(Cache::new(
|
||||
std::fs::read_to_string("index.html")
|
||||
.ok()
|
||||
.unwrap_or_else(|| include_str!("index.html").to_owned()),
|
||||
std::fs::read_to_string("nojs.html")
|
||||
.ok()
|
||||
.unwrap_or_else(|| include_str!("nojs.html").to_owned()),
|
||||
std::env::args().nth(1).map(|v| {
|
||||
v.parse()
|
||||
.expect("argument must be an integer (Haltestelle als SWU Stop-Number)")
|
||||
}),
|
||||
));
|
||||
if let Err(e) = cache.api_client.lock().await.directions_load().await {
|
||||
eprintln!("No direction data loaded: {e}");
|
||||
}
|
||||
let rocket = rocket::build()
|
||||
.manage(Arc::clone(&cache))
|
||||
.mount("/", routes![index, nojs, favicon, uni_all, bf_all])
|
||||
.ignite()
|
||||
.await?;
|
||||
// should not be necessary anymore
|
||||
// let _direction_updater_task = tokio::task::spawn(async move {
|
||||
// let start = Instant::now();
|
||||
// while start.elapsed().as_secs() < 120 * 60 {
|
||||
// tokio::time::sleep(Duration::from_secs(15)).await;
|
||||
// tokio::time::sleep(cache.update_swu().await).await;
|
||||
// }
|
||||
// });
|
||||
rocket.launch().await?;
|
||||
Ok(())
|
||||
}
|
53
src/nojs.html
Normal file
53
src/nojs.html
Normal file
@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta http-equiv="refresh" content="15">
|
||||
<title>ulm öpnv abfahrten</title>
|
||||
<style>
|
||||
.dsep {
|
||||
color: gray;
|
||||
}
|
||||
.dir0 > .ddest {
|
||||
color: LightPink;
|
||||
}
|
||||
.dir1 > .ddest {
|
||||
color: LightSkyBlue;
|
||||
}
|
||||
.dir2 > .ddest {
|
||||
color: LightGreen;
|
||||
}
|
||||
.dir3 > .ddest {
|
||||
color: LightPink;
|
||||
}
|
||||
.dirunknown {
|
||||
color: Silver;
|
||||
}
|
||||
.dtimenow {
|
||||
color: Red;
|
||||
}
|
||||
.dtime1min {
|
||||
color: OrangeRed;
|
||||
}
|
||||
.dtime2min {
|
||||
color: MediumVioletRed;
|
||||
}
|
||||
.dtime5min {
|
||||
color: MediumOrchid;
|
||||
}
|
||||
.dtime10min {
|
||||
color: MediumSlateBlue;
|
||||
}
|
||||
.dtime15min {
|
||||
color: Silver;
|
||||
}
|
||||
.dtimefuture {
|
||||
color: Gray;
|
||||
}
|
||||
.dtimeunknown {
|
||||
color: White;
|
||||
}
|
||||
</style>
|
||||
</head>
|
Loading…
Reference in New Issue
Block a user