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 /<relation_id>
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
d3e6d7ac42
commit
e0ade9e5ab
13 changed files with 1049 additions and 20 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -6,12 +6,13 @@ import click
|
|||
|
||||
from osm_geojson.pt.core import (
|
||||
GeoJson,
|
||||
OsmError,
|
||||
build_route_coords,
|
||||
fetch_relation_full,
|
||||
make_geojson,
|
||||
nearest_coord_index,
|
||||
node_name,
|
||||
parse_elements,
|
||||
nearest_coord_index,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -35,8 +36,12 @@ def cli() -> 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
0
src/osm_geojson/py.typed
Normal file
0
src/osm_geojson/py.typed
Normal file
Loading…
Add table
Add a link
Reference in a new issue