Make public transport routes discoverable with shareable search
Add one-click examples and route search by number or metadata, with transport type, location and map-area filters. Preserve search filters and bounds in shareable URLs and restore searches on reload and browser navigation. Validate with 91 passing tests, including desktop and mobile browser coverage, and strict mypy checks. Closes #2
This commit is contained in:
parent
29afb4f115
commit
a79b0a7098
7 changed files with 919 additions and 12 deletions
15
README.md
15
README.md
|
|
@ -76,6 +76,19 @@ routes as GeoJSON.
|
|||
|
||||
#### Features
|
||||
|
||||
- Search worldwide for public transport routes by route number, name,
|
||||
destination, operator or network, with an optional transport type filter.
|
||||
Click a result to load it; search uses Overpass and shows up to 50 matches.
|
||||
- Narrow a route search with a location (for example, route `24`, location
|
||||
`Bristol, UK`, transport type `Bus`). Choose a matching place to centre the
|
||||
map and show its search boundary, or tick **Search this map area** to use
|
||||
the current view instead. Routes may extend outside the search area.
|
||||
- Share or bookmark the page URL after searching. It saves the route text,
|
||||
transport type, selected location and exact area; opening it automatically
|
||||
restores the filters and matching route list. Back and Forward restore
|
||||
previous searches. Results reflect the latest OpenStreetMap data.
|
||||
- Start with one-click examples: Bristol airport bus A1, Istanbul Metro M11,
|
||||
or all M11 directions. No relation ID lookup is needed to try the tool.
|
||||
- Enter a relation ID or OSM URL to load a route onto the map.
|
||||
- Click stops in the sidebar list or on the map to set start and end points.
|
||||
- Preview the selected segment highlighted on the map.
|
||||
|
|
@ -101,6 +114,8 @@ when the server is running.
|
|||
|
||||
| Endpoint | Description |
|
||||
|---|---|
|
||||
| `GET /api/search?q=TEXT[&mode=TYPE][&south=S&west=W&north=N&east=E]` | Find public transport route relations, optionally within an area |
|
||||
| `GET /api/locations?q=PLACE` | Find candidate locations and their search bounds |
|
||||
| `GET /api/route/<id>` | Full route GeoJSON, stop list, and sibling routes |
|
||||
| `GET /api/segment/<id>?from=NAME&to=NAME` | Segment between two named stops |
|
||||
| `GET /api/route_master/<id>` | All member routes of a route_master |
|
||||
|
|
|
|||
|
|
@ -1,16 +1,32 @@
|
|||
"""Core data-fetching and processing functions for osm-pt-geojson."""
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import warnings
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
OSM_API = "https://www.openstreetmap.org/api/0.6"
|
||||
OVERPASS_URL = "https://overpass.atownsend.org.uk/api/interpreter"
|
||||
PUBLIC_TRANSPORT_ROUTE_TYPES = {
|
||||
"bus", "trolleybus", "tram", "subway", "train",
|
||||
"light_rail", "monorail", "ferry", "funicular",
|
||||
}
|
||||
SEARCH_LIMIT = 50
|
||||
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
||||
_location_lock = threading.Lock()
|
||||
_last_location_request = 0.0
|
||||
|
||||
# Type aliases
|
||||
Coord = list[float] # [lon, lat]
|
||||
OsmTags = dict[str, str]
|
||||
OsmElement = dict[str, Any]
|
||||
GeoJson = dict[str, Any]
|
||||
Bounds = tuple[float, float, float, float] # south, west, north, east
|
||||
|
||||
|
||||
class OsmError(Exception):
|
||||
|
|
@ -22,6 +38,138 @@ class OsmError(Exception):
|
|||
self.status_code = status_code
|
||||
|
||||
|
||||
def validate_search_bounds(bounds: Bounds) -> None:
|
||||
"""Reject invalid, non-finite or excessively broad route search areas."""
|
||||
south, west, north, east = bounds
|
||||
if not all(math.isfinite(value) for value in bounds) or not (
|
||||
-90 <= south < north <= 90 and -180 <= west < east <= 180
|
||||
):
|
||||
raise OsmError("Choose a valid search area that does not cross the date line.", 400)
|
||||
if north - south > 20 or east - west > 20:
|
||||
raise OsmError("Zoom in or choose a smaller location to search for routes.", 400)
|
||||
|
||||
|
||||
def find_locations(query: str) -> list[dict[str, Any]]:
|
||||
"""Resolve a submitted place name to candidate areas, with cached results."""
|
||||
query = query.strip()
|
||||
if not 2 <= len(query) <= 100 or any(ord(char) < 32 for char in query):
|
||||
raise OsmError("Enter between 2 and 100 characters for the location.", 400)
|
||||
# Serialize cache misses and space requests at least one second apart.
|
||||
with _location_lock:
|
||||
return _find_locations_cached(query)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def _find_locations_cached(query: str) -> list[dict[str, Any]]:
|
||||
"""Fetch place bounds from Nominatim on explicit searches, never autocomplete."""
|
||||
global _last_location_request
|
||||
delay = 1 - (time.monotonic() - _last_location_request)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
_last_location_request = time.monotonic()
|
||||
try:
|
||||
response = requests.get(
|
||||
NOMINATIM_URL,
|
||||
params={"q": query, "format": "jsonv2", "limit": "5", "dedupe": "1"},
|
||||
headers={
|
||||
"User-Agent": "osm-pt-geojson/1.0 (https://openstreetmap.tools)",
|
||||
"Accept": "application/json",
|
||||
}, timeout=15,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
raise OsmError("Location search is unavailable. Please try again shortly.", 502) from exc
|
||||
if not isinstance(data, list):
|
||||
raise OsmError("Location search returned an unexpected response.", 502)
|
||||
locations = []
|
||||
for item in data:
|
||||
try:
|
||||
south, north, west, east = (float(value) for value in item["boundingbox"])
|
||||
bounds = (south, west, north, east)
|
||||
# Broad place results remain selectable; route search explains zooming in.
|
||||
if not all(math.isfinite(value) for value in bounds) or not (
|
||||
-90 <= south < north <= 90 and -180 <= west < east <= 180
|
||||
):
|
||||
continue
|
||||
locations.append({"label": item["display_name"], "bounds": bounds})
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
return locations
|
||||
|
||||
|
||||
def search_routes(query: str, mode: str = "", bounds: Bounds | None = None) -> dict[str, Any]:
|
||||
"""Search public transport relations by literal text or exact route number.
|
||||
|
||||
Names, termini, operators and networks are matched case-insensitively.
|
||||
Fetch one extra result to indicate when the output limit is exceeded.
|
||||
"""
|
||||
query = query.strip()
|
||||
if not 2 <= len(query) <= 100 or any(ord(char) < 32 for char in query):
|
||||
raise OsmError("Enter between 2 and 100 characters to search for a route.", 400)
|
||||
if mode and mode not in PUBLIC_TRANSPORT_ROUTE_TYPES:
|
||||
raise OsmError("Choose a supported transport type.", 400)
|
||||
if bounds is not None:
|
||||
validate_search_bounds(bounds)
|
||||
# Overpass uses POSIX ERE; escape only its metacharacters, then quote for QL.
|
||||
literal = re.sub(r'([.\[\]\\*+?{}()|^$])', r'\\\1', query)
|
||||
text_pattern = json.dumps(literal, ensure_ascii=False)
|
||||
ref_pattern = json.dumps(f"^{literal}$", ensure_ascii=False)
|
||||
modes = mode or "|".join(sorted(PUBLIC_TRANSPORT_ROUTE_TYPES))
|
||||
spatial_query = ""
|
||||
input_set = ""
|
||||
if bounds is not None:
|
||||
bbox = ",".join(f"{value:.7f}" for value in bounds)
|
||||
spatial_query = (
|
||||
f'rel({bbox})["type"="route"]["route"~"^({modes})$"]->.areaRoutes;'
|
||||
'rel(br.areaRoutes)["type"="route_master"]->.areaMasters;'
|
||||
'(.areaRoutes;.areaMasters;)->.inArea;'
|
||||
)
|
||||
input_set = ".inArea"
|
||||
selectors = []
|
||||
for kind, mode_tag in (("route", "route"), ("route_master", "route_master")):
|
||||
base = f'rel{input_set}["type"="{kind}"]["{mode_tag}"~"^({modes})$"]'
|
||||
selectors.append(f'{base}["ref"~{ref_pattern},i];')
|
||||
for key in ("name", "from", "to", "operator", "network"):
|
||||
selectors.append(f'{base}["{key}"~{text_pattern},i];')
|
||||
overpass_query = "[out:json][timeout:25];" + spatial_query + "(" + "".join(selectors) + f");out tags {SEARCH_LIMIT + 1};"
|
||||
try:
|
||||
response = requests.post(
|
||||
OVERPASS_URL, data={"data": overpass_query},
|
||||
headers={"User-Agent": "osm-pt-geojson/1.0"}, timeout=35,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
raise OsmError("Route search is unavailable. Please try again shortly.", 502) from exc
|
||||
if not isinstance(data, dict) or data.get("remark") or not isinstance(data.get("elements"), list):
|
||||
raise OsmError("Route search could not complete. Try a more specific search.", 502)
|
||||
routes = []
|
||||
for element in data["elements"]:
|
||||
if not isinstance(element, dict) or element.get("type") != "relation":
|
||||
continue
|
||||
tags = element.get("tags", {})
|
||||
if not isinstance(tags, dict):
|
||||
continue
|
||||
relation_type = tags.get("type")
|
||||
route_mode = tags.get("route_master" if relation_type == "route_master" else "route")
|
||||
if relation_type not in ("route", "route_master") or route_mode not in PUBLIC_TRANSPORT_ROUTE_TYPES:
|
||||
continue
|
||||
if not isinstance(element.get("id"), int):
|
||||
continue
|
||||
routes.append({
|
||||
"id": element["id"], "name": tags.get("name") or tags.get("ref") or str(element["id"]),
|
||||
"ref": tags.get("ref"), "from": tags.get("from"), "to": tags.get("to"),
|
||||
"operator": tags.get("operator"), "network": tags.get("network"),
|
||||
"route": route_mode, "type": relation_type,
|
||||
})
|
||||
routes.sort(key=lambda route: (
|
||||
str(route["ref"] or "").casefold() != query.casefold(),
|
||||
str(route["name"]).casefold(), route["id"],
|
||||
))
|
||||
return {"routes": routes[:SEARCH_LIMIT], "truncated": len(routes) > SEARCH_LIMIT}
|
||||
|
||||
|
||||
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"
|
||||
|
|
|
|||
388
tests/test_pt_search.py
Normal file
388
tests/test_pt_search.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
"""Offline tests for public transport relation search and its JSON endpoint."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import responses
|
||||
|
||||
from osm_geojson.pt import core
|
||||
|
||||
|
||||
def relation(relation_id: int = 1, **tags: str) -> dict[str, Any]:
|
||||
"""Build a minimal tagged Overpass relation."""
|
||||
return {"type": "relation", "id": relation_id, "tags": {"type": "route", "route": "bus", **tags}}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app(monkeypatch: Any) -> Any:
|
||||
"""Load the web application independently of Python's import path."""
|
||||
spec = importlib.util.spec_from_file_location("pt_web_app", Path(__file__).parents[1] / "web/app.py")
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, spec.name, module)
|
||||
spec.loader.exec_module(module)
|
||||
module.app.config["TESTING"] = True
|
||||
return module.app
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_search_metadata_and_query() -> None:
|
||||
"""Return routes and masters, exclude other relations, and prefer exact refs."""
|
||||
responses.post(core.OVERPASS_URL, json={"elements": [
|
||||
relation(2, name="Airport shuttle", ref="X1", operator="City Bus", **{"from": "Airport", "to": "Centre"}),
|
||||
relation(3, name="M11", type="route_master", route_master="subway", ref="M11"),
|
||||
relation(1, name="Metro M11", route="subway", ref="M11"),
|
||||
relation(4, route="hiking"),
|
||||
{"type": "node", "id": 5},
|
||||
]})
|
||||
result = core.search_routes(" M11 ")
|
||||
assert [item["id"] for item in result["routes"]] == [3, 1, 2]
|
||||
assert result["routes"][0]["type"] == "route_master"
|
||||
assert result["routes"][2]["operator"] == "City Bus"
|
||||
assert result["truncated"] is False
|
||||
request = responses.calls[0].request
|
||||
query = parse_qs(request.body)["data"][0]
|
||||
assert '["ref"~"^M11$",i]' in query
|
||||
assert '["route_master"~' in query
|
||||
assert 'out tags 51;' in query
|
||||
assert request.headers["User-Agent"] == "osm-pt-geojson/1.0"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_literal_search_and_mode_filter() -> None:
|
||||
"""User text stays a literal regex and cannot inject Overpass selectors."""
|
||||
responses.post(core.OVERPASS_URL, json={"elements": []})
|
||||
text = 'İstanbul [A.*] "\\);out;'
|
||||
assert core.search_routes(text, "subway")["routes"] == []
|
||||
query = parse_qs(responses.calls[0].request.body)["data"][0]
|
||||
escaped = 'İstanbul \\[A\\.\\*\\] "\\\\\\);out;'
|
||||
assert f'["name"~{json.dumps(escaped, ensure_ascii=False)},i]' in query
|
||||
assert '["route"~"^(subway)$"]' in query
|
||||
assert '["route_master"~"^(subway)$"]' in query
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query,mode", [("", ""), ("x", ""), ("x" * 101, ""), ("a\nb", ""), ("M11", "hiking")])
|
||||
@responses.activate
|
||||
def test_invalid_search(query: str, mode: str) -> None:
|
||||
"""Reject invalid input without contacting Overpass."""
|
||||
with pytest.raises(core.OsmError) as error:
|
||||
core.search_routes(query, mode)
|
||||
assert error.value.status_code == 400
|
||||
assert len(responses.calls) == 0
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_search_limit() -> None:
|
||||
"""Tell the UI when more than fifty relations matched."""
|
||||
responses.post(core.OVERPASS_URL, json={"elements": [relation(i) for i in range(51)]})
|
||||
result = core.search_routes("Airport")
|
||||
assert len(result["routes"]) == 50
|
||||
assert result["truncated"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [{"remark": "runtime error: Query timed out", "elements": []}, {}, []])
|
||||
@responses.activate
|
||||
def test_incomplete_search(payload: Any) -> None:
|
||||
"""A timed-out or malformed response must not look like an empty result."""
|
||||
responses.post(core.OVERPASS_URL, json=payload)
|
||||
with pytest.raises(core.OsmError) as error:
|
||||
core.search_routes("M11")
|
||||
assert error.value.status_code == 502
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [requests.Timeout("timeout"), "invalid JSON", 429, 504])
|
||||
@responses.activate
|
||||
def test_upstream_search_failure(failure: Any) -> None:
|
||||
"""Network, rate-limit and decoding failures become library errors."""
|
||||
if isinstance(failure, int):
|
||||
responses.post(core.OVERPASS_URL, status=failure)
|
||||
else:
|
||||
responses.post(core.OVERPASS_URL, body=failure)
|
||||
with pytest.raises(core.OsmError) as error:
|
||||
core.search_routes("M11")
|
||||
assert error.value.status_code == 502
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_search_api(app: Any) -> None:
|
||||
"""The endpoint handles results, empty matches, invalid inputs and upstream errors."""
|
||||
responses.post(core.OVERPASS_URL, json={"elements": [relation(15083963, name="M11", ref="M11")]})
|
||||
client = app.test_client()
|
||||
result = client.get("/api/search?q=M11&mode=subway")
|
||||
assert result.status_code == 200
|
||||
assert result.json["routes"][0]["id"] == 15083963
|
||||
responses.post(core.OVERPASS_URL, json={"elements": []})
|
||||
assert client.get("/api/search?q=Unknown").json == {"routes": [], "truncated": False}
|
||||
for path in ("/api/search", "/api/search?q=M11&mode=hiking"):
|
||||
result = client.get(path)
|
||||
assert result.status_code == 400
|
||||
assert result.json["error"] == "invalid_query"
|
||||
responses.post(core.OVERPASS_URL, status=503)
|
||||
result = client.get("/api/search?q=M11")
|
||||
assert result.status_code == 502
|
||||
assert result.json["error"] == "osm_error"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1280, 390])
|
||||
def test_search_browser(app: Any, width: int, monkeypatch: Any) -> None:
|
||||
"""Search results load routes on desktop and mobile, with safe text and errors."""
|
||||
import threading
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
browser_api = pytest.importorskip("playwright.sync_api")
|
||||
monkeypatch.setitem(app.view_functions["route_page"].__globals__, "fetch_relation_name", lambda _: "Metro M11")
|
||||
server = make_server("127.0.0.1", 0, app)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with browser_api.sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch()
|
||||
page = browser.new_page(viewport={"width": width, "height": 844})
|
||||
errors: list[str] = []
|
||||
location_requests: list[str] = []
|
||||
page.on("pageerror", lambda error: errors.append(str(error)))
|
||||
page.on('request', lambda request: location_requests.append(request.url) if '/api/locations?' in request.url else None)
|
||||
page.route("**/tile.openstreetmap.org/**", lambda route: route.abort())
|
||||
page.route("**/api/locations?*", lambda route: route.fulfill(json={"locations": [
|
||||
{"label": "Bristol, UK", "bounds": [51.39, -2.73, 51.55, -2.51]},
|
||||
{"label": "Bristol, USA", "bounds": [41.6, -72.9, 41.8, -72.8]},
|
||||
]}))
|
||||
page.route("**/api/search?*", lambda route: route.fulfill(json={
|
||||
"routes": [{"id": 15083963, "name": "Metro M11 <script>", "ref": "M11",
|
||||
"route": "subway", "type": "route", "from": "Airport", "to": "Centre",
|
||||
"operator": "Metro"}], "truncated": True,
|
||||
}))
|
||||
page.route("**/api/route/15083963", lambda route: route.fulfill(json={
|
||||
"name": "Metro M11", "ref": "M11", "other_directions": [],
|
||||
"stops": [{"name": "Airport", "lat": 41.2, "lon": 28.8}],
|
||||
"geojson": {"type": "FeatureCollection", "features": [{
|
||||
"type": "Feature", "properties": {},
|
||||
"geometry": {"type": "LineString", "coordinates": [[28.8, 41.2], [28.9, 41.3]]},
|
||||
}]},
|
||||
}))
|
||||
page.goto(f"http://127.0.0.1:{server.server_port}/")
|
||||
page.get_by_label("Find a public transport route").fill("24")
|
||||
page.get_by_label("Transport type").select_option("bus")
|
||||
page.get_by_label("Location (optional)").fill("Bristol")
|
||||
page.get_by_role("button", name="Search", exact=True).click()
|
||||
browser_api.expect(page.locator("#search-status")).to_contain_text("Choose a location")
|
||||
with page.expect_request("**/api/search?*") as request:
|
||||
page.get_by_role("button", name="Bristol, UK", exact=True).click()
|
||||
params = parse_qs(request.value.url.split('?')[1])
|
||||
assert params['q'] == ['24'] and params['mode'] == ['bus']
|
||||
assert params['south'] == ['51.39'] and params['east'] == ['-2.51']
|
||||
browser_api.expect(page.locator('#search-area-label')).to_contain_text('Bristol, UK')
|
||||
assert page.evaluate('map.getBounds().contains(L.latLngBounds([51.39, -2.73], [51.55, -2.51]))')
|
||||
assert page.evaluate('map.getZoom()') > 2
|
||||
assert page.evaluate('searchAreaLayer !== null')
|
||||
browser_api.expect(page.locator("#search-status")).to_contain_text("More matches exist")
|
||||
shared_url = page.url
|
||||
shared_params = parse_qs(shared_url.split('?')[1])
|
||||
assert shared_params['location'] == ['Bristol']
|
||||
assert shared_params['place'] == ['Bristol, UK']
|
||||
assert shared_params['q'] == ['24'] and shared_params['mode'] == ['bus']
|
||||
assert len(location_requests) == 1
|
||||
page.set_viewport_size({'width': 390 if width == 1280 else 1280, 'height': 844})
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.reload()
|
||||
assert parse_qs(request.value.url.split('?')[1]) == params
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
browser_api.expect(page.get_by_label('Find a public transport route')).to_have_value('24')
|
||||
browser_api.expect(page.get_by_label('Transport type')).to_have_value('bus')
|
||||
browser_api.expect(page.get_by_label('Location (optional)')).to_have_value('Bristol')
|
||||
browser_api.expect(page.locator('#search-area-label')).to_contain_text('Bristol, UK')
|
||||
assert page.url == shared_url
|
||||
assert len(location_requests) == 1
|
||||
page.set_viewport_size({'width': width, 'height': 844})
|
||||
if width == 390:
|
||||
page.evaluate('openPanel()')
|
||||
link = page.locator("#search-results a")
|
||||
browser_api.expect(link).to_contain_text("Metro M11 <script>")
|
||||
assert page.locator("#search-results script").count() == 0
|
||||
page.get_by_label('Search this map area').check()
|
||||
browser_api.expect(page.get_by_label('Location (optional)')).to_be_disabled()
|
||||
page.evaluate('map.setView([51.45, -2.59], 13, {animate: false})')
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.get_by_role('button', name='Search', exact=True).click()
|
||||
map_params = parse_qs(request.value.url.split('?')[1])
|
||||
assert float(map_params['south'][0]) < 51.45 < float(map_params['north'][0])
|
||||
assert float(map_params['west'][0]) < -2.59 < float(map_params['east'][0])
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
shared_map_url = page.url
|
||||
assert parse_qs(shared_map_url.split('?')[1])['area'] == ['map']
|
||||
page.set_viewport_size({'width': 390 if width == 1280 else 1280, 'height': 844})
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.reload()
|
||||
assert parse_qs(request.value.url.split('?')[1]) == map_params
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
browser_api.expect(page.get_by_label('Search this map area')).to_be_checked()
|
||||
browser_api.expect(page.get_by_label('Location (optional)')).to_be_disabled()
|
||||
assert page.url == shared_map_url
|
||||
page.set_viewport_size({'width': width, 'height': 844})
|
||||
if width == 390:
|
||||
page.evaluate('openPanel()')
|
||||
page.evaluate('map.setView([51.5, -2.7], 13, {animate: false})')
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.get_by_role('button', name='Search', exact=True).click()
|
||||
moved_params = parse_qs(request.value.url.split('?')[1])
|
||||
assert moved_params['west'] != map_params['west']
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.go_back()
|
||||
assert parse_qs(request.value.url.split('?')[1]) == map_params
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.go_forward()
|
||||
assert parse_qs(request.value.url.split('?')[1]) == moved_params
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
page.get_by_label('Search this map area').uncheck()
|
||||
browser_api.expect(page.get_by_label('Location (optional)')).to_be_enabled()
|
||||
page.get_by_label('Location (optional)').fill('Bristol')
|
||||
page.get_by_role('button', name='Search', exact=True).click()
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('Choose a location')
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.get_by_role('button', name='Bristol, UK', exact=True).click()
|
||||
assert parse_qs(request.value.url.split('?')[1])['south'] == ['51.39']
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('More matches exist')
|
||||
link.click()
|
||||
browser_api.expect(page.locator("#route-name")).to_have_text("Metro M11")
|
||||
browser_api.expect(page).to_have_url(f"http://127.0.0.1:{server.server_port}/15083963")
|
||||
page.route("**/api/search?*", lambda route: route.fulfill(json={"routes": [], "truncated": False}))
|
||||
page.get_by_label("Find a public transport route").fill("Unknown")
|
||||
page.get_by_role("button", name="Search", exact=True).click()
|
||||
browser_api.expect(page.locator("#search-status")).to_contain_text("No routes found")
|
||||
assert page.locator("#search-results a").count() == 0
|
||||
world_url = page.url
|
||||
assert parse_qs(world_url.split('?')[1]) == {'q': ['Unknown']}
|
||||
with page.expect_request('**/api/search?*') as request:
|
||||
page.reload()
|
||||
assert parse_qs(request.value.url.split('?')[1]) == {'q': ['Unknown']}
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('No routes found')
|
||||
page.route("**/api/search?*", lambda route: route.fulfill(status=502, json={"message": "Search unavailable"}))
|
||||
page.get_by_role("button", name="Search", exact=True).click()
|
||||
browser_api.expect(page.locator("#search-status")).to_have_text("Search unavailable")
|
||||
browser_api.expect(page.get_by_role("button", name="Search", exact=True)).to_be_enabled()
|
||||
page.route('**/api/locations?*', lambda route: route.fulfill(json={'locations': []}))
|
||||
page.get_by_label('Location (optional)').fill('Unknown place')
|
||||
page.get_by_role('button', name='Search', exact=True).click()
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('No location found')
|
||||
page.route('**/api/locations?*', lambda route: route.fulfill(status=502, json={'message': 'Location unavailable'}))
|
||||
page.get_by_role('button', name='Search', exact=True).click()
|
||||
browser_api.expect(page.locator('#search-status')).to_have_text('Location unavailable')
|
||||
page.goto(f'http://127.0.0.1:{server.server_port}/?q=24&mode=bus&south=nan&west=-3&north=52&east=-2')
|
||||
browser_api.expect(page.locator('#search-status')).to_contain_text('Invalid search area')
|
||||
assert errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def location_cache(monkeypatch: Any) -> None:
|
||||
"""Give each location test an empty cache and no pending rate-limit delay."""
|
||||
core._find_locations_cached.cache_clear()
|
||||
monkeypatch.setattr(core, '_last_location_request', 0.0)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_locations_cache_and_bounds(location_cache: None) -> None:
|
||||
"""Geocode worldwide, preserve candidate areas, and reuse successful lookups."""
|
||||
responses.get(core.NOMINATIM_URL, json=[
|
||||
{"display_name": "Bristol, UK", "boundingbox": ["51.39", "51.55", "-2.73", "-2.51"]},
|
||||
{"display_name": "Bristol, USA", "boundingbox": ["41.6", "41.8", "-72.9", "-72.8"]},
|
||||
{"display_name": "Invalid", "boundingbox": ["nan", "52", "-3", "-2"]},
|
||||
])
|
||||
locations = core.find_locations(' Bristol ')
|
||||
assert locations[0] == {"label": "Bristol, UK", "bounds": (51.39, -2.73, 51.55, -2.51)}
|
||||
assert len(locations) == 2
|
||||
assert core.find_locations('Bristol') == locations
|
||||
assert len(responses.calls) == 1
|
||||
assert 'countrycodes' not in responses.calls[0].request.url
|
||||
assert 'openstreetmap.tools' in responses.calls[0].request.headers['User-Agent']
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_location_api(app: Any, location_cache: None) -> None:
|
||||
"""Location resolution returns candidates or explicit errors, never global routes."""
|
||||
client = app.test_client()
|
||||
responses.get(core.NOMINATIM_URL, json=[])
|
||||
result = client.get('/api/locations?q=Nowhere')
|
||||
assert result.status_code == 200
|
||||
assert result.json == {"locations": []}
|
||||
assert client.get('/api/locations').status_code == 400
|
||||
assert client.get('/api/locations?q=x').status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize('payload', [{}, 'broken JSON', requests.Timeout('timeout'), 429])
|
||||
@responses.activate
|
||||
def test_location_failure(payload: Any, location_cache: None) -> None:
|
||||
"""Upstream errors and invalid location payloads become OsmError."""
|
||||
if isinstance(payload, int):
|
||||
responses.get(core.NOMINATIM_URL, status=payload)
|
||||
elif isinstance(payload, dict):
|
||||
responses.get(core.NOMINATIM_URL, json=payload)
|
||||
else:
|
||||
responses.get(core.NOMINATIM_URL, body=payload)
|
||||
with pytest.raises(core.OsmError) as error:
|
||||
core.find_locations('Bristol')
|
||||
assert error.value.status_code == 502
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_area_query_and_parent_masters() -> None:
|
||||
"""Scope routes before text filtering and include masters of local routes."""
|
||||
responses.post(core.OVERPASS_URL, json={"elements": [relation(24, ref='24')]})
|
||||
result = core.search_routes('24', 'bus', (51.39, -2.73, 51.55, -2.51))
|
||||
assert result['routes'][0]['ref'] == '24'
|
||||
query = parse_qs(responses.calls[0].request.body)['data'][0]
|
||||
assert 'rel(51.3900000,-2.7300000,51.5500000,-2.5100000)' in query
|
||||
assert 'rel(br.areaRoutes)["type"="route_master"]' in query
|
||||
assert 'rel.inArea["type"="route"]["route"~"^(bus)$"]["ref"~"^24$",i]' in query
|
||||
assert 'rel.inArea["type"="route_master"]' in query
|
||||
|
||||
|
||||
@pytest.mark.parametrize('suffix', [
|
||||
'&south=51', '&south=x&west=-3&north=52&east=-2',
|
||||
'&south=nan&west=-3&north=52&east=-2',
|
||||
'&south=51&west=-3&north=inf&east=-2',
|
||||
'&south=52&west=-3&north=51&east=-2',
|
||||
'&south=51&west=179&north=52&east=-179',
|
||||
'&south=-90&west=-180&north=90&east=180',
|
||||
])
|
||||
@responses.activate
|
||||
def test_invalid_area_api(app: Any, suffix: str) -> None:
|
||||
"""Reject partial, invalid, broad and date-line-crossing bounds without HTTP."""
|
||||
result = app.test_client().get('/api/search?q=24' + suffix)
|
||||
assert result.status_code == 400
|
||||
assert result.json['error'] == 'invalid_query'
|
||||
assert len(responses.calls) == 0
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_area_api(app: Any) -> None:
|
||||
"""Parse and pass all four map coordinates through to Overpass."""
|
||||
responses.post(core.OVERPASS_URL, json={"elements": []})
|
||||
result = app.test_client().get('/api/search?q=24&mode=bus&south=51.39&west=-2.73&north=51.55&east=-2.51')
|
||||
assert result.status_code == 200
|
||||
assert 'rel(51.3900000,-2.7300000,51.5500000,-2.5100000)' in parse_qs(responses.calls[0].request.body)['data'][0]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_location_rate_limit(location_cache: None, monkeypatch: Any) -> None:
|
||||
"""Distinct lookups are spaced one second apart; cached ones need no delay."""
|
||||
delays: list[float] = []
|
||||
monkeypatch.setattr(core.time, 'monotonic', lambda: 10.0)
|
||||
monkeypatch.setattr(core.time, 'sleep', delays.append)
|
||||
responses.get(core.NOMINATIM_URL, json=[])
|
||||
core.find_locations('Bristol')
|
||||
core.find_locations('London')
|
||||
core.find_locations('Bristol')
|
||||
assert delays == [1.0]
|
||||
assert len(responses.calls) == 2
|
||||
40
web/app.py
40
web/app.py
|
|
@ -7,26 +7,24 @@ from flask.typing import ResponseReturnValue
|
|||
|
||||
from osm_geojson.pt.core import (
|
||||
OsmError,
|
||||
PUBLIC_TRANSPORT_ROUTE_TYPES,
|
||||
Bounds,
|
||||
build_route_coords,
|
||||
fetch_relation_full,
|
||||
fetch_relation_name,
|
||||
fetch_route_master_routes,
|
||||
fetch_sibling_routes,
|
||||
find_locations,
|
||||
make_geojson,
|
||||
nearest_coord_index,
|
||||
node_name,
|
||||
parse_elements,
|
||||
search_routes,
|
||||
)
|
||||
|
||||
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()
|
||||
|
|
@ -144,6 +142,36 @@ def api_route(relation_id: int) -> ResponseReturnValue:
|
|||
})
|
||||
|
||||
|
||||
@app.get("/api/search")
|
||||
def api_search() -> ResponseReturnValue:
|
||||
"""Find public transport routes by name, terminus or route number."""
|
||||
try:
|
||||
bounds: Bounds | None = None
|
||||
keys = ("south", "west", "north", "east")
|
||||
if any(key in request.args for key in keys):
|
||||
try:
|
||||
bounds = (
|
||||
float(request.args["south"]), float(request.args["west"]),
|
||||
float(request.args["north"]), float(request.args["east"]),
|
||||
)
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise OsmError("All four search area coordinates are required and must be numbers.", 400) from exc
|
||||
return jsonify(search_routes(request.args.get("q", ""), request.args.get("mode", ""), bounds))
|
||||
except OsmError as exc:
|
||||
code = "invalid_query" if exc.status_code == 400 else "osm_error"
|
||||
return jsonify({"error": code, "message": str(exc)}), exc.status_code
|
||||
|
||||
|
||||
@app.get("/api/locations")
|
||||
def api_locations() -> ResponseReturnValue:
|
||||
"""Find candidate locations and their search bounds for a place name."""
|
||||
try:
|
||||
return jsonify({"locations": find_locations(request.args.get("q", ""))})
|
||||
except OsmError as exc:
|
||||
code = "invalid_query" if exc.status_code == 400 else "osm_error"
|
||||
return jsonify({"error": code, "message": str(exc)}), exc.status_code
|
||||
|
||||
|
||||
@app.route("/api/segment/<int:relation_id>")
|
||||
def api_segment(relation_id: int) -> ResponseReturnValue:
|
||||
"""Return GeoJSON for the segment between two named stops."""
|
||||
|
|
|
|||
|
|
@ -533,12 +533,235 @@ async function loadRouteMaster(relationId) {
|
|||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let selectedLocation = null;
|
||||
let searchAreaLayer = null;
|
||||
const locationInput = document.getElementById('search-location');
|
||||
const mapAreaCheckbox = document.getElementById('search-map-area');
|
||||
const locationResults = document.getElementById('location-results');
|
||||
const areaLabel = document.getElementById('search-area-label');
|
||||
let searchController = null;
|
||||
|
||||
/** Remove the previous search outline when its scope changes. */
|
||||
function clearSearchArea() {
|
||||
if (searchAreaLayer) searchAreaLayer.remove();
|
||||
searchAreaLayer = null;
|
||||
areaLabel.textContent = '';
|
||||
}
|
||||
|
||||
locationInput.addEventListener('input', () => {
|
||||
selectedLocation = null;
|
||||
locationResults.replaceChildren();
|
||||
clearSearchArea();
|
||||
});
|
||||
|
||||
mapAreaCheckbox.addEventListener('change', () => {
|
||||
locationInput.disabled = mapAreaCheckbox.checked;
|
||||
locationResults.replaceChildren();
|
||||
clearSearchArea();
|
||||
if (!mapAreaCheckbox.checked && selectedLocation) chooseLocation(selectedLocation);
|
||||
});
|
||||
|
||||
/** Centre the map and show the bounds used for a chosen place. */
|
||||
function chooseLocation(location) {
|
||||
selectedLocation = location;
|
||||
locationResults.replaceChildren();
|
||||
clearSearchArea();
|
||||
const [south, west, north, east] = location.bounds;
|
||||
const bounds = L.latLngBounds([south, west], [north, east]);
|
||||
searchAreaLayer = L.rectangle(bounds, {
|
||||
color: '#0d6efd', weight: 2, dashArray: '6 4', fill: false, interactive: false,
|
||||
}).addTo(map);
|
||||
map.fitBounds(bounds, { ...fitPadding(), animate: false });
|
||||
areaLabel.textContent = `Search area: ${location.label}`;
|
||||
}
|
||||
|
||||
/** Search relation metadata, resolving an optional place before searching routes. */
|
||||
async function searchRoutes({ updateUrl = true, restoredBounds = null } = {}) {
|
||||
if (searchController) searchController.abort();
|
||||
const controller = new AbortController();
|
||||
searchController = controller;
|
||||
const button = document.getElementById('search-button');
|
||||
const status = document.getElementById('search-status');
|
||||
const results = document.getElementById('search-results');
|
||||
const params = new URLSearchParams({
|
||||
q: document.getElementById('route-search').value.trim(),
|
||||
mode: document.getElementById('search-mode').value,
|
||||
});
|
||||
button.disabled = true;
|
||||
locationInput.disabled = true;
|
||||
mapAreaCheckbox.disabled = true;
|
||||
results.replaceChildren();
|
||||
status.textContent = 'Searching routes…';
|
||||
try {
|
||||
if (mapAreaCheckbox.checked) {
|
||||
clearSearchArea();
|
||||
const bounds = map.getBounds();
|
||||
// Leaflet wraps world copies; normalise longitude without hiding date-line crossings.
|
||||
const west = ((bounds.getWest() + 180) % 360 + 360) % 360 - 180;
|
||||
const east = west + bounds.getEast() - bounds.getWest();
|
||||
const searchBounds = restoredBounds || [Math.max(-90, bounds.getSouth()), west,
|
||||
Math.min(90, bounds.getNorth()), east];
|
||||
['south', 'west', 'north', 'east'].forEach((key, index) => params.set(key, searchBounds[index]));
|
||||
if (restoredBounds) {
|
||||
const [south, west, north, east] = restoredBounds;
|
||||
searchAreaLayer = L.rectangle([[south, west], [north, east]], {
|
||||
color: '#0d6efd', weight: 2, dashArray: '6 4', fill: false, interactive: false,
|
||||
}).addTo(map);
|
||||
}
|
||||
areaLabel.textContent = restoredBounds ? 'Search area: shared map view' : 'Search area: current map view';
|
||||
} else if (locationInput.value.trim()) {
|
||||
if (!selectedLocation) {
|
||||
status.textContent = 'Finding location…';
|
||||
const response = await fetch(URLS.locationsApi + '?' + new URLSearchParams({ q: locationInput.value.trim() }), { signal: controller.signal });
|
||||
const data = await response.json();
|
||||
if (controller.signal.aborted) return;
|
||||
if (!response.ok) {
|
||||
status.textContent = data.message || 'Location search failed. Please try again.';
|
||||
return;
|
||||
}
|
||||
if (!data.locations.length) {
|
||||
status.textContent = 'No location found. Try a more specific place name.';
|
||||
return;
|
||||
}
|
||||
if (data.locations.length > 1) {
|
||||
status.textContent = 'Choose a location to search for routes:';
|
||||
locationResults.replaceChildren();
|
||||
for (const location of data.locations) {
|
||||
const choice = document.createElement('button');
|
||||
choice.type = 'button';
|
||||
choice.className = 'list-group-item list-group-item-action';
|
||||
choice.textContent = location.label;
|
||||
choice.addEventListener('click', () => {
|
||||
chooseLocation(location);
|
||||
searchRoutes();
|
||||
});
|
||||
locationResults.appendChild(choice);
|
||||
}
|
||||
return;
|
||||
}
|
||||
chooseLocation(data.locations[0]);
|
||||
}
|
||||
['south', 'west', 'north', 'east'].forEach((key, index) => params.set(key, selectedLocation.bounds[index]));
|
||||
} else {
|
||||
clearSearchArea();
|
||||
}
|
||||
if (updateUrl) {
|
||||
const urlParams = new URLSearchParams(params);
|
||||
if (!urlParams.get('mode')) urlParams.delete('mode');
|
||||
if (mapAreaCheckbox.checked) {
|
||||
urlParams.set('area', 'map');
|
||||
} else if (selectedLocation && locationInput.value.trim()) {
|
||||
urlParams.set('location', locationInput.value.trim());
|
||||
urlParams.set('place', selectedLocation.label);
|
||||
}
|
||||
const url = URLS.indexPage + '?' + urlParams;
|
||||
if (location.pathname + location.search !== url) history.pushState(null, '', url);
|
||||
}
|
||||
status.textContent = 'Searching routes…';
|
||||
const response = await fetch(URLS.searchApi + '?' + params, { signal: controller.signal });
|
||||
const data = await response.json();
|
||||
if (controller.signal.aborted) return;
|
||||
if (!response.ok) {
|
||||
status.textContent = data.message || 'Route search failed. Please try again.';
|
||||
return;
|
||||
}
|
||||
status.textContent = data.routes.length
|
||||
? `${data.routes.length} results. Choose a route to load it.${data.truncated ? ' More matches exist; narrow your search.' : ''}`
|
||||
: 'No routes found. Try a route number, destination or operator.';
|
||||
for (const route of data.routes) {
|
||||
const link = document.createElement('a');
|
||||
link.href = URLS.routePage + route.id;
|
||||
link.className = 'list-group-item list-group-item-action';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'fw-semibold d-block';
|
||||
name.textContent = route.name;
|
||||
const details = document.createElement('span');
|
||||
details.className = 'text-muted d-block';
|
||||
details.textContent = [
|
||||
route.ref, route.route.replaceAll('_', ' '),
|
||||
route.from && route.to ? `${route.from} → ${route.to}` : route.from || route.to,
|
||||
route.operator || route.network,
|
||||
route.type === 'route_master' ? 'All directions' : null,
|
||||
`Relation ${route.id}`,
|
||||
].filter(Boolean).join(' · ');
|
||||
link.append(name, details);
|
||||
results.appendChild(link);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') status.textContent = 'Network error searching routes. Please try again.';
|
||||
} finally {
|
||||
if (searchController === controller) {
|
||||
button.disabled = false;
|
||||
mapAreaCheckbox.disabled = false;
|
||||
locationInput.disabled = mapAreaCheckbox.checked;
|
||||
searchController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore submitted filters and use the saved bounds exactly, on every screen size. */
|
||||
function restoreSearch() {
|
||||
if (searchController) searchController.abort();
|
||||
searchController = null;
|
||||
document.getElementById('search-button').disabled = false;
|
||||
mapAreaCheckbox.disabled = false;
|
||||
const params = new URLSearchParams(location.search);
|
||||
document.getElementById('route-search').value = params.get('q') || '';
|
||||
document.getElementById('search-mode').value = params.get('mode') || '';
|
||||
locationInput.value = params.get('location') || '';
|
||||
mapAreaCheckbox.checked = params.get('area') === 'map';
|
||||
locationInput.disabled = mapAreaCheckbox.checked;
|
||||
selectedLocation = null;
|
||||
locationResults.replaceChildren();
|
||||
clearSearchArea();
|
||||
document.getElementById('search-results').replaceChildren();
|
||||
const status = document.getElementById('search-status');
|
||||
status.textContent = '';
|
||||
if (!params.has('q')) return;
|
||||
if (isMobile()) openPanel();
|
||||
const keys = ['south', 'west', 'north', 'east'];
|
||||
let bounds = null;
|
||||
if (keys.some(key => params.has(key))) {
|
||||
bounds = keys.map(key => params.has(key) && params.get(key).trim() ? Number(params.get(key)) : NaN);
|
||||
const [south, west, north, east] = bounds;
|
||||
if (!bounds.every(Number.isFinite) || south < -90 || north > 90 || west < -180 || east > 180
|
||||
|| south >= north || west >= east || north - south > 20 || east - west > 20) {
|
||||
status.textContent = 'Invalid search area in this URL. Choose a location or zoom in and search again.';
|
||||
return;
|
||||
}
|
||||
if (mapAreaCheckbox.checked) {
|
||||
map.fitBounds([[south, west], [north, east]], { ...fitPadding(), animate: false });
|
||||
} else {
|
||||
locationInput.value ||= params.get('place') || 'Shared search area';
|
||||
chooseLocation({ label: params.get('place') || locationInput.value, bounds });
|
||||
}
|
||||
} else if (mapAreaCheckbox.checked) {
|
||||
status.textContent = 'This map search URL is missing its search area. Zoom in and search again.';
|
||||
return;
|
||||
}
|
||||
const query = document.getElementById('route-search').value.trim();
|
||||
const mode = params.get('mode') || '';
|
||||
if (query.length < 2 || query.length > 100 || /[\x00-\x1f]/.test(query)
|
||||
|| (mode && document.getElementById('search-mode').value !== mode)) {
|
||||
status.textContent = 'Invalid search filters in this URL. Update the filters and search again.';
|
||||
return;
|
||||
}
|
||||
searchRoutes({ updateUrl: false, restoredBounds: bounds });
|
||||
}
|
||||
|
||||
document.getElementById('route-search-form').addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
locationResults.replaceChildren();
|
||||
searchRoutes();
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
const prefix = URLS.routePage;
|
||||
if (location.pathname.startsWith(prefix)) {
|
||||
const id = parseInt(location.pathname.slice(prefix.length), 10);
|
||||
if (id) loadRoute(id);
|
||||
}
|
||||
restoreSearch();
|
||||
});
|
||||
|
||||
if (RELATION_ID) {
|
||||
|
|
@ -546,3 +769,4 @@ if (RELATION_ID) {
|
|||
} else if (isMobile()) {
|
||||
openPanel();
|
||||
}
|
||||
restoreSearch();
|
||||
|
|
|
|||
|
|
@ -28,9 +28,35 @@
|
|||
|
||||
<p>All endpoints are read-only and require no authentication. Data is fetched
|
||||
live from the <a href="https://wiki.openstreetmap.org/wiki/API_v0.6">OSM API v0.6</a>.
|
||||
Relation IDs can be found on
|
||||
<a href="https://www.openstreetmap.org">openstreetmap.org</a> — search for a
|
||||
route and click through to its relation page.</p>
|
||||
Route search uses <a href="https://wiki.openstreetmap.org/wiki/Overpass_API">Overpass</a>
|
||||
to find relation IDs by route metadata.</p>
|
||||
|
||||
<h2><span class="badge bg-primary me-2">GET</span><code>/api/search?q=TEXT&mode=TYPE</code></h2>
|
||||
<p>Search worldwide for public transport <code>route</code> and <code>route_master</code>
|
||||
relations. Required <code>q</code> is 2–100 characters. Route numbers (<code>ref</code>)
|
||||
match exactly; names, termini, operators and networks match literal substrings,
|
||||
case-insensitively. Optional <code>mode</code> filters by a supported transport type.</p>
|
||||
<p>Optional <code>south</code>, <code>west</code>, <code>north</code> and <code>east</code>
|
||||
coordinates restrict the search to routes with mapped members within a bounding box,
|
||||
including their parent route masters. All four coordinates are required together.
|
||||
Routes may extend outside the box. Bounds must be finite and ordered, span at most
|
||||
20 degrees in either direction, and must not cross the date line.</p>
|
||||
<p>Returns <code>{"routes": [...], "truncated": false}</code>. Each result has
|
||||
<code>id</code>, <code>name</code>, <code>ref</code>, <code>from</code>, <code>to</code>,
|
||||
<code>operator</code>, <code>network</code>, <code>route</code> (transport type) and
|
||||
<code>type</code> (relation type). At most 50 results are returned; <code>truncated</code>
|
||||
indicates more matches. No matches returns an empty list. Invalid input returns
|
||||
400 <code>invalid_query</code>; upstream failures return 502 <code>osm_error</code>.</p>
|
||||
<pre>GET /api/search?q=M11&mode=subway</pre>
|
||||
<pre>GET /api/search?q=24&mode=bus&south=51.39&west=-2.73&north=51.55&east=-2.51</pre>
|
||||
|
||||
<h2><span class="badge bg-primary me-2">GET</span><code>/api/locations?q=PLACE</code></h2>
|
||||
<p>Find up to five candidate places using Nominatim. Required <code>q</code> is 2–100
|
||||
characters. Returns <code>{"locations": [{"label": "...", "bounds": [south, west, north, east]}]}</code>.
|
||||
An empty list means no matching location. Invalid input returns 400 <code>invalid_query</code>;
|
||||
upstream failures return 502 <code>osm_error</code>. Location lookups are submitted explicitly,
|
||||
cached and limited to one upstream request per second per application process.</p>
|
||||
<pre>GET /api/locations?q=Bristol%2C+UK</pre>
|
||||
|
||||
<p>All error responses return JSON with at least <code>error</code> (machine-readable
|
||||
code) and <code>message</code> (human-readable description) fields.</p>
|
||||
|
|
|
|||
|
|
@ -32,15 +32,90 @@
|
|||
<!-- Scrollable content area -->
|
||||
<div id="sidebar-inner">
|
||||
|
||||
<form id="route-search-form" class="mb-3">
|
||||
<label for="route-search" class="form-label fw-semibold small">Find a public transport route</label>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input id="route-search" type="search" class="form-control" required
|
||||
minlength="2" maxlength="100" placeholder="e.g. M11 or Bristol Airport"
|
||||
aria-describedby="search-help">
|
||||
<button id="search-button" class="btn btn-primary" type="submit">Search</button>
|
||||
</div>
|
||||
<label for="search-mode" class="visually-hidden">Transport type</label>
|
||||
<select id="search-mode" class="form-select form-select-sm">
|
||||
<option value="">All transport types</option>
|
||||
<option value="bus">Bus</option>
|
||||
<option value="trolleybus">Trolleybus</option>
|
||||
<option value="tram">Tram</option>
|
||||
<option value="subway">Subway / metro</option>
|
||||
<option value="train">Train</option>
|
||||
<option value="light_rail">Light rail</option>
|
||||
<option value="monorail">Monorail</option>
|
||||
<option value="ferry">Ferry</option>
|
||||
<option value="funicular">Funicular</option>
|
||||
</select>
|
||||
<label for="search-location" class="form-label small mt-2 mb-1">Location (optional)</label>
|
||||
<input id="search-location" class="form-control form-control-sm" type="text"
|
||||
minlength="2" maxlength="100" placeholder="e.g. Bristol, UK" autocomplete="off"
|
||||
aria-describedby="search-area-help">
|
||||
<div class="form-check mt-2">
|
||||
<input id="search-map-area" class="form-check-input" type="checkbox">
|
||||
<label for="search-map-area" class="form-check-label small">Search this map area</label>
|
||||
</div>
|
||||
<p id="search-help" class="form-text mb-0">Search by route number, name,
|
||||
destination, operator or network. Route numbers match exactly.</p>
|
||||
<p id="search-area-help" class="form-text mb-0">Leave location blank to search worldwide,
|
||||
or limit to a place or the current map view. Routes can extend outside the area.</p>
|
||||
<p id="search-area-label" class="small mt-2 mb-0"></p>
|
||||
<p class="form-text mb-0">After searching, copy the page URL to share these filters.</p>
|
||||
<div id="location-results" class="list-group small mt-2"></div>
|
||||
<p id="search-status" class="small mt-2 mb-0" role="status" aria-live="polite"></p>
|
||||
<div id="search-results" class="list-group small mt-2"></div>
|
||||
</form>
|
||||
|
||||
<!-- A first route is one click away; no OSM knowledge required. -->
|
||||
<details class="mb-3"{% if not relation_id %} open{% endif %}>
|
||||
<summary class="fw-semibold">Try a public transport route</summary>
|
||||
<p class="small mt-2 mb-2">View a route on the map and download it as GeoJSON.
|
||||
Choose an example to get started:</p>
|
||||
<div class="list-group small mb-2">
|
||||
<a class="list-group-item list-group-item-action"
|
||||
href="{{ url_for('route_page', relation_id=18892969) }}">
|
||||
<span class="fw-semibold d-block">Bristol airport bus A1</span>
|
||||
<span class="text-muted">Airport → Bus Station · Bus</span>
|
||||
</a>
|
||||
<a class="list-group-item list-group-item-action"
|
||||
href="{{ url_for('route_page', relation_id=15083963) }}">
|
||||
<span class="fw-semibold d-block">Istanbul Metro M11</span>
|
||||
<span class="text-muted">One direction · Subway</span>
|
||||
</a>
|
||||
<a class="list-group-item list-group-item-action"
|
||||
href="{{ url_for('route_page', relation_id=15083966) }}">
|
||||
<span class="fw-semibold d-block">Istanbul M11 — all directions</span>
|
||||
<span class="text-muted">Compare routes, then choose a direction</span>
|
||||
</a>
|
||||
</div>
|
||||
<details{% if not relation_id %} open{% endif %} class="small">
|
||||
<summary class="fw-semibold">How to use this tool</summary>
|
||||
<ol class="ps-3 mt-2 mb-0">
|
||||
<li>Search and choose a result, try an example, or paste a route URL below.</li>
|
||||
<li>Download the full route, or select a start stop and then an end stop to preview a segment.</li>
|
||||
<li>Choose whether to include stops, then download your GeoJSON file.</li>
|
||||
</ol>
|
||||
</details>
|
||||
</details>
|
||||
|
||||
<!-- Load form -->
|
||||
<form method="post" action="{{ url_for('load') }}" class="mb-3">
|
||||
<label class="form-label fw-semibold small">Relation ID or OSM URL</label>
|
||||
<label for="relation-input" class="form-label fw-semibold small">Load your own route</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" name="relation" class="form-control"
|
||||
placeholder="e.g. 15083963"
|
||||
<input type="text" name="relation" id="relation-input" class="form-control"
|
||||
placeholder="Relation ID or OSM URL" aria-describedby="relation-help" required
|
||||
value="{{ relation_id or '' }}">
|
||||
<button class="btn btn-primary" type="submit">Load</button>
|
||||
</div>
|
||||
<p id="relation-help" class="form-text mb-0">Paste an OpenStreetMap route relation URL
|
||||
(e.g. https://www.openstreetmap.org/relation/15083963) or its numeric ID.
|
||||
A relation groups the ways and stops belonging to a route.</p>
|
||||
</form>
|
||||
|
||||
<!-- Error alert -->
|
||||
|
|
@ -141,6 +216,9 @@
|
|||
// Injected by Flask so app.js works correctly under any mount path.
|
||||
const RELATION_ID = {{ relation_id | tojson }};
|
||||
const URLS = {
|
||||
indexPage: {{ url_for('index') | tojson }},
|
||||
searchApi: {{ url_for('api_search') | tojson }},
|
||||
locationsApi: {{ url_for('api_locations') | tojson }},
|
||||
routeApi: {{ url_for('api_route', relation_id=0)[:-1] | tojson }},
|
||||
segmentApi: {{ url_for('api_segment', relation_id=0)[:-1] | tojson }},
|
||||
routeMasterApi: {{ url_for('api_route_master', relation_id=0)[:-1] | tojson }},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue