//! A query said in a few words.
//!
//! The raw reading goes on the bus too, for a widget that wants to draw it
//! properly. This is for everything else: a wallpaper, a tooltip, a line in
//! a panel -- somewhere that has room for a sentence and no business knowing
//! what a GBFS dock is.
//!
//! Shapes come from apollos-types, the source of truth for the wire. A type
//! it does not describe, or a reading that does not parse, says nothing
//! rather than something wrong.
use apollos_types::{
AqiCondensed, CalendarCondensed, EphemerisCondensed, GbfsCondensed, GtfsCondensed,
TidalCondensed, WeatherCondensed,
};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Point {
pub text: String,
/// `#RRGGBB`, where the thing has a colour of its own -- a transit line.
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
impl Point {
fn plain(text: String) -> Point {
Point { text, color: None }
}
}
/// Departures beyond this many routes are a timetable, not a glance.
const MAX_ROUTES: usize = 6;
pub fn summarise(kind: &str, data: &Value) -> Vec<Point> {
match kind {
"gtfs" => parse(data).map(gtfs).unwrap_or_default(),
"gbfs" => parse(data).map(gbfs).unwrap_or_default(),
"weather" => parse(data).map(weather).unwrap_or_default(),
"tidal" => parse(data).map(tidal).unwrap_or_default(),
"aqi" => parse(data).map(aqi).unwrap_or_default(),
"ephem" => parse(data).map(ephem).unwrap_or_default(),
"calendar" => parse(data).map(calendar).unwrap_or_default(),
_ => vec![],
}
}
fn parse<T: DeserializeOwned>(data: &Value) -> Option<Vec<T>> {
serde_json::from_value(data.clone()).ok()
}
/// `08:05:00` as `08:05`; GTFS's after-midnight `25:10:00` as `01:10`.
fn clock(t: &str) -> String {
let mut parts = t.split(':');
match (
parts.next().and_then(|h| h.parse::<u32>().ok()),
parts.next(),
) {
(Some(h), Some(m)) => format!("{:02}:{m}", h % 24),
_ => t.to_string(),
}
}
/// Realtime where the feed gave it, the timetable where it did not --
/// paired by position, so one missing estimate does not shift the rest.
fn departures(r: &GtfsCondensed) -> Vec<String> {
r.times
.iter()
.enumerate()
.map(|(i, scheduled)| {
r.times_live
.as_ref()
.and_then(|live| live.get(i).cloned().flatten())
.unwrap_or_else(|| scheduled.clone())
})
.collect()
}
fn gtfs(mut routes: Vec<GtfsCondensed>) -> Vec<Point> {
// Soonest first: the feed's order says nothing about which bus is next.
// Sorted on the clock as sent, where 25:10 rightly follows 23:50.
routes.sort_by_key(|r| {
departures(r)
.first()
.cloned()
.unwrap_or_else(|| "99".into())
});
routes
.iter()
.filter(|r| !r.times.is_empty())
.take(MAX_ROUTES)
.map(|r| {
let times: Vec<String> = departures(r).iter().take(2).map(|t| clock(t)).collect();
let name = r.route_name.as_deref().unwrap_or(&r.route);
Point {
text: format!("{name} {} · {}", r.dest, times.join(" ")),
color: r.color.clone(),
}
})
.collect()
}
fn gbfs(entries: Vec<GbfsCondensed>) -> Vec<Point> {
let (loose, docks): (Vec<_>, Vec<_>) = entries
.into_iter()
.partition(|e| e.kind.as_deref() == Some("free_bike"));
let mut points: Vec<Point> = docks
.iter()
.map(|d| {
let bikes = if d.avail_elec > 0 {
format!("{} bikes ({} e)", d.avail, d.avail_elec)
} else {
format!("{} bikes", d.avail)
};
Point::plain(format!("{}: {bikes} · {} docks", d.name, d.docks_avail))
})
.collect();
if !loose.is_empty() {
points.push(Point::plain(format!("{} free nearby", loose.len())));
}
points
}
fn weather(entries: Vec<WeatherCondensed>) -> Vec<Point> {
entries
.iter()
.map(|w| {
let unit = match w.units.as_str() {
"metric" => "°C",
"imperial" => "°F",
_ => "°",
};
Point::plain(format!(
"{}: {}{unit} {}",
w.name,
w.temp.round(),
w.weather
))
})
.collect()
}
fn tidal(entries: Vec<TidalCondensed>) -> Vec<Point> {
entries
.iter()
.filter_map(|t| {
let mut parts = vec![];
if let Some(h) = &t.first_h {
parts.push(format!("High {h}"));
}
if let Some(l) = &t.first_l {
parts.push(format!("Low {l}"));
}
(!parts.is_empty()).then(|| Point::plain(parts.join(" · ")))
})
.collect()
}
fn aqi(entries: Vec<AqiCondensed>) -> Vec<Point> {
entries
.iter()
.filter_map(|a| {
// Whatever the monitor measured; nothing is assumed present.
let mut readings: Vec<String> = a
.measurements
.iter()
.filter_map(|(k, v)| match v {
Value::Number(n) => Some(format!("{k} {n}")),
Value::String(s) => Some(format!("{k} {s}")),
_ => None,
})
.collect();
readings.sort();
if readings.is_empty() {
return None;
}
let text = readings.join(" · ");
Some(Point::plain(match &a.name {
Some(n) => format!("{n}: {text}"),
None => text,
}))
})
.collect()
}
fn ephem(entries: Vec<EphemerisCondensed>) -> Vec<Point> {
entries
.iter()
.map(|e| {
// In the order the day happens, not the order the map keeps.
let mut periods: Vec<(&String, &String)> = e.periods.iter().collect();
periods.sort_by(|a, b| a.1.cmp(b.1));
let text: Vec<String> = periods.iter().map(|(k, v)| format!("{k} {v}")).collect();
Point::plain(format!("{}: {}", e.name, text.join(" · ")))
})
.collect()
}
fn calendar(mut events: Vec<CalendarCondensed>) -> Vec<Point> {
events.sort_by(|a, b| a.date_start.cmp(&b.date_start));
events
.iter()
.map(|e| Point::plain(format!("{} · {}", e.description, e.date_start)))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn transit_is_soonest_first_and_prefers_the_live_time() {
let data = json!([
{"route": "87", "dest": "Arlington", "dir": "In", "mode": "Bus",
"times": ["08:30:00"]},
{"route": "14", "route_name": "14", "dest": "Heath St", "dir": "In", "mode": "Bus",
"times": ["08:00:00", "08:20:00"], "times_live": ["08:03:00", null],
"color": "#FFC72C"}
]);
let p = summarise("gtfs", &data);
assert_eq!(p[0].text, "14 Heath St · 08:03 08:20");
assert_eq!(p[0].color.as_deref(), Some("#FFC72C"));
assert_eq!(p[1].text, "87 Arlington · 08:30");
}
#[test]
fn after_midnight_reads_as_a_clock() {
assert_eq!(clock("25:10:00"), "01:10");
}
#[test]
fn a_dock_and_the_loose_bikes_around_it() {
let data = json!([
{"name": "Union Sq", "id": "1", "avail": 5, "avail_elec": 2, "docks_avail": 7,
"ebikes_info": []},
{"name": "bike", "id": "b1", "kind": "free_bike", "ebikes_info": []},
{"name": "bike", "id": "b2", "kind": "free_bike", "ebikes_info": []}
]);
let p: Vec<String> = summarise("gbfs", &data)
.into_iter()
.map(|p| p.text)
.collect();
assert_eq!(
p,
vec!["Union Sq: 5 bikes (2 e) · 7 docks", "2 free nearby"]
);
}
#[test]
fn weather_carries_its_units() {
let data = json!([{"name": "Home", "weather": "light rain", "temp": 11.6, "feel": 10.0,
"hum": 80.0, "pressure": 1012.0, "wind": {"speed": 3.0, "deg": 90.0}, "units": "metric"}]);
assert_eq!(summarise("weather", &data)[0].text, "Home: 12°C light rain");
}
#[test]
fn what_does_not_parse_says_nothing() {
assert!(summarise("gtfs", &json!({"not": "a list"})).is_empty());
assert!(summarise("bourse", &json!([1, 2])).is_empty());
}
}
neiam
/apollos-dbus
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
8.5 KiB · text
History
5fae34f