From e0ade9e5aba1d57c203a60370a2072935bdef9e5 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Fri, 27 Feb 2026 18:18:12 +0000 Subject: [PATCH] Add web frontend and refactor core to use OsmError - Refactor core.py: replace sys.exit() calls with OsmError exceptions so the library is safe to use from Flask and other callers - Add fetch_sibling_routes and fetch_route_master_routes to core.py - Add Flask web frontend (web/app.py, templates, static assets): - Map view with Leaflet; full route drawn in grey on load - Sidebar stop list; active-slot UX for From/To selection - Segment preview and download (full route or selected segment) - Include-stops toggle applied client-side - Bookmarkable URLs: GET / - Clear selection button - Other directions panel (sibling routes from same route_master) - route_master handling: draws all member routes in colour on map with links to each individual direction - Add SVG favicon - Add py.typed marker; add .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 8 + .../pt/__pycache__/__init__.cpython-313.pyc | Bin 230 -> 0 bytes .../pt/__pycache__/cli.cpython-313.pyc | Bin 6105 -> 0 bytes .../pt/__pycache__/core.cpython-313.pyc | Bin 7526 -> 0 bytes src/osm_geojson/pt/cli.py | 32 +- src/osm_geojson/pt/core.py | 147 +++++- src/osm_geojson/py.typed | 0 tests/test_osm_pt_geojson.py | 5 +- web/app.py | 210 ++++++++ web/static/app.js | 467 ++++++++++++++++++ web/static/favicon.svg | 6 + web/static/style.css | 68 +++ web/templates/index.html | 126 +++++ 13 files changed, 1049 insertions(+), 20 deletions(-) create mode 100644 .gitignore delete mode 100644 src/osm_geojson/pt/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/osm_geojson/pt/__pycache__/cli.cpython-313.pyc delete mode 100644 src/osm_geojson/pt/__pycache__/core.cpython-313.pyc create mode 100644 src/osm_geojson/py.typed create mode 100644 web/app.py create mode 100644 web/static/app.js create mode 100644 web/static/favicon.svg create mode 100644 web/static/style.css create mode 100644 web/templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b66a79b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +build/ +dist/ +.venv/ +*.geojson diff --git a/src/osm_geojson/pt/__pycache__/__init__.cpython-313.pyc b/src/osm_geojson/pt/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 78cb62468b6289abd59c21dd9c559413e0892016..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 230 zcmYk0K?;IE6ozLiN(ApPTNgS}(JI=viJ${KOs6u`CvQfg?$H5SdL_4AAn*c>QqaG7 z|Ig$9_*>6&nUz~V(}lgSF8sv5rgf}&ustJo;{M diff --git a/src/osm_geojson/pt/__pycache__/cli.cpython-313.pyc b/src/osm_geojson/pt/__pycache__/cli.cpython-313.pyc deleted file mode 100644 index 2fb996dbb9b27d55f5f15e1769c411aea14c9b2b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6105 zcmcIoTW}NC89sZFw7T1t!N%sYfI}^eZ3;GEV+aH=V8BVe-o%64C_-AxBJ!^0>ndrnKrbGZk%SYQH&e?i-nQraa0qq|V~WWEx)b&>2x@hBiPZ@V1~9VW@*b| z4qEOEKj6TQ0Vj42xUg$Lz`}qVy9YejGvLKu5lQ^0LvoDzB(~j!kmQuuE~n&bLKk`L zALWKN+GJPSZK&HCNSh^rthC2CI2s(c<@Rig_*>u|UO~l?*qSisC zTh$UuREEM4~t8(f<|UCd`^XMb)!diN^GJDrGu}c2vV;F6SIsW32r^m@x%K zjcbXNnjFkIU&9*E9G+oJPQf_rG%6yb^7!Yjx+Q<@R9jx3 ze(e)~_gw9w&wuOWjgwQy-#hn&LxFvd1ph71P0!S;`7`-L#ZY+KSP;7Gk6jOh#wTuA z^}yf#jZQe5sXMxt`*^SCCB_wpVWofu%N9%+zKxPhn9c5j?#t3SF_XOjViFA;Q}v97 z4UsY|{^RHGS7}yDcMQ%TEW(~;_(ymXR3-;}<89C*Bb})8U4*wnlk=xjy3wv1)LV7a zTXEb}0`$onZ)KD?xs+XqZaDZ~{uO)$7pu(4$aVsiiI5evjMUh!kOh~@Y|orfoe{zt zEknd%R~2vxMs^OlSAB+|!~zY=v53pq^uWoUcOn-dLZ?OM?R^0V?RzsKug)?sbxoP31AZJi3jMjP)(w*1?1b3|H*>4ZSwe1ThCm} zLfhGSzIF1%RAb@z67Rk?aCKm+Az%Od#@{v;c1*V|Y}i+9++PeHxX<@grauVfM}D^{ zA1vTvaOZu#ZKFnXZ%`9ZScO_yC-Rd&q&#@)b-$B^QHp;+3EZ z(-j=~D|puHq1Wrzz!FClmb_y7B*Bu2km{VM&sj^TQs)d{qMa&S0AP%Z&_IR|_`_Y6;@*1pSdNDi*TIM$|c?uXW$?B^L8s<-#8 zw7`qk)`5s*?*-5&ND=PRfD|UuV6Oy9UP4Lok-9SSlacJMSV7HGJLuZVj`C>rj@k%8 zDq3Xsa6OzX$Tf1U%u9hDms%GAeOR+~GH2T(;4T*q=8jm7X6IQXi8jU+D^BZ5k|epJ z<-VeESrZAuCMqc1fp@^!m?(Sjsh~@^3c>&Kdr%!_Z=+!j)O85d#mLMs`;@u}MAI@1 zIW5^}i*v|w4QrY<_6tZx&zkKWvCIJo$&3yL)=~~Oke%dC579iFGD<_i>Eog{Mu!wH zCSvd&fQ*pxDz?UBy3(G4_@L@3BXvm$(;SrMnS)`L#sSlj)-Ed;5Qq#kZLzn zRwg((Sq6Fc7-XRoUd*zXqn3C(`qElFmevnlY74IV-_LGWO4JCuyNe`SKVenCe_dsR(Xsoebb@&MRBtW3GE=hWZW;!9U*Dyq3 z1yd~@C)-buil*=@N(0nHR&cIvjvTQ#2wEQ(xv0a0aUXgzi}bBWAfBz{`FJ21^>3m z{xAH&$^J!GaNgChCg{J)50-^`m$0%Jcir+#6l+ zpP%f16lhu$>ZisFj)E~ee)m#w- z^%sIjD^7#YkRdUB`o^oW^s<>#F_wQW!SJR)3R!s+Dll>18YWKD(JBL93N+>WX1nh- z&Ig7jPcHFwQ~PZ-0esTX5#C_BqS1sFk47 zU(mEPb!^HxeVDoaM_o6r*3v~n0nU@r+_`ahvAaSX$JhITzf-iOHZCGtJt1%`cs zpn6QI(t3f}kUv?1{)XA@bNoj;Y4>jZ*W^oSJz`zgny)thY|}E!F#DG|#0E>GR}v8G zxYqn`^Y!QgvuTlWUfXelg!tkpFc459j|lI@eX~SLT^9vW!fn3&&?)C?5XtJo9>qZDU#% zYwJrKv|rS%D>cfs&hyJSZ3{c}dTQoStPr^vG?a45^c}-b}h8 zsVqi(Wces#GcvlsG}+fJi^9Iu_r0)_X~`ceA!y(QW6U(s?wmoJd;Bt)SbZsamFC9+ IOi^?AFV|uXng9R* diff --git a/src/osm_geojson/pt/__pycache__/core.cpython-313.pyc b/src/osm_geojson/pt/__pycache__/core.cpython-313.pyc deleted file mode 100644 index 1434f58b2923b3ea33668debdb662e9845428451..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7526 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 None: @click.argument("relation_id", type=int) def list_stations(relation_id: int) -> None: """List all stations in an OSM public transport route relation.""" - data = fetch_relation_full(relation_id) - nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + try: + data = fetch_relation_full(relation_id) + nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + except OsmError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) click.echo(f"Route: {tags.get('name', relation_id)}") click.echo(f"Stops ({len(stop_ids)}):") for i, sid in enumerate(stop_ids, 1): @@ -60,8 +65,13 @@ def route_between( no_stops: bool, ) -> None: """Output GeoJSON for the route segment between two named stations.""" - data = fetch_relation_full(relation_id) - nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + try: + data = fetch_relation_full(relation_id) + nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + except OsmError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + route_coords = build_route_coords(way_ids, ways, nodes) def find_stop(name: str) -> int | None: @@ -80,8 +90,8 @@ def route_between( if sid_to is None: errors.append(f"Station not found: {to_station!r}") if errors: - for e in errors: - click.echo(f"Error: {e}", err=True) + for msg in errors: + click.echo(f"Error: {msg}", err=True) click.echo("Available stations:", err=True) for sid in stop_ids: if sid in nodes: @@ -104,8 +114,12 @@ def route_between( @click.option("--no-stops", is_flag=True, default=False, help="Omit stop points from output.") def full_route(relation_id: int, output: str | None, no_stops: bool) -> None: """Output GeoJSON for the entire route, end to end.""" - data = fetch_relation_full(relation_id) - nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + try: + data = fetch_relation_full(relation_id) + nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + except OsmError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) route_coords = build_route_coords(way_ids, ways, nodes) geojson = make_geojson(route_coords, stop_ids, nodes, tags, no_stops=no_stops) output_geojson(geojson, output) diff --git a/src/osm_geojson/pt/core.py b/src/osm_geojson/pt/core.py index c49d524..cef2e38 100644 --- a/src/osm_geojson/pt/core.py +++ b/src/osm_geojson/pt/core.py @@ -1,8 +1,7 @@ """Core data-fetching and processing functions for osm-pt-geojson.""" -import sys +import warnings from typing import Any -import click import requests OSM_API = "https://www.openstreetmap.org/api/0.6" @@ -14,17 +13,28 @@ OsmElement = dict[str, Any] GeoJson = dict[str, Any] +class OsmError(Exception): + """Raised when an OSM API request fails or returns unexpected data.""" + + def __init__(self, message: str, status_code: int = 500) -> None: + """Initialise with a human-readable message and an HTTP-style status code.""" + super().__init__(message) + self.status_code = status_code + + def fetch_relation_full(relation_id: int) -> dict[str, Any]: """Fetch the full OSM API response for a relation, including all member ways and nodes.""" url = f"{OSM_API}/relation/{relation_id}/full.json" try: resp = requests.get(url, headers={"User-Agent": "osm-pt-geojson/1.0"}, timeout=30) except requests.RequestException as e: - click.echo(f"Error: {e}", err=True) - sys.exit(1) + raise OsmError(f"Network error fetching relation {relation_id}: {e}", 502) from e + if resp.status_code == 404: + raise OsmError(f"Relation {relation_id} not found on OpenStreetMap.", 404) if resp.status_code != 200: - click.echo(f"Error: HTTP {resp.status_code} fetching relation {relation_id}", err=True) - sys.exit(1) + raise OsmError( + f"Unexpected HTTP {resp.status_code} fetching relation {relation_id}.", 502 + ) result: dict[str, Any] = resp.json() return result @@ -48,8 +58,7 @@ def parse_elements( relation = elem if relation is None: - click.echo(f"Error: relation {relation_id} not found in API response", err=True) - sys.exit(1) + raise OsmError(f"Relation {relation_id} not found in API response.", 404) stop_ids: list[int] = [] way_ids: list[int] = [] @@ -92,7 +101,7 @@ def build_route_coords( elif chain[0] == wn[0]: chain = list(reversed(wn)) + chain[1:] else: - click.echo(f"Warning: gap before way {way_id}", err=True) + warnings.warn(f"Gap in route geometry before way {way_id}", stacklevel=2) chain.extend(wn) return [[nodes[nid]["lon"], nodes[nid]["lat"]] for nid in chain if nid in nodes] @@ -110,6 +119,56 @@ def nearest_coord_index(lon: float, lat: float, route_coords: list[Coord]) -> in return best_i +def fetch_sibling_routes(relation_id: int) -> list[dict[str, Any]]: + """Return sibling route relations from the same route_master, excluding self. + + Fetches parent relations of relation_id, finds any route_master parents, + and returns the other member relations with their names. Returns an empty + list if there is no route_master parent or if any request fails. + """ + url = f"{OSM_API}/relation/{relation_id}/relations.json" + try: + resp = requests.get(url, headers={"User-Agent": "osm-pt-geojson/1.0"}, timeout=30) + except requests.RequestException: + return [] + if resp.status_code != 200: + return [] + + data: dict[str, Any] = resp.json() + sibling_ids: list[int] = [] + for elem in data.get("elements", []): + if elem.get("tags", {}).get("type") != "route_master": + continue + for member in elem.get("members", []): + if member["type"] == "relation" and member["ref"] != relation_id: + sibling_ids.append(member["ref"]) + + result: list[dict[str, Any]] = [] + for sid in sibling_ids: + surl = f"{OSM_API}/relation/{sid}.json" + try: + sresp = requests.get( + surl, headers={"User-Agent": "osm-pt-geojson/1.0"}, timeout=30 + ) + except requests.RequestException: + continue + if sresp.status_code != 200: + continue + sdata: dict[str, Any] = sresp.json() + for elem in sdata.get("elements", []): + if elem["type"] == "relation" and elem["id"] == sid: + stags: OsmTags = elem.get("tags", {}) + result.append( + { + "id": sid, + "name": stags.get("name", str(sid)), + "ref": stags.get("ref"), + } + ) + break + return result + + def node_name(node: OsmElement) -> str: """Return a human-readable name for a node: name tag, ref tag, or node ID.""" tags: OsmTags = node.get("tags", {}) @@ -172,3 +231,73 @@ def make_geojson( ) return {"type": "FeatureCollection", "features": features} + + +def fetch_route_master_routes( + relation_id: int, +) -> tuple[OsmTags, list[dict[str, Any]]]: + """Fetch a route_master relation and return (rm_tags, routes). + + Raises OsmError if the relation is not found or is not a route_master. + Each entry in routes has: id, name, ref, from, to, geojson (lines only). + Members that cannot be fetched are included with null geojson. + """ + url = f"{OSM_API}/relation/{relation_id}.json" + try: + resp = requests.get(url, headers={"User-Agent": "osm-pt-geojson/1.0"}, timeout=30) + except requests.RequestException as e: + raise OsmError(f"Network error fetching relation {relation_id}: {e}", 502) from e + if resp.status_code == 404: + raise OsmError(f"Relation {relation_id} not found on OpenStreetMap.", 404) + if resp.status_code != 200: + raise OsmError( + f"Unexpected HTTP {resp.status_code} fetching relation {relation_id}.", 502 + ) + + data: dict[str, Any] = resp.json() + rm_tags: OsmTags = {} + member_ids: list[int] = [] + for elem in data.get("elements", []): + if elem["type"] == "relation" and elem["id"] == relation_id: + rm_tags = elem.get("tags", {}) + for m in elem.get("members", []): + if m["type"] == "relation": + member_ids.append(m["ref"]) + break + + if not rm_tags: + raise OsmError(f"Relation {relation_id} not found in API response.", 404) + if rm_tags.get("type") != "route_master": + raise OsmError( + f"Relation {relation_id} is not a route_master " + f"(type={rm_tags.get('type')!r}).", + 422, + ) + + routes: list[dict[str, Any]] = [] + for mid in member_ids: + entry: dict[str, Any] + try: + full_data = fetch_relation_full(mid) + nodes, ways, stop_ids, way_ids, tags = parse_elements(full_data, mid) + route_coords = build_route_coords(way_ids, ways, nodes) + entry = { + "id": mid, + "name": tags.get("name", str(mid)), + "ref": tags.get("ref"), + "from": tags.get("from"), + "to": tags.get("to"), + "geojson": make_geojson(route_coords, stop_ids, nodes, tags, no_stops=True), + } + except OsmError: + entry = { + "id": mid, + "name": str(mid), + "ref": None, + "from": None, + "to": None, + "geojson": None, + } + routes.append(entry) + + return rm_tags, routes diff --git a/src/osm_geojson/py.typed b/src/osm_geojson/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_osm_pt_geojson.py b/tests/test_osm_pt_geojson.py index c89e236..afdd8e5 100644 --- a/tests/test_osm_pt_geojson.py +++ b/tests/test_osm_pt_geojson.py @@ -8,6 +8,7 @@ from click.testing import CliRunner from osm_geojson.pt import core from osm_geojson.pt.cli import cli, output_geojson +from osm_geojson.pt.core import OsmError FIXTURES = Path(__file__).parent / "fixtures" FULL_URL = "https://www.openstreetmap.org/api/0.6/relation/15083963/full.json" @@ -57,8 +58,8 @@ def test_parse_elements_tags(parsed: tuple) -> None: def test_parse_elements_unknown_relation(full_data: dict) -> None: - """Requesting a relation ID not present in the response exits with an error.""" - with pytest.raises(SystemExit): + """Requesting a relation ID not present in the response raises OsmError.""" + with pytest.raises(OsmError): core.parse_elements(full_data, 9999999) diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..65da89e --- /dev/null +++ b/web/app.py @@ -0,0 +1,210 @@ +"""Flask web frontend for osm-pt-geojson.""" +import re +from typing import Any + +from flask import Flask, jsonify, redirect, render_template, request, url_for +from flask.typing import ResponseReturnValue + +from osm_geojson.pt.core import ( + OsmError, + build_route_coords, + fetch_relation_full, + fetch_route_master_routes, + fetch_sibling_routes, + make_geojson, + nearest_coord_index, + node_name, + parse_elements, +) + +app = Flask(__name__) +app.secret_key = "dev" + +PUBLIC_TRANSPORT_ROUTE_TYPES = { + "bus", "trolleybus", "tram", "subway", "train", + "light_rail", "monorail", "ferry", "funicular", +} + + +def parse_relation_id(text: str) -> int | None: + """Extract a relation ID from a bare integer, partial path, or full OSM URL.""" + text = text.strip() + m = re.search(r"relation/(\d+)", text) + if m: + return int(str(m.group(1))) + if re.fullmatch(r"\d+", text): + return int(text) + return None + + +def check_route_tags( + tags: dict[str, str], relation_id: int +) -> tuple[dict[str, Any], int] | None: + """Return a (error_dict, http_status) if the relation is not a supported public transport route.""" + if tags.get("type") == "route_master": + return { + "error": "is_route_master", + "message": ( + f"Relation {relation_id} is a route_master relation. " + "Select one of its individual routes." + ), + }, 422 + if tags.get("type") != "route": + kind = tags.get("type", "unknown") + return { + "error": "not_public_transport", + "message": ( + f"Relation {relation_id} is a {kind!r} relation, not a route relation. " + "This tool only supports public transport route relations." + ), + }, 422 + route = tags.get("route") + if not route: + return { + "error": "not_public_transport", + "message": ( + f"Relation {relation_id} is a route relation but has no 'route' tag. " + "Cannot determine the route type." + ), + }, 422 + if route not in PUBLIC_TRANSPORT_ROUTE_TYPES: + supported = ", ".join(sorted(PUBLIC_TRANSPORT_ROUTE_TYPES)) + return { + "error": "not_public_transport", + "route_type": route, + "message": ( + f"Relation {relation_id} is a {route!r} route, not a public transport route. " + f"This tool supports: {supported}." + ), + }, 422 + return None + + +@app.route("/") +def index() -> ResponseReturnValue: + """Render the landing page with no relation loaded.""" + error = request.args.get("error") + return render_template("index.html", relation_id=None, error=error) + + +@app.route("/") +def route_page(relation_id: int) -> ResponseReturnValue: + """Render the page with a relation pre-loaded.""" + return render_template("index.html", relation_id=relation_id, error=None) + + +@app.route("/load", methods=["POST"]) +def load() -> ResponseReturnValue: + """Parse user input and redirect to the relation page.""" + raw = request.form.get("relation", "").strip() + relation_id = parse_relation_id(raw) + if relation_id is None: + return redirect(url_for("index", error="Could not find a relation ID in that input.")) + return redirect(url_for("route_page", relation_id=relation_id)) + + +@app.route("/api/route/") +def api_route(relation_id: int) -> ResponseReturnValue: + """Return the full route as GeoJSON plus a list of stops.""" + try: + data = fetch_relation_full(relation_id) + nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + except OsmError as e: + return jsonify({"error": "osm_error", "message": str(e)}), e.status_code + + err = check_route_tags(tags, relation_id) + if err: + return jsonify(err[0]), err[1] + + route_coords = build_route_coords(way_ids, ways, nodes) + geojson = make_geojson(route_coords, stop_ids, nodes, tags) + + stops = [] + for sid in stop_ids: + if sid in nodes: + n = nodes[sid] + stops.append({"name": node_name(n), "lat": n["lat"], "lon": n["lon"]}) + + other_directions = fetch_sibling_routes(relation_id) + + return jsonify({ + "name": tags.get("name", str(relation_id)), + "ref": tags.get("ref"), + "stops": stops, + "geojson": geojson, + "other_directions": other_directions, + }) + + +@app.route("/api/segment/") +def api_segment(relation_id: int) -> ResponseReturnValue: + """Return GeoJSON for the segment between two named stops.""" + from_name = request.args.get("from", "").strip() + to_name = request.args.get("to", "").strip() + include_stops = request.args.get("stops", "1") != "0" + + if not from_name or not to_name: + return jsonify({ + "error": "missing_params", + "message": "Both 'from' and 'to' parameters are required.", + }), 400 + + try: + data = fetch_relation_full(relation_id) + nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + except OsmError as e: + return jsonify({"error": "osm_error", "message": str(e)}), e.status_code + + err = check_route_tags(tags, relation_id) + if err: + return jsonify(err[0]), err[1] + + route_coords = build_route_coords(way_ids, ways, nodes) + + def find_stop(name: str) -> int | None: + """Return the node ID of the stop matching name (case-insensitive), or None.""" + for sid in stop_ids: + if sid in nodes and node_name(nodes[sid]).lower() == name.lower(): + return sid + return None + + sid_from = find_stop(from_name) + sid_to = find_stop(to_name) + + missing = [n for n, s in ((from_name, sid_from), (to_name, sid_to)) if s is None] + if missing: + available = [node_name(nodes[s]) for s in stop_ids if s in nodes] + return jsonify({ + "error": "station_not_found", + "message": f"Station(s) not found: {', '.join(repr(m) for m in missing)}", + "available": available, + }), 404 + + assert sid_from is not None and sid_to is not None + idx_from = nearest_coord_index(nodes[sid_from]["lon"], nodes[sid_from]["lat"], route_coords) + idx_to = nearest_coord_index(nodes[sid_to]["lon"], nodes[sid_to]["lat"], route_coords) + + geojson = make_geojson( + route_coords, stop_ids, nodes, tags, + idx_from=idx_from, idx_to=idx_to, + no_stops=not include_stops, + ) + return jsonify(geojson) + + +@app.route("/api/route_master/") +def api_route_master(relation_id: int) -> ResponseReturnValue: + """Return all member routes of a route_master with their GeoJSON geometries.""" + try: + rm_tags, routes = fetch_route_master_routes(relation_id) + except OsmError as e: + return jsonify({"error": "osm_error", "message": str(e)}), e.status_code + return jsonify({ + "name": rm_tags.get("name", str(relation_id)), + "ref": rm_tags.get("ref"), + "routes": routes, + }) + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..a94b6b5 --- /dev/null +++ b/web/static/app.js @@ -0,0 +1,467 @@ +/** + * osm-pt-geojson web frontend + * Handles map rendering, stop selection, segment preview and GeoJSON download. + */ + +'use strict'; + +// ── Map setup ────────────────────────────────────────────────────────────── + +const map = L.map('map').setView([20, 0], 2); + +L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19, +}).addTo(map); + +// Leaflet layers +let fullRouteLayer = null; // thin grey polyline for the full route +let segmentLayer = null; // bold coloured polyline for the selected segment +let markerLayer = null; // all stop markers +let routeMasterLayers = []; // coloured polylines when viewing a route_master + +// ── State ───────────────────────────────────────────────────────────────── + +let routeData = null; // response from /api/route/ +let segmentGeoJson = null; // last response from /api/segment/ (always includes stops) +let activeSlot = 'from'; // 'from' | 'to' | null +let selectedFrom = null; // stop name string or null +let selectedTo = null; // stop name string or null + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Show an error alert in the sidebar. + * @param {string} msg + */ +function showError(msg) { + const el = document.getElementById('js-alert'); + document.getElementById('js-alert-msg').textContent = msg; + el.classList.remove('d-none'); +} + +/** + * Trigger a file download of text content. + * @param {string} content + * @param {string} filename + */ +function downloadText(content, filename) { + const blob = new Blob([content], { type: 'application/geo+json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +/** + * Return a safe filename fragment from a stop name. + * @param {string} name + * @returns {string} + */ +function safeName(name) { + return name.replace(/[^a-zA-Z0-9_\-]/g, '_').replace(/_+/g, '_'); +} + +// ── Slot UI ──────────────────────────────────────────────────────────────── + +/** + * Update the From/To slot boxes to reflect current state. + */ +function renderSlots() { + const slots = { from: selectedFrom, to: selectedTo }; + for (const [id, value] of Object.entries(slots)) { + const box = document.getElementById(`slot-${id}`); + const text = document.getElementById(`slot-${id}-text`); + const pencil = document.getElementById(`slot-${id}-pencil`); + const isActive = activeSlot === id; + + box.classList.toggle('active', isActive); + pencil.classList.toggle('d-none', !isActive); + + if (value) { + text.textContent = value; + text.classList.remove('placeholder'); + } else { + text.textContent = 'Click a stop…'; + text.classList.add('placeholder'); + } + } +} + +/** + * Set which slot is active. Pass null to deactivate both. + * @param {'from'|'to'|null} slot + */ +function setActiveSlot(slot) { + activeSlot = slot; + renderSlots(); +} + +// ── Stop selection ───────────────────────────────────────────────────────── + +/** + * Handle a stop being selected (from list click or map marker click). + * @param {string} name + */ +function selectStop(name) { + if (activeSlot === 'from') { + selectedFrom = name; + setActiveSlot('to'); + } else if (activeSlot === 'to') { + selectedTo = name; + setActiveSlot(null); + } else { + // Neither slot active — do nothing, user must click a slot first. + return; + } + renderStopList(); + updateMarkers(); + if (selectedFrom && selectedTo) { + loadSegment(); + } +} + +// ── Stop list ────────────────────────────────────────────────────────────── + +/** + * Render the sidebar stop list from routeData. + */ +function renderStopList() { + if (!routeData) return; + const list = document.getElementById('stop-list'); + document.getElementById('stop-count').textContent = `(${routeData.stops.length})`; + list.innerHTML = ''; + for (const stop of routeData.stops) { + const div = document.createElement('div'); + div.className = 'stop-item'; + div.textContent = stop.name; + if (stop.name === selectedFrom) div.classList.add('is-from'); + if (stop.name === selectedTo) div.classList.add('is-to'); + div.addEventListener('click', () => selectStop(stop.name)); + list.appendChild(div); + } +} + +// ── Map rendering ────────────────────────────────────────────────────────── + +/** Marker colours for stop states. */ +const MARKER_COLOURS = { + from: '#198754', // green + to: '#dc3545', // red + segment: '#0d6efd', // blue + default: '#6c757d', // grey +}; + +/** + * Create a small circular Leaflet marker. + * @param {number} lat + * @param {number} lon + * @param {string} colour hex colour + * @param {string} title + * @returns {L.CircleMarker} + */ +function makeMarker(lat, lon, colour, title) { + return L.circleMarker([lat, lon], { + radius: 6, + color: colour, + fillColor: colour, + fillOpacity: 0.9, + weight: 2, + }).bindTooltip(title); +} + +/** + * Determine which stops fall within the current segment (by index). + * Returns a Set of stop names. + * @returns {Set} + */ +function segmentStopNames() { + if (!segmentGeoJson) return new Set(); + return new Set( + segmentGeoJson.features + .filter(f => f.geometry.type === 'Point') + .map(f => f.properties.name) + ); +} + +/** + * Redraw all stop markers to reflect current selection state. + */ +function updateMarkers() { + if (!routeData) return; + if (markerLayer) markerLayer.remove(); + markerLayer = L.layerGroup(); + const inSegment = segmentStopNames(); + + for (const stop of routeData.stops) { + let colour; + if (stop.name === selectedFrom) colour = MARKER_COLOURS.from; + else if (stop.name === selectedTo) colour = MARKER_COLOURS.to; + else if (inSegment.has(stop.name)) colour = MARKER_COLOURS.segment; + else colour = MARKER_COLOURS.default; + + const marker = makeMarker(stop.lat, stop.lon, colour, stop.name); + marker.on('click', () => selectStop(stop.name)); + markerLayer.addLayer(marker); + } + markerLayer.addTo(map); +} + +/** + * Draw or redraw the segment layer from segmentGeoJson, respecting the stops toggle. + */ +function renderSegment() { + if (segmentLayer) segmentLayer.remove(); + if (!segmentGeoJson) return; + + const includeStops = document.getElementById('include-stops').checked; + + // Build a filtered GeoJSON: always keep LineString, conditionally keep Points. + const filtered = { + type: 'FeatureCollection', + features: segmentGeoJson.features.filter(f => + f.geometry.type === 'LineString' || (f.geometry.type === 'Point' && includeStops) + ), + }; + + segmentLayer = L.geoJSON(filtered, { + style: { color: '#0d6efd', weight: 5, opacity: 0.85 }, + pointToLayer: (feature, latlng) => + makeMarker(latlng.lat, latlng.lng, MARKER_COLOURS.segment, feature.properties.name), + }).addTo(map); + + updateMarkers(); + updateDownloadButtons(); +} + +// ── Download buttons ─────────────────────────────────────────────────────── + +/** + * Enable/disable the segment download button and wire up both download buttons. + */ +function updateDownloadButtons() { + const btn = document.getElementById('btn-download-segment'); + if (segmentGeoJson && selectedFrom && selectedTo) { + btn.classList.remove('disabled'); + } else { + btn.classList.add('disabled'); + } +} + +document.getElementById('btn-download-segment').addEventListener('click', () => { + if (!segmentGeoJson || !selectedFrom || !selectedTo) return; + const includeStops = document.getElementById('include-stops').checked; + const geojson = { + type: 'FeatureCollection', + features: segmentGeoJson.features.filter(f => + f.geometry.type === 'LineString' || (f.geometry.type === 'Point' && includeStops) + ), + }; + const ref = routeData.ref || 'route'; + const filename = `${safeName(ref)}-${safeName(selectedFrom)}-${safeName(selectedTo)}.geojson`; + downloadText(JSON.stringify(geojson, null, 2), filename); +}); + +document.getElementById('btn-download-full').addEventListener('click', () => { + if (!routeData) return; + const includeStops = document.getElementById('include-stops').checked; + const geojson = { + type: 'FeatureCollection', + features: routeData.geojson.features.filter(f => + f.geometry.type === 'LineString' || (f.geometry.type === 'Point' && includeStops) + ), + }; + const ref = routeData.ref || 'route'; + downloadText(JSON.stringify(geojson, null, 2), `${safeName(ref)}-full.geojson`); +}); + +// ── Clear button ─────────────────────────────────────────────────────────── + +document.getElementById('btn-clear').addEventListener('click', () => { + selectedFrom = null; + selectedTo = null; + segmentGeoJson = null; + if (segmentLayer) { segmentLayer.remove(); segmentLayer = null; } + setActiveSlot('from'); + renderStopList(); + updateMarkers(); + updateDownloadButtons(); +}); + +// ── Stops toggle ─────────────────────────────────────────────────────────── + +document.getElementById('include-stops').addEventListener('change', () => { + renderSegment(); // re-filters existing data, no network request +}); + +// ── Slot click handlers ──────────────────────────────────────────────────── + +document.getElementById('slot-from').addEventListener('click', () => setActiveSlot('from')); +document.getElementById('slot-to').addEventListener('click', () => setActiveSlot('to')); + +// ── API calls ────────────────────────────────────────────────────────────── + +/** + * Fetch the full route and render it on the map. + * @param {number} relationId + */ +async function loadRoute(relationId) { + try { + const resp = await fetch(`/api/route/${relationId}`); + const data = await resp.json(); + if (!resp.ok) { + if (data.error === 'is_route_master') { + await loadRouteMaster(relationId); + return; + } + showError(data.message || `Error loading relation ${relationId}.`); + return; + } + routeData = data; + // Clean up any previous route_master view + for (const l of routeMasterLayers) l.remove(); + routeMasterLayers = []; + document.getElementById('route-master-panel').classList.add('d-none'); + selectedFrom = null; + selectedTo = null; + segmentGeoJson = null; + setActiveSlot('from'); + + // Show route panel + document.getElementById('route-panel').classList.remove('d-none'); + document.getElementById('route-name').textContent = data.name; + + // Draw full route in grey + if (fullRouteLayer) fullRouteLayer.remove(); + fullRouteLayer = L.geoJSON(data.geojson, { + style: { color: '#adb5bd', weight: 3, opacity: 0.7 }, + pointToLayer: () => null, // don't render stop points here + filter: f => f.geometry.type === 'LineString', + }).addTo(map); + + map.fitBounds(fullRouteLayer.getBounds(), { padding: [20, 20] }); + + renderStopList(); + updateMarkers(); + updateDownloadButtons(); + + // Other directions (sibling routes from the same route_master) + const dirPanel = document.getElementById('other-directions-panel'); + const dirList = document.getElementById('other-directions-list'); + dirList.innerHTML = ''; + if (data.other_directions && data.other_directions.length > 0) { + dirPanel.classList.remove('d-none'); + for (const dir of data.other_directions) { + const a = document.createElement('a'); + a.href = `/${dir.id}`; + a.className = 'stop-item d-block text-decoration-none'; + a.textContent = dir.name; + dirList.appendChild(a); + } + } else { + dirPanel.classList.add('d-none'); + } + } catch (e) { + showError('Network error loading route.'); + } +} + +/** + * Fetch the segment GeoJSON and render it. + */ +async function loadSegment() { + if (!selectedFrom || !selectedTo || !routeData) return; + const rid = RELATION_ID; + const params = new URLSearchParams({ from: selectedFrom, to: selectedTo, stops: '1' }); + try { + const resp = await fetch(`/api/segment/${rid}?${params}`); + const data = await resp.json(); + if (!resp.ok) { + showError(data.message || 'Error loading segment.'); + return; + } + segmentGeoJson = data; + renderSegment(); + } catch (e) { + showError('Network error loading segment.'); + } +} + +/** + * Fetch and display all member routes of a route_master relation. + * @param {number} relationId + */ +async function loadRouteMaster(relationId) { + try { + const resp = await fetch(`/api/route_master/${relationId}`); + const data = await resp.json(); + if (!resp.ok) { + showError(data.message || `Error loading route master ${relationId}.`); + return; + } + + // Clear individual route state + routeData = null; + selectedFrom = null; + selectedTo = null; + segmentGeoJson = null; + if (fullRouteLayer) { fullRouteLayer.remove(); fullRouteLayer = null; } + if (segmentLayer) { segmentLayer.remove(); segmentLayer = null; } + if (markerLayer) { markerLayer.remove(); markerLayer = null; } + for (const l of routeMasterLayers) l.remove(); + routeMasterLayers = []; + + // Hide individual route panel, show route_master panel + document.getElementById('route-panel').classList.add('d-none'); + document.getElementById('route-master-panel').classList.remove('d-none'); + document.getElementById('route-master-name').textContent = data.name; + + const colours = ['#0d6efd', '#dc3545', '#198754', '#fd7e14', '#6f42c1']; + const list = document.getElementById('route-master-list'); + list.innerHTML = ''; + + data.routes.forEach((route, i) => { + const colour = colours[i % colours.length]; + + if (route.geojson) { + const layer = L.geoJSON(route.geojson, { + style: { color: colour, weight: 4, opacity: 0.85 }, + }).addTo(map); + routeMasterLayers.push(layer); + } + + const div = document.createElement('div'); + div.className = 'stop-item d-flex align-items-center gap-2'; + + const dot = document.createElement('span'); + dot.style.cssText = + `display:inline-block;width:10px;height:10px;border-radius:50%;` + + `background:${colour};flex-shrink:0`; + + const a = document.createElement('a'); + a.href = `/${route.id}`; + a.className = 'text-decoration-none text-reset flex-grow-1'; + a.textContent = route.name; + + div.appendChild(dot); + div.appendChild(a); + list.appendChild(div); + }); + + if (routeMasterLayers.length > 0) { + let bounds = routeMasterLayers[0].getBounds(); + for (const l of routeMasterLayers.slice(1)) bounds = bounds.extend(l.getBounds()); + map.fitBounds(bounds, { padding: [20, 20] }); + } + } catch (e) { + showError('Network error loading route master.'); + } +} + +// ── Init ─────────────────────────────────────────────────────────────────── + +if (RELATION_ID) { + loadRoute(RELATION_ID); +} diff --git a/web/static/favicon.svg b/web/static/favicon.svg new file mode 100644 index 0000000..f9190cd --- /dev/null +++ b/web/static/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..03f0101 --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,68 @@ +html, body { + height: 100%; + overflow: hidden; +} + +#main-row { + height: calc(100vh - 56px); /* subtract navbar height */ +} + +#sidebar { + height: 100%; + overflow-y: auto; + padding: 1rem; +} + +#map { + height: 100%; +} + +.slot-box { + border: 2px solid #dee2e6; + border-radius: 6px; + padding: 0.4rem 0.6rem; + cursor: pointer; + min-height: 2.2rem; + display: flex; + align-items: center; + justify-content: space-between; + transition: border-color 0.15s; +} + +.slot-box.active { + border-color: #0d6efd; + background: #f0f5ff; +} + +.slot-box .placeholder { + color: #adb5bd; + font-style: italic; +} + +.slot-box .pencil { + color: #0d6efd; + font-size: 0.8rem; +} + +#stop-list .stop-item { + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.9rem; +} + +#stop-list .stop-item:hover { + background: #e9ecef; +} + +#stop-list .stop-item.is-from { + background: #d1e7dd; +} + +#stop-list .stop-item.is-to { + background: #f8d7da; +} + +.leaflet-container { + font-family: inherit; +} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..ed496f0 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,126 @@ + + + + + + OSM Public Transport → GeoJSON + + + + + + + + + +
+
+ + + + + +
+ +
+
+ + + + + + +