From 88d10af3f555db6969ff21f0a662481db2fc8cde Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Fri, 25 Jul 2025 18:16:34 +0100 Subject: [PATCH] New travel type: coach Fixes #149 --- agenda/trip.py | 96 +++++++++++++++++++++++++++++++--------- agenda/types.py | 19 ++++++++ static/js/map.js | 2 +- templates/macros.html | 41 +++++++++++++++++ templates/trip_page.html | 25 ++++++++++- 5 files changed, 158 insertions(+), 25 deletions(-) diff --git a/agenda/trip.py b/agenda/trip.py index b1cf69f..978434d 100644 --- a/agenda/trip.py +++ b/agenda/trip.py @@ -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 diff --git a/agenda/types.py b/agenda/types.py index 67a9545..94830fd 100644 --- a/agenda/types.py +++ b/agenda/types.py @@ -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)) diff --git a/static/js/map.js b/static/js/map.js index 8ae88a6..73ca7d1 100644 --- a/static/js/map.js +++ b/static/js/map.js @@ -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), { diff --git a/templates/macros.html b/templates/macros.html index ff49986..5335d62 100644 --- a/templates/macros.html +++ b/templates/macros.html @@ -238,6 +238,47 @@ https://www.flightradar24.com/data/flights/{{ flight.airline_detail.iata | lower {% endmacro %} +{% macro coach_row(item) %} + {% set url = item.url %} +
{{ item.depart.strftime("%a, %d %b %Y") }}
+
+ {% if g.user.is_authenticated and item.url %}{% endif %} + {{ item.from }} → {{ item.to }} + {% if g.user.is_authenticated and item.url %}{% endif %} +
+
{{ item.depart.strftime("%H:%M") }}
+
+ {% if item.arrive %} + {{ item.arrive.strftime("%H:%M") }} + {% if item.depart != item.arrive and item.arrive.date() != item.depart.date() %}+1 day{% endif %} + {% endif %} +
+
{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins
+
{{ item.operator }}
+
+ {% if g.user.is_authenticated %} + {{ item.booking_reference }} + {% else %} + redacted + {% endif %} +
+
+
+
+ {% if item.distance %} + {{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }} + {% endif %} +
+
+ {% if g.user.is_authenticated and item.price and item.currency %} + {{ "{:,f}".format(item.price) }} {{ item.currency }} + {% if item.currency != "GBP" and item.currency in fx_rate %} + {{ "{:,.2f}".format(item.price / fx_rate[item.currency]) }} GBP + {% endif %} + {% endif %} +
+{% endmacro %} + {% macro ferry_row(item) %}
{{ item.depart.strftime("%a, %d %b %Y") }}
diff --git a/templates/trip_page.html b/templates/trip_page.html index 7056efc..531c0ba 100644 --- a/templates/trip_page.html +++ b/templates/trip_page.html @@ -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() %}

@@ -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 @@ {% endif %}

+ {% elif item.type == "coach" %} +
+ {{ display_datetime(item.depart) }} + → + {{ item.arrive.strftime("%H:%M %z") }} + {% if item.class %} + {{ item.class }} + {% endif %} + 🕒{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins + {% if item.distance %} + + 🛤️ + {{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }} + + {% endif %} +
{% elif item.type == "ferry" %}
{{ item.operator }} - {{ item.ferry }}