New travel type: coach

Fixes #149
This commit is contained in:
Edward Betts 2025-07-25 18:16:34 +01:00
parent 51139a1a70
commit 88d10af3f5
5 changed files with 158 additions and 25 deletions

View file

@ -90,6 +90,38 @@ def load_ferries(
return ferries
def load_coaches(
data_dir: str, route_distances: travel.RouteDistances | None = None
) -> list[StrDict]:
"""Load coaches."""
coaches = load_travel("coach", "coaches", data_dir)
stations = travel.parse_yaml("coach_stations", data_dir)
by_name = {station["name"]: station for station in stations}
for item in coaches:
add_station_objects(item, by_name)
# Add route distance and CO2 calculation if available
if route_distances:
travel.add_leg_route_distance(item, route_distances)
if "distance" in item:
# Calculate CO2 emissions for coach (0.027 kg CO2e per passenger per km)
item["co2_kg"] = item["distance"] * 0.027
# Include GeoJSON route if defined in stations data
from_station = item.get("from_station")
to_station = item.get("to_station")
if from_station and to_station:
# Support scalar or mapping routes: string or dict of station name -> geojson base name
routes_val = from_station.get("routes", {})
if isinstance(routes_val, str):
geo = routes_val
else:
geo = routes_val.get(to_station.get("name"))
if geo:
item["geojson_filename"] = geo
return coaches
def depart_datetime(item: StrDict) -> datetime:
"""Return a datetime for this travel item.
@ -156,7 +188,8 @@ def collect_travel_items(
return sorted(
load_flights(load_flight_bookings(data_dir))
+ load_trains(data_dir, route_distances=route_distances)
+ load_ferries(data_dir, route_distances=route_distances),
+ load_ferries(data_dir, route_distances=route_distances)
+ load_coaches(data_dir, route_distances=route_distances),
key=depart_datetime,
)
@ -266,6 +299,8 @@ def get_locations(trip: Trip) -> dict[str, StrDict]:
match t["type"]:
case "train":
station_list += stations_from_travel(t)
case "coach":
station_list += [t["from_station"], t["to_station"]]
case "flight":
for field in "from_airport", "to_airport":
if field in t:
@ -368,33 +403,50 @@ def get_trip_routes(trip: Trip, data_dir: str) -> list[StrDict]:
}
)
continue
assert t["type"] == "train"
for leg in t["legs"]:
train_from, train_to = leg["from_station"], leg["to_station"]
geojson_filename = train_from.get("routes", {}).get(train_to["name"])
key = "_".join(["train"] + sorted([train_from["name"], train_to["name"]]))
if not geojson_filename:
if t["type"] == "coach":
coach_from, coach_to = t["from_station"], t["to_station"]
key = "_".join(["coach"] + sorted([coach_from["name"], coach_to["name"]]))
# Use GeoJSON route when available, otherwise draw straight line
if t.get("geojson_filename"):
filename = os.path.join("coach_routes", t["geojson_filename"])
routes.append({"type": "coach", "key": key, "geojson_filename": filename})
else:
routes.append(
{
"type": "coach",
"key": key,
"from": latlon_tuple(coach_from),
"to": latlon_tuple(coach_to),
}
)
continue
if t["type"] == "train":
for leg in t["legs"]:
train_from, train_to = leg["from_station"], leg["to_station"]
geojson_filename = train_from.get("routes", {}).get(train_to["name"])
key = "_".join(["train"] + sorted([train_from["name"], train_to["name"]]))
if not geojson_filename:
routes.append(
{
"type": "train",
"key": key,
"from": latlon_tuple(train_from),
"to": latlon_tuple(train_to),
}
)
continue
if geojson_filename in seen_geojson:
continue
seen_geojson.add(geojson_filename)
routes.append(
{
"type": "train",
"key": key,
"from": latlon_tuple(train_from),
"to": latlon_tuple(train_to),
"geojson_filename": os.path.join("train_routes", geojson_filename),
}
)
continue
if geojson_filename in seen_geojson:
continue
seen_geojson.add(geojson_filename)
routes.append(
{
"type": "train",
"key": key,
"geojson_filename": os.path.join("train_routes", geojson_filename),
}
)
if routes:
return routes

View file

@ -42,6 +42,7 @@ class TripElement:
"train": ":train:",
"flight": ":airplane:",
"ferry": ":ferry:",
"coach": ":bus:",
}
alias = emoji_map.get(self.element_type)
@ -333,6 +334,24 @@ class Trip:
end_country=to_country,
)
)
if item["type"] == "coach":
# Include coach journeys in trip elements
from_country = agenda.get_country(item["from_station"]["country"])
to_country = agenda.get_country(item["to_station"]["country"])
name = f"{item['from']}{item['to']}"
elements.append(
TripElement(
start_time=item["depart"],
end_time=item.get("arrive"),
title=name,
detail=item,
element_type="coach",
start_loc=item["from"],
end_loc=item["to"],
start_country=from_country,
end_country=to_country,
)
)
return sorted(elements, key=lambda e: utils.as_datetime(e.start_time))

View file

@ -140,7 +140,7 @@ function build_map(map_id, coordinates, routes) {
// Draw routes
routes.forEach(function(route) {
var color = {"train": "blue", "flight": "red", "unbooked_flight": "orange"}[route.type];
var color = {"train": "blue", "flight": "red", "unbooked_flight": "orange", "coach": "green"}[route.type];
var style = { weight: 3, opacity: 0.5, color: color };
if (route.geojson) {
L.geoJSON(JSON.parse(route.geojson), {

View file

@ -238,6 +238,47 @@ https://www.flightradar24.com/data/flights/{{ flight.airline_detail.iata | lower
</div>
{% endmacro %}
{% macro coach_row(item) %}
{% set url = item.url %}
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item">
{% if g.user.is_authenticated and item.url %}<a href="{{ url }}">{% endif %}
{{ item.from }} &rarr; {{ item.to }}
{% if g.user.is_authenticated and item.url %}</a>{% endif %}
</div>
<div class="grid-item">{{ item.depart.strftime("%H:%M") }}</div>
<div class="grid-item">
{% if item.arrive %}
{{ item.arrive.strftime("%H:%M") }}
{% if item.depart != item.arrive and item.arrive.date() != item.depart.date() %}+1 day{% endif %}
{% endif %}
</div>
<div class="grid-item">{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins</div>
<div class="grid-item">{{ item.operator }}</div>
<div class="grid-item">
{% if g.user.is_authenticated %}
{{ item.booking_reference }}
{% else %}
<em>redacted</em>
{% endif %}
</div>
<div class="grid-item">
</div>
<div class="grid-item text-end">
{% if item.distance %}
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
{% endif %}
</div>
<div class="grid-item text-end">
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,f}".format(item.price) }} {{ item.currency }}</span>
{% if item.currency != "GBP" and item.currency in fx_rate %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(item.price / fx_rate[item.currency]) }} GBP</span>
{% endif %}
{% endif %}
</div>
{% endmacro %}
{% macro ferry_row(item) %}
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item">

View file

@ -2,9 +2,9 @@
{% block title %}{{ trip.title }} ({{ display_date(trip.start) }}) - Edward Betts{% endblock %}
{% from "macros.html" import trip_link, display_datetime, display_date_no_year, display_date, conference_row, accommodation_row, flight_row, train_row with context %}
{% from "macros.html" import trip_link, display_datetime, display_date_no_year, display_date, conference_row, accommodation_row, flight_row, train_row, ferry_row, coach_row with context %}
{% set row = { "flight": flight_row, "train": train_row } %}
{% set row = {"flight": flight_row, "train": train_row, "ferry": ferry_row, "coach": coach_row} %}
{% macro next_and_previous() %}
<p>
@ -271,6 +271,11 @@
{{ item.from }}
{{ item.to }}
{% elif item.type == "coach" %}
🚌
{{ item.from }}
{{ item.to }}
{% elif item.type == "ferry" %}
⛴️
{{ item.from }}
@ -317,6 +322,22 @@
</span>
{% endif %}
</div>
{% elif item.type == "coach" %}
<div>
{{ display_datetime(item.depart) }}
{{ item.arrive.strftime("%H:%M %z") }}
{% if item.class %}
<span class="badge bg-info text-nowrap">{{ item.class }}</span>
{% endif %}
<span>🕒{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins</span>
{% if item.distance %}
<span>
🛤️
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
</span>
{% endif %}
</div>
{% elif item.type == "ferry" %}
<div>
<span>{{ item.operator }} - {{ item.ferry }}</span>