diff --git a/agenda/conference.py b/agenda/conference.py index 89f43fe..1d90d96 100644 --- a/agenda/conference.py +++ b/agenda/conference.py @@ -94,7 +94,7 @@ def _require_date_value(value: typing.Any, field_name: str) -> DateOrDateTime: def _require_date_only(value: typing.Any, field_name: str) -> date: """Return field value as a date or raise ValueError.""" - return typing.cast(date, utils.as_date(_require_date_value(value, field_name))) + return utils.as_date(_require_date_value(value, field_name)) def conference_date_fields(item: StrDict) -> StrDict: diff --git a/agenda/trip.py b/agenda/trip.py index 0518b93..842e37c 100644 --- a/agenda/trip.py +++ b/agenda/trip.py @@ -2,6 +2,7 @@ import decimal import hashlib +import json import os import typing import unicodedata @@ -10,6 +11,7 @@ from datetime import date, datetime, timedelta, timezone import flask import pycountry import yaml +from geopy.distance import geodesic # type: ignore from agenda import conference, ical, travel, trip_schengen from agenda.types import StrDict, Trip, TripElement @@ -281,6 +283,156 @@ def load_buses( ) +def route_filename_without_extension(route: str) -> str: + """Return route filename without a GeoJSON extension.""" + return route.removesuffix(".geojson") + + +def line_strings_from_geojson(geojson_data: StrDict) -> list[list[list[float]]]: + """Extract LineString coordinate arrays from GeoJSON.""" + if geojson_data["type"] == "FeatureCollection": + features = typing.cast(list[StrDict], geojson_data["features"]) + return [ + line for feature in features for line in line_strings_from_geojson(feature) + ] + + if geojson_data["type"] == "Feature": + return line_strings_from_geojson(typing.cast(StrDict, geojson_data["geometry"])) + + if geojson_data["type"] == "LineString": + return [typing.cast(list[list[float]], geojson_data["coordinates"])] + + if geojson_data["type"] == "MultiLineString": + return typing.cast(list[list[list[float]]], geojson_data["coordinates"]) + + return [] + + +def geojson_lonlat_to_latlon(coord: list[float]) -> tuple[float, float]: + """Convert a GeoJSON lon/lat coordinate to a Leaflet lat/lon tuple.""" + return (coord[1], coord[0]) + + +def geojson_route_endpoints( + geojson_data: StrDict, +) -> tuple[tuple[float, float], tuple[float, float]] | None: + """Return the first and last points of a GeoJSON route.""" + lines = line_strings_from_geojson(geojson_data) + populated_lines = [line for line in lines if line] + if not populated_lines: + return None + return ( + geojson_lonlat_to_latlon(populated_lines[0][0]), + geojson_lonlat_to_latlon(populated_lines[-1][-1]), + ) + + +def geojson_route_distance_km(geojson_data: StrDict) -> float: + """Calculate the length of a GeoJSON route in kilometres.""" + total = 0.0 + for line in line_strings_from_geojson(geojson_data): + for i in range(len(line) - 1): + total += float( + geodesic( + geojson_lonlat_to_latlon(line[i]), + geojson_lonlat_to_latlon(line[i + 1]), + ).km + ) + return total + + +def car_endpoint_type(name: str) -> str: + """Choose a marker type for a car route endpoint.""" + return ( + "home" + if name.casefold() in {"home", "pch", "picture house court"} + else "car_stop" + ) + + +def car_route_labels(item: StrDict) -> tuple[str | None, str | None]: + """Return car route from/to labels from YAML or the route filename.""" + from_label = item.get("from") + to_label = item.get("to") + if isinstance(from_label, str) and isinstance(to_label, str): + return (from_label, to_label) + + route = item.get("route") + if not isinstance(route, str): + return ( + from_label if isinstance(from_label, str) else None, + to_label if isinstance(to_label, str) else None, + ) + + route_stem = os.path.basename(route_filename_without_extension(route)) + if "_to_" not in route_stem: + return ( + from_label if isinstance(from_label, str) else None, + to_label if isinstance(to_label, str) else None, + ) + + start, end = route_stem.split("_to_", 1) + return ( + from_label if isinstance(from_label, str) else start.replace("_", " "), + to_label if isinstance(to_label, str) else end.replace("_", " "), + ) + + +def car_endpoint_marker_type(item: StrDict, direction: str, label: str) -> str: + """Return the marker type for a car route endpoint.""" + marker_type = item.get(direction + "_type") + return marker_type if isinstance(marker_type, str) else car_endpoint_type(label) + + +def load_cars(data_dir: str) -> list[StrDict]: + """Load car journeys.""" + filename = os.path.join(data_dir, "car_journeys.yaml") + if not os.path.exists(filename): + return [] + + items = load_travel("car", "car_journeys", data_dir) + for item in items: + route = item.get("route") + if not isinstance(route, str): + continue + + route_filename = route_filename_without_extension(route) + item["geojson_filename"] = route_filename + + with open( + os.path.join(data_dir, "car_routes", route_filename + ".geojson") + ) as f: + geojson_data = typing.cast(StrDict, json.load(f)) + + if "distance" not in item: + item["distance"] = geojson_route_distance_km(geojson_data) + + endpoints = geojson_route_endpoints(geojson_data) + from_label, to_label = car_route_labels(item) + if endpoints is None: + continue + + for direction, field, label, endpoint in ( + ("from", "from_location", from_label, endpoints[0]), + ("to", "to_location", to_label, endpoints[1]), + ): + if not label: + continue + item[field] = { + "name": label, + "type": car_endpoint_marker_type(item, direction, label), + "latitude": endpoint[0], + "longitude": endpoint[1], + } + + if from_label: + item["from"] = from_label + if to_label: + item["to"] = to_label + + return items + + def process_flight( flight: StrDict, by_iata: dict[str, Airline], airports: list[StrDict] ) -> None: @@ -337,7 +489,8 @@ def collect_travel_items( + load_trains(data_dir, route_distances=route_distances) + load_ferries(data_dir, route_distances=route_distances) + load_coaches(data_dir, route_distances=route_distances) - + load_buses(data_dir, route_distances=route_distances), + + load_buses(data_dir, route_distances=route_distances) + + load_cars(data_dir), key=depart_datetime, ) @@ -465,6 +618,8 @@ def get_locations(trip: Trip) -> dict[str, StrDict]: "ferry_terminal": {}, "coach_station": {}, "bus_stop": {}, + "home": {}, + "car_stop": {}, } station_list = [] @@ -488,6 +643,13 @@ def get_locations(trip: Trip) -> dict[str, StrDict]: for field in "from_terminal", "to_terminal": terminal = t[field] locations["ferry_terminal"][terminal["name"]] = terminal + case "car": + for field in ("from_location", "to_location"): + location = t.get(field) + if not location: + continue + location_type = location.get("type", "car_stop") + locations[location_type][location["name"]] = location locations["station"] = process_station_list(station_list) return locations @@ -635,6 +797,27 @@ def get_trip_routes(trip: Trip, data_dir: str) -> list[StrDict]: } ) continue + if t["type"] == "car": + route = t.get("geojson_filename") + if not isinstance(route, str): + continue + route_filename = route_filename_without_extension(route) + key = "_".join( + [ + "car", + typing.cast(str, t.get("from", "")), + typing.cast(str, t.get("to", "")), + route_filename, + ] + ) + routes.append( + { + "type": "car", + "key": key, + "geojson_filename": os.path.join("car_routes", route_filename), + } + ) + continue if t["type"] == "train": for leg in t["legs"]: train_from, train_to = leg["from_station"], leg["to_station"] diff --git a/agenda/types.py b/agenda/types.py index 5edb66e..89d69b4 100644 --- a/agenda/types.py +++ b/agenda/types.py @@ -16,7 +16,6 @@ from agenda import format_list_with_ampersand from . import utils - StrDict = dict[str, typing.Any] DateOrDateTime = datetime.datetime | datetime.date @@ -46,6 +45,7 @@ class TripElement: "ferry": ":ferry:", "coach": ":bus:", "bus": ":bus:", + "car": ":automobile:", } alias = emoji_map.get(self.element_type) @@ -414,6 +414,25 @@ class Trip: end_country=to_country, ) ) + if item["type"] == "car": + start_location = item.get("from_location", {}) + end_location = item.get("to_location", {}) + from_country = agenda.get_country(start_location.get("country")) + to_country = agenda.get_country(end_location.get("country")) + name = f"{item.get('from', 'Car journey')} → {item.get('to', 'destination')}" + elements.append( + TripElement( + start_time=item["depart"], + end_time=item.get("arrive"), + title=name, + detail=item, + element_type="car", + start_loc=item.get("from"), + end_loc=item.get("to"), + start_country=from_country, + end_country=to_country, + ) + ) return sorted(elements, key=lambda e: utils.as_datetime(e.start_time)) diff --git a/docs/personal-data-yaml.md b/docs/personal-data-yaml.md index 08c391e..adccd2f 100644 --- a/docs/personal-data-yaml.md +++ b/docs/personal-data-yaml.md @@ -23,6 +23,7 @@ This document describes the YAML files read from `../personal-data/`. It is inte - `ferries.yaml` `from` and `to` values reference `ferry_terminals.yaml` `name`. - `buses.yaml` `from` and `to` values reference `bus_stops.yaml` `name`. - `coaches.yaml` `from` and `to` values reference `coach_stations.yaml` `name`. +- `car_journeys.yaml` `route` values name files in `car_routes/`. The `.geojson` extension is optional. - Station, stop, and terminal `routes` values name GeoJSON files without the `.geojson` extension. ## `accommodation.yaml` @@ -250,6 +251,41 @@ Example: base_fare: '55.00' ``` +## `car_journeys.yaml` + +Top-level shape: list of car journeys. + +Used by: trip loading, maps, trip timeline. Car journeys are for driving your own car or a rental car. + +Required fields: + +- `trip`: trip start date. +- `depart`: departure date or datetime. +- `arrive`: arrival date or datetime. +- `route`: GeoJSON filename in `car_routes/`, with or without the `.geojson` extension. + +Optional fields: + +- `from`, `to`: endpoint labels. If omitted and the route filename uses `A_to_B`, labels are inferred from the filename. +- `from_type`, `to_type`: marker type override. Use `home` to render a house icon. Otherwise car endpoints render as car markers. +- `operator`, `vehicle`, `price`, `currency`, `distance`. + +The route distance is calculated from the GeoJSON when `distance` is not present. If an inferred or explicit endpoint label is `home`, `PCH`, or `Picture House Court`, the marker is rendered as a house. + +Example: + +```yaml +- trip: 2026-07-16 + depart: 2026-07-16 + arrive: 2026-07-16 + route: PCH_to_EMF.geojson + +- trip: 2026-07-16 + depart: 2026-07-19 + arrive: 2026-07-19 + route: EMF_to_PCH.geojson +``` + ## `conferences.yaml` Top-level shape: list of conferences and conference-like events. diff --git a/static/js/map.js b/static/js/map.js index 7bd9a02..b47aa20 100644 --- a/static/js/map.js +++ b/static/js/map.js @@ -8,6 +8,8 @@ var emojiByType = { "ferry_terminal": "🚢", "coach_station": "🚌", "bus_stop": "🚏", + "home": "🏠", + "car_stop": "🚗", "accommodation": "🏨", "conference": "🖥️", "event": "🍷" @@ -197,7 +199,7 @@ function build_map(map_id, coordinates, routes) { // Draw routes routes.forEach(function(route) { - var color = {"train": "blue", "flight": "red", "unbooked_flight": "orange", "coach": "green", "bus": "purple"}[route.type]; + var color = {"train": "blue", "flight": "red", "unbooked_flight": "orange", "coach": "green", "bus": "purple", "car": "#555"}[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 71363b5..cb96e12 100644 --- a/templates/macros.html +++ b/templates/macros.html @@ -409,7 +409,7 @@ https://www.flightradar24.com/data/flights/{{ flight.airline_detail.iata | lower {% if item.operator and item.operator != item.name %}{{ item.operator }}{% endif %} {% elif e.element_type != "conference" %} - {# Transport: flight, train, ferry, coach, bus #} + {# Transport: flight, train, ferry, coach, bus, car #} {% set has_arrive = item.arrive is defined and item.arrive %} {# item.depart may be a date (no .date() method) or a datetime (has .date()) #} {% set has_time = item.depart is defined and item.depart and item.depart.hour is defined %} diff --git a/templates/trip_page.html b/templates/trip_page.html index 3360414..16e92ff 100644 --- a/templates/trip_page.html +++ b/templates/trip_page.html @@ -367,20 +367,25 @@ - {% elif e.element_type in ("coach", "bus") %} + {% elif e.element_type in ("coach", "bus", "car") %} {% set item = e.detail %}
- 🚌 {{ item.from }} → {{ item.to }} + {% if e.element_type == "car" %}🚗{% else %}🚌{% endif %} + {{ item.from }} → {{ item.to }} {% if item.operator %}{{ item.operator }}{% endif %}

- {{ item.depart.strftime("%H:%M") }} → {{ item.arrive.strftime("%H:%M") }} + {% if item.depart.hour is defined and item.arrive.hour is defined %} + {{ item.depart.strftime("%H:%M") }} → {{ item.arrive.strftime("%H:%M") }} + {% endif %} {% if item.class %} {{ item.class }} {% endif %} - 🕒{{ trip_duration(item.depart, item.arrive) }} + {% if item.depart.hour is defined and item.arrive.hour is defined %} + 🕒{{ trip_duration(item.depart, item.arrive) }} + {% endif %} {% if item.distance %} 🛤️ {{ "{:,.0f} km".format(item.distance) }} {% endif %} diff --git a/tests/test_trip.py b/tests/test_trip.py index 614d669..409c0a5 100644 --- a/tests/test_trip.py +++ b/tests/test_trip.py @@ -1,5 +1,7 @@ """Tests for trip map coordinate assembly.""" +import json +import pathlib from datetime import date import agenda.trip @@ -167,3 +169,72 @@ def test_get_trip_routes_assumes_unbooked_paris_trip_is_by_train( "geojson_filename": "train_routes/London_St_Pancras_to_Paris_Gare_du_Nord", } ] + + +def test_load_cars_infers_route_labels_and_home_marker(tmp_path: pathlib.Path) -> None: + """Car journeys should load direct GeoJSON routes and endpoint markers.""" + (tmp_path / "car_routes").mkdir() + (tmp_path / "car_journeys.yaml").write_text("""--- +- trip: 2026-07-16 + depart: 2026-07-16 + arrive: 2026-07-16 + route: PCH_to_EMF.geojson +""") + (tmp_path / "car_routes" / "PCH_to_EMF.geojson").write_text( + json.dumps( + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [[-2.60283, 51.44083], [-1.2, 52.0]], + }, + } + ) + ) + + cars = agenda.trip.load_cars(str(tmp_path)) + + assert len(cars) == 1 + assert cars[0]["type"] == "car" + assert cars[0]["from"] == "PCH" + assert cars[0]["to"] == "EMF" + assert cars[0]["geojson_filename"] == "PCH_to_EMF" + assert cars[0]["distance"] > 0 + assert cars[0]["from_location"] == { + "name": "PCH", + "type": "home", + "latitude": 51.44083, + "longitude": -2.60283, + } + assert cars[0]["to_location"] == { + "name": "EMF", + "type": "car_stop", + "latitude": 52.0, + "longitude": -1.2, + } + + +def test_get_trip_routes_includes_car_geojson() -> None: + """Car routes should render from the car_routes directory.""" + trip = Trip( + start=date(2026, 7, 16), + travel=[ + { + "type": "car", + "from": "PCH", + "to": "EMF", + "geojson_filename": "PCH_to_EMF", + } + ], + ) + + routes = agenda.trip.get_trip_routes(trip, "/tmp/personal-data") + + assert routes == [ + { + "type": "car", + "key": "car_PCH_EMF_PCH_to_EMF", + "geojson_filename": "car_routes/PCH_to_EMF", + } + ] diff --git a/validate_yaml.py b/validate_yaml.py index 9a41271..a24a5d5 100755 --- a/validate_yaml.py +++ b/validate_yaml.py @@ -38,7 +38,7 @@ def check_currency(item: agenda.types.StrDict) -> None: def check_country_code( - item: agenda.types.StrDict, source: str, required: bool = True + item: typing.Mapping[str, typing.Any], source: str, required: bool = True ) -> None: """Throw error if country code is missing or invalid.""" country = item.get("country") @@ -494,6 +494,109 @@ def check_buses() -> None: print(len(buses), "buses") +def car_route_path(route: str) -> str: + """Return the absolute path for a car route filename.""" + route_filename = agenda.trip.route_filename_without_extension(route) + if os.path.isabs(route_filename) or ".." in route_filename.split(os.path.sep): + raise ValueError(f"invalid car route filename {route!r}") + return os.path.join(data_dir, "car_routes", route_filename + ".geojson") + + +def validate_car_journey_routes(cars: list[agenda.types.StrDict]) -> None: + """Check every car journey route references an existing GeoJSON file.""" + for car in cars: + route = car.get("route") + if not isinstance(route, str) or not route: + pprint(car) + print("car journey missing route") + sys.exit(-1) + try: + route_path = car_route_path(route) + except ValueError as exc: + pprint(car) + print(exc) + sys.exit(-1) + if not os.path.exists(route_path): + pprint(car) + print(f"car route does not exist: {route_path}") + sys.exit(-1) + + +def validate_car_journey_dates( + cars: list[agenda.types.StrDict], trip_list: list[agenda.types.Trip] +) -> None: + """Check car journey dates fit the referenced trip.""" + trip_lookup = {trip.start: trip for trip in trip_list} + prev_depart = None + prev_car = None + + for car in cars: + for field in ("trip", "depart", "arrive"): + if field not in car: + pprint(car) + print(f"car journey missing {field}") + sys.exit(-1) + + current_depart = normalize_datetime(car["depart"]) + arrive = normalize_datetime(car["arrive"]) + + if prev_depart and current_depart < prev_depart: + assert prev_car is not None + print("Out of order car journey found:") + print( + f" Previous: {prev_car.get('depart')} {prev_car.get('from', '')} -> {prev_car.get('to', '')}" + ) + print( + f" Current: {car.get('depart')} {car.get('from', '')} -> {car.get('to', '')}" + ) + assert ( + False + ), "Car journeys are not in chronological order by departure time." + prev_depart = current_depart + prev_car = car + + if arrive < current_depart: + print("Invalid car journey found (arrive before depart):") + print(f" {car.get('depart')} {car.get('from', '')} -> {car.get('to', '')}") + print(f" arrive: {car.get('arrive')}") + assert False, "Car journey arrival time is earlier than departure time." + + trip_start = agenda.utils.as_date(car["trip"]) + trip = trip_lookup.get(trip_start) + if trip is None: + pprint(car) + print(f"car journey references unknown trip {trip_start}") + sys.exit(-1) + + trip_end = trip.end or trip.start + depart_date = agenda.utils.as_date(car["depart"]) + arrive_date = agenda.utils.as_date(car["arrive"]) + + if depart_date < trip.start or arrive_date > trip_end: + pprint(car) + print( + "car journey dates outside trip range: " + f"{depart_date} to {arrive_date}; trip {trip.start} to {trip_end}" + ) + sys.exit(-1) + + check_currency(car) + + +def check_car_journeys() -> None: + """Check car journeys, route files and trip date consistency.""" + filepath = os.path.join(data_dir, "car_journeys.yaml") + if not os.path.exists(filepath): + print("0 car journeys") + return + + cars = agenda.travel.parse_yaml("car_journeys", data_dir) + validate_car_journey_routes(cars) + trip_list = agenda.trip.build_trip_list(data_dir) + validate_car_journey_dates(cars, trip_list) + print(len(cars), "car journeys") + + def check_airlines() -> list[agenda.types.StrDict]: """Check airlines.""" airlines: list[agenda.types.StrDict] = agenda.travel.parse_yaml( @@ -521,6 +624,7 @@ def check_airlines() -> list[agenda.types.StrDict]: def check() -> None: """Validate personal data YAML files.""" airlines = check_airlines() + check_car_journeys() check_trips() check_flights({airline["iata"] for airline in airlines}) check_trains()