From 71e1b5716482f958099ae743b713cbdf14248783 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 15 Aug 2026 12:06:23 +0100 Subject: [PATCH] Improve stop search and add documentation --- README.md | 7 +- src/uk_bus_stops/app.py | 32 ++++++- src/uk_bus_stops/core.py | 47 ++++++---- src/uk_bus_stops/static/about.css | 23 +++++ src/uk_bus_stops/static/app.js | 63 ++++++++++++- src/uk_bus_stops/templates/about.html | 129 ++++++++++++++++++++++++++ src/uk_bus_stops/templates/index.html | 11 ++- tests/test_uk_bus_stops.py | 59 +++++++++++- tests/test_uk_bus_stops_playwright.py | 24 +++-- 9 files changed, 358 insertions(+), 37 deletions(-) create mode 100644 src/uk_bus_stops/static/about.css create mode 100644 src/uk_bus_stops/templates/about.html diff --git a/README.md b/README.md index 0d288ec..1f86d44 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,15 @@ when the server is running. A mobile-friendly map for finding the `naptan:AtcoCode` recorded on UK bus stops in OpenStreetMap. Search by postcode, street, place, exact ATCO code, or -browser location. Searches are preserved in shareable `?q=...` or +browser location. Postcode, street, and place searches show up to 20 +UK-only Nominatim matches for the user to choose from. Searches are preserved in shareable `?q=...` or `?lat=...&lon=...` URLs, and `latitude, longitude` can be entered directly in the search box. Selecting a stop shows its tags and stop-area-aware bus route relations. Stop results identify their transport mode (such as bus, rail or tram), and moving the map at zoom level 15 or closer loads stops in the visible area. Wider map views deliberately do not query Overpass. +The current map bounds bias Nominatim's ranking without excluding UK matches +outside the visible area. Selecting a stop replaces the search parameters with a shareable stop URL such as `?node=485403163` (or `?way=...` / `?relation=...` for other OSM object @@ -134,6 +137,8 @@ flask --app uk_bus_stops.app run ``` The production URL is `https://openstreetmap.tools/uk-bus-stops/`. +The app's `/about` page documents shareable URL parameters, map behaviour, +data sources, privacy considerations, and JSON endpoints. Browser tests use Python Playwright. After installing the development extras, install Chromium once and run the suite: diff --git a/src/uk_bus_stops/app.py b/src/uk_bus_stops/app.py index e98bd67..72faddb 100644 --- a/src/uk_bus_stops/app.py +++ b/src/uk_bus_stops/app.py @@ -18,7 +18,7 @@ from uk_bus_stops.core import ( stops_in_bounds, ) -ATCO_PATTERN = re.compile(r"^(?=[A-Za-z0-9]{10,16}$)(?=.*\d)[A-Za-z0-9]+$") +ATCO_PATTERN = re.compile(r"^(?=[A-Za-z0-9]{9,16}$)(?=.*\d)[A-Za-z0-9]+$") COORDINATE_PATTERN = re.compile( r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*,\s*" r"([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*$" @@ -36,6 +36,20 @@ def parse_coordinates(value: str) -> tuple[float, float] | None: return lat, lon +def parse_viewbox() -> tuple[float, float, float, float] | None: + """Parse an optional west/south/east/north search-bias bounding box.""" + names = ("west", "south", "east", "north") + values = [request.args.get(name) for name in names] + if not any(value is not None for value in values): + return None + if any(value is None for value in values): + raise ValueError("All four viewbox coordinates are required.") + west, south, east, north = (float(value) for value in values if value is not None) + if not (-180 <= west < east <= 180 and -90 <= south < north <= 90): + raise ValueError("The search viewbox is invalid.") + return west, south, east, north + + def create_app(test_config: dict[str, Any] | None = None) -> Flask: """Create and configure the bus stop finder application.""" app = Flask(__name__) @@ -47,12 +61,21 @@ def create_app(test_config: dict[str, Any] | None = None) -> Flask: """Render the bus stop finder.""" return render_template("index.html") + @app.get("/about") + def about() -> ResponseReturnValue: + """Explain the finder, its shareable URLs, and its data sources.""" + return render_template("about.html") + @app.get("/api/search") def search() -> ResponseReturnValue: """Search directly by ATCO code or geocode a UK location.""" query = request.args.get("q", "").strip() if not query: return _error("missing_query", "Enter a postcode, place, street or ATCO code.", 400) + try: + viewbox = parse_viewbox() + except ValueError as exc: + return _error("invalid_viewbox", str(exc), 400) try: coordinates = parse_coordinates(query) if coordinates is not None: @@ -66,11 +89,10 @@ def create_app(test_config: dict[str, Any] | None = None) -> Flask: if ATCO_PATTERN.fullmatch(query): stops = find_atco_code(query) return jsonify({"kind": "atco", "label": query.upper(), "stops": stops}) - location = geocode(query) - if location is None: + locations = geocode(query, viewbox=viewbox) + if not locations: return _error("not_found", "No UK location matched that search.", 404) - stops = nearby_stops(location["lat"], location["lon"]) - return jsonify({"kind": "location", "location": location, "stops": stops}) + return jsonify({"kind": "geocode", "query": query, "locations": locations}) except UpstreamError as exc: return _error("upstream_error", str(exc), 502) diff --git a/src/uk_bus_stops/core.py b/src/uk_bus_stops/core.py index 84de7fe..e59b1d8 100644 --- a/src/uk_bus_stops/core.py +++ b/src/uk_bus_stops/core.py @@ -32,26 +32,39 @@ def _get_json(url: str, *, params: dict[str, Any]) -> Any: raise UpstreamError("The OpenStreetMap service is temporarily unavailable.") from exc -def geocode(query: str) -> dict[str, Any] | None: - """Geocode a UK postcode, street, or place name with Nominatim.""" +def geocode( + query: str, viewbox: tuple[float, float, float, float] | None = None +) -> list[dict[str, Any]]: + """Return UK-only matches, optionally biased to a west/south/east/north box.""" + params: dict[str, Any] = { + "q": query, + "format": "jsonv2", + "limit": 20, + "countrycodes": "gb", + "addressdetails": 1, + "dedupe": 1, + "bounded": 0, + } + if viewbox is not None: + west, south, east, north = viewbox + params["viewbox"] = f"{west},{north},{east},{south}" results = _get_json( NOMINATIM_URL, - params={ - "q": query, - "format": "jsonv2", - "limit": 1, - "countrycodes": "gb", - "addressdetails": 1, - }, + params=params, ) - if not isinstance(results, list) or not results: - return None - result = results[0] - return { - "lat": float(result["lat"]), - "lon": float(result["lon"]), - "label": result.get("display_name", query), - } + if not isinstance(results, list): + return [] + locations: list[dict[str, Any]] = [] + for result in results: + if not isinstance(result, dict) or "lat" not in result or "lon" not in result: + continue + locations.append({ + "lat": float(result["lat"]), + "lon": float(result["lon"]), + "label": result.get("display_name", query), + "type": result.get("type"), + }) + return locations def _overpass(query: str) -> list[dict[str, Any]]: diff --git a/src/uk_bus_stops/static/about.css b/src/uk_bus_stops/static/about.css new file mode 100644 index 0000000..30d06ab --- /dev/null +++ b/src/uk_bus_stops/static/about.css @@ -0,0 +1,23 @@ +body { color: #1c2333; background: #f8f9fa; } +.navbar { min-height: 56px; } +.navbar-brand { font-size: 1.05rem; } +.doc-column { max-width: 1100px; } +.doc-column h1 { font-size: clamp(1.8rem, 4vw, 2.5rem); margin-bottom: 1rem; } +.doc-column h2 { margin-top: 2.25rem; margin-bottom: .8rem; font-size: 1.35rem; scroll-margin-top: 1rem; } +.doc-column p, .doc-column li { line-height: 1.7; } +.doc-column code { overflow-wrap: anywhere; } +.share-table { min-width: 900px; } +.share-table th:nth-child(1), .share-table td:nth-child(1) { width: 14%; } +.share-table th:nth-child(2), .share-table td:nth-child(2) { width: 51%; } +.share-table th:nth-child(3), .share-table td:nth-child(3) { width: 35%; } +.share-table td:nth-child(3), .share-table td:nth-child(3) code { + white-space: nowrap; + overflow-wrap: normal; +} +.endpoint-table td:first-child { width: 58%; } +@media (max-width: 575.98px) { + .navbar-brand { font-size: .95rem; } + .navbar .nav-link { font-size: .75rem !important; } + .doc-column .lead { font-size: 1.05rem; } + .endpoint-table td:first-child { width: auto; } +} diff --git a/src/uk_bus_stops/static/app.js b/src/uk_bus_stops/static/app.js index ade616d..6bb67a9 100644 --- a/src/uk_bus_stops/static/app.js +++ b/src/uk_bus_stops/static/app.js @@ -58,6 +58,19 @@ function setSearchUrl(params, replace = false) { history[replace ? 'replaceState' : 'pushState'](null, '', url); } +/** Build a text-search API URL biased toward the map without restricting results. */ +function locationSearchApiUrl(query) { + const bounds = map.getBounds(); + const params = new URLSearchParams({ + q: query, + west: bounds.getWest().toFixed(7), + south: bounds.getSouth().toFixed(7), + east: bounds.getEast().toFixed(7), + north: bounds.getNorth().toFixed(7), + }); + return `${API_URLS.search}?${params}`; +} + /** Perform a map movement without treating it as a user pan. */ function moveMap(callback, onComplete = null) { userMapInteractionPending = false; @@ -94,7 +107,7 @@ function restoreSearchFromUrl() { const query = params.get('q'); if (query) { byId('search-input').value = query; - loadUrl(`${API_URLS.search}?${new URLSearchParams({q: query})}`); + loadUrl(locationSearchApiUrl(query)); return; } @@ -110,6 +123,7 @@ function restoreSearchFromUrl() { } byId('search-input').value = ''; + show('geocode-panel', false); markers.clearLayers(); if (searchMarker) { searchMarker.remove(); searchMarker = null; } if (selectedMarkerHalo) { selectedMarkerHalo.remove(); selectedMarkerHalo = null; } @@ -139,6 +153,7 @@ function stopApiUrl(template, type, id) { /** Fetch and display the stop named by a shareable URL. */ async function loadSharedStop(type, id) { show('alert', false); + show('geocode-panel', false); setLoading(true, 'Loading stop…'); try { const response = await fetch(stopApiUrl(API_URLS.stopTemplate, type, id)); @@ -232,6 +247,7 @@ function renderStops(data, fitMap = true, sortOrigin = null, preserveSelection = if (!preserveSelection) { show('stop-detail', false); show('results-panel'); + show('geocode-panel', false); } byId('results-heading').textContent = data.kind === 'atco' ? `ATCO code ${data.label}` @@ -287,6 +303,46 @@ function renderStops(data, fitMap = true, sortOrigin = null, preserveSelection = if (isMobile() && data.kind !== 'map') setPanel(true); } +/** Show UK-only Nominatim matches and wait for the user to choose one. */ +function renderGeocodeChoices(data) { + selectedStop = null; + markers.clearLayers(); + if (searchMarker) { searchMarker.remove(); searchMarker = null; } + if (selectedMarkerHalo) { selectedMarkerHalo.remove(); selectedMarkerHalo = null; } + show('stop-detail', false); + show('results-panel', false); + show('geocode-panel'); + byId('geocode-summary').textContent = `${data.locations.length} UK match${data.locations.length === 1 ? '' : 'es'} for “${data.query}”`; + const list = byId('geocode-list'); + list.replaceChildren(); + for (const locationResult of data.locations) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'stop-item location-item'; + const title = document.createElement('span'); + title.className = 'stop-title'; + title.textContent = locationResult.label; + button.appendChild(title); + if (locationResult.type) { + const meta = document.createElement('span'); + meta.className = 'stop-meta text-capitalize'; + meta.textContent = String(locationResult.type).replaceAll('_', ' '); + button.appendChild(meta); + } + button.addEventListener('click', () => chooseLocation(locationResult)); + list.appendChild(button); + } + if (isMobile()) setPanel(true); +} + +/** Load nearby stops for one selected Nominatim result. */ +function chooseLocation(locationResult) { + const coordinates = {lat: locationResult.lat, lon: locationResult.lon}; + setSearchUrl(new URLSearchParams(coordinates)); + const apiParams = new URLSearchParams({...coordinates, label: locationResult.label}); + loadUrl(`${API_URLS.stops}?${apiParams}`); +} + /** Choose marker colours that make different transport modes easy to scan. */ function markerColour(stop) { const type = stop.transport_type || ''; @@ -430,7 +486,8 @@ async function loadUrl(url) { const response = await fetch(url); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Search failed.'); - renderStops(data); + if (data.kind === 'geocode') renderGeocodeChoices(data); + else renderStops(data); } catch (error) { showError(error.message || 'Network error. Please try again.'); } finally { @@ -489,7 +546,7 @@ byId('search-form').addEventListener('submit', event => { } else { const params = new URLSearchParams({q: query}); setSearchUrl(params); - loadUrl(`${API_URLS.search}?${params}`); + loadUrl(locationSearchApiUrl(query)); } }); diff --git a/src/uk_bus_stops/templates/about.html b/src/uk_bus_stops/templates/about.html new file mode 100644 index 0000000..4a22c57 --- /dev/null +++ b/src/uk_bus_stops/templates/about.html @@ -0,0 +1,129 @@ + + + + + + + About – UK Bus Stop Finder + + + + + + +
+
+

About the UK Bus Stop Finder

+

+ This tool makes it easier to find the ATCO code recorded for a UK public + transport stop in OpenStreetMap. ATCO codes are used by journey-planning + services and integrations such as Home Assistant bus departure displays. +

+

+ Search by postcode, street, place, ATCO code, or coordinates. Select a + result to see its code, direction, coordinates, OSM tags, and associated + bus route relations. Stops without an ATCO code are still shown and link + to the OpenStreetMap editor. +

+ +

Shareable URL parameters

+

Use one search form at a time; selecting a stop replaces search parameters with its OSM object ID.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ You can also enter coordinates directly in the search box as + latitude, longitude, for example 51.4545, -2.5879. + Browser location searches produce a shareable lat/lon URL. +

+ +

Using the map

+
    +
  • Search results are sorted by distance from the searched point.
  • +
  • After moving the map, results are sorted from the new map centre.
  • +
  • Text searches use the visible map as a Nominatim ranking preference, but matches outside the view remain eligible.
  • +
  • Stops in the visible area load automatically at zoom level 15 or closer.
  • +
  • Wider views do not query Overpass, to avoid requesting an excessively large area.
  • +
  • Selecting a stop highlights it and refreshes surrounding markers without closing its details.
  • +
+ +

Data and availability

+

+ Stop and route data comes from + OpenStreetMap contributors. + Place searches use Nominatim. Transport + queries use the Britain-and-Ireland Overpass instance operated at + overpass.atownsend.org.uk, + with Private.coffee as a fallback. +

+

+ Public Overpass services can occasionally be busy. OSM data may be incomplete + or out of date, and this finder is not an official source of NaPTAN data. + If a code or stop is missing, use the edit link on its detail panel and follow + OpenStreetMap's editing and source requirements. +

+ +

Location and privacy

+

+ “Use my current location” asks the browser for permission. The coordinates are + sent to this application to find nearby stops and appear in the shareable URL; + the application does not intentionally persist search or location history. + Requests to map tiles, Nominatim, and Overpass are subject to those services' + own policies and operational logs. +

+ +

JSON endpoints

+

The interface uses these endpoints. Errors have the form {"error": "code", "message": "text"}.

+
+ + + + + + + + + +
EndpointPurpose
GET {{ url_for('search') }}?q=…Search a place, coordinates, or ATCO code.
GET {{ url_for('stops') }}?lat=…&lon=…Stops within 1 km of a point.
GET {{ url_for('bounded_stops') }}?south=…&west=…&north=…&east=…Stops in a safely limited map area.
GET {{ url_for('stop_detail', element_type='node', element_id=485403178) }}One stop by OSM type and ID.
GET {{ url_for('stop_routes', element_type='node', element_id=485403178) }}Stop-area-aware bus routes serving a stop.
+
+ +

Open the stop finder

+
+
+ + diff --git a/src/uk_bus_stops/templates/index.html b/src/uk_bus_stops/templates/index.html index e02289a..add876a 100644 --- a/src/uk_bus_stops/templates/index.html +++ b/src/uk_bus_stops/templates/index.html @@ -12,7 +12,10 @@
@@ -41,6 +44,12 @@ Finding stops… +
+

Choose a location

+

+
+
+

Nearby stops

diff --git a/tests/test_uk_bus_stops.py b/tests/test_uk_bus_stops.py index 83c7b95..db765c2 100644 --- a/tests/test_uk_bus_stops.py +++ b/tests/test_uk_bus_stops.py @@ -25,9 +25,13 @@ def test_geocode_limits_search_to_great_britain_and_identifies_app() -> None: json=[{"lat": "51.45", "lon": "-2.59", "display_name": "Bristol, England"}], ) result = core.geocode("Bristol") - assert result == {"lat": 51.45, "lon": -2.59, "label": "Bristol, England"} + assert result == [{ + "lat": 51.45, "lon": -2.59, "label": "Bristol, England", "type": None, + }] request = responses.calls[0].request assert "countrycodes=gb" in request.url + assert "limit=20" in request.url + assert "bounded=0" in request.url assert request.headers["User-Agent"].startswith("uk-bus-stops/") assert "edward@4angle.com" in request.headers["User-Agent"] @@ -143,6 +147,7 @@ def test_place_name_is_not_mistaken_for_atco_code() -> None: """Long alphabetic place names still go through Nominatim geocoding.""" assert ATCO_PATTERN.fullmatch("Manchester") is None assert ATCO_PATTERN.fullmatch("0100BRP90314") is not None + assert ATCO_PATTERN.fullmatch("010000056") is not None def test_parse_coordinates_accepts_valid_pair_and_rejects_invalid_pair() -> None: @@ -165,6 +170,45 @@ def test_search_coordinates_bypasses_nominatim(client: Any) -> None: assert responses.calls[0].request.url.startswith(core.OVERPASS_URL) +@responses.activate +def test_geocode_uses_unbounded_map_bias() -> None: + """The map view biases Nominatim ranking without restricting its results.""" + responses.get(core.NOMINATIM_URL, json=[]) + assert core.geocode("North Street", viewbox=(-2.7, 51.4, -2.5, 51.5)) == [] + url = responses.calls[0].request.url + assert "viewbox=-2.7%2C51.5%2C-2.5%2C51.4" in url + assert "bounded=0" in url + + +@responses.activate +def test_place_search_returns_uk_choices_before_loading_stops(client: Any) -> None: + """Ambiguous place searches return UK Nominatim choices without querying Overpass.""" + responses.get(core.NOMINATIM_URL, json=[ + {"lat": "51.1", "lon": "-1.3", "display_name": "North Street, Winchester", + "type": "residential"}, + {"lat": "51.4", "lon": "-2.6", "display_name": "North Street, Bristol", + "type": "secondary"}, + ]) + response = client.get( + "/api/search?q=North+Street&west=-2.7&south=51.4&east=-2.5&north=51.5" + ) + assert response.status_code == 200 + data = response.get_json() + assert data["kind"] == "geocode" + assert [item["label"] for item in data["locations"]] == [ + "North Street, Winchester", "North Street, Bristol", + ] + assert len(responses.calls) == 1 + assert "viewbox=-2.7%2C51.5%2C-2.5%2C51.4" in responses.calls[0].request.url + + +def test_search_rejects_partial_viewbox(client: Any) -> None: + """Search-bias bounds must contain all four valid coordinates.""" + response = client.get("/api/search?q=North+Street&west=-2.7") + assert response.status_code == 400 + assert response.get_json()["error"] == "invalid_viewbox" + + def test_location_coordinates_are_validated(client: Any) -> None: """Latitude and longitude must be numeric and within geographic ranges.""" assert client.get("/api/stops?lat=hello&lon=1").status_code == 400 @@ -211,3 +255,16 @@ def test_index_contains_search_and_map(client: Any) -> None: assert b"Find a UK bus stop code" in response.data assert b'id="search-input"' in response.data assert b'id="map"' in response.data + assert b'href="/about"' in response.data + + +def test_about_documents_urls_and_services(client: Any) -> None: + """The About page documents shareable links, APIs, and OSM data sources.""" + response = client.get("/about") + assert response.status_code == 200 + assert b"Shareable URL parameters" in response.data + assert b"?q=Bristol+Temple+Meads" in response.data + assert b"?node=485403178" in response.data + assert b"?lat=51.4545&lon=-2.5879" in response.data + assert b"overpass.atownsend.org.uk" in response.data + assert b"JSON endpoints" in response.data diff --git a/tests/test_uk_bus_stops_playwright.py b/tests/test_uk_bus_stops_playwright.py index d258d8c..892e3d7 100644 --- a/tests/test_uk_bus_stops_playwright.py +++ b/tests/test_uk_bus_stops_playwright.py @@ -39,7 +39,11 @@ def test_search_and_stop_details_in_browser(chromium_browser: Any, flask_url: st """A location search renders stops, an ATCO code, tags, and route metadata.""" page = chromium_browser.new_page(viewport={"width": 1280, "height": 800}) page_errors: list[str] = [] + search_requests: list[str] = [] page.on("pageerror", lambda error: page_errors.append(str(error))) + page.on("request", lambda request: ( + search_requests.append(request.url) if "/api/search?" in request.url else None + )) stop = { "type": "node", "id": 123, "lat": 51.451, "lon": -2.591, "name": "Central Stop", "atco_code": "0100BRP90314", "indicator": "N", @@ -48,14 +52,12 @@ def test_search_and_stop_details_in_browser(chromium_browser: Any, flask_url: st "tags": {"highway": "bus_stop", "shelter": "yes"}, } page.route("**/api/search?*", lambda route: route.fulfill(json={ - "kind": "location", - "location": {"lat": 51.45, "lon": -2.59, "label": "Bristol"}, - "stops": [{ - "type": "node", "id": 125, "lat": 51.47, "lon": -2.61, - "name": "Far Stop", "atco_code": "0100BRP90316", "indicator": None, - "bearing": "E", "transport_type": "Bus stop", - "tags": {"highway": "bus_stop"}, - }, stop], + "kind": "geocode", + "query": "Bristol", + "locations": [ + {"lat": 51.45, "lon": -2.59, "label": "Bristol Centre", "type": "city"}, + {"lat": 51.2, "lon": -2.7, "label": "Bristol Road, Somerset", "type": "road"}, + ], })) page.route("**/api/stops?*", lambda route: route.fulfill(json={ "kind": "location", @@ -85,8 +87,12 @@ def test_search_and_stop_details_in_browser(chromium_browser: Any, flask_url: st page.get_by_label("Location or ATCO code").fill("Bristol") page.get_by_role("button", name="Search").click() playwright_api.expect(page).to_have_url(re.compile(r"\?q=Bristol$")) + assert all(key in search_requests[-1] for key in ("west=", "south=", "east=", "north=")) + playwright_api.expect(page.get_by_text("2 UK matches for “Bristol”", exact=True)).to_be_visible() + page.get_by_text("Bristol Centre", exact=True).click() + playwright_api.expect(page).to_have_url(re.compile(r"\?lat=51.45&lon=-2.59$")) playwright_api.expect(page.get_by_text("Central Stop", exact=True)).to_be_visible() - assert page.locator("#stop-list .stop-title").all_inner_texts() == ["Central Stop", "Far Stop"] + assert page.locator("#stop-list .stop-title").all_inner_texts() == ["Central Stop", "Nearby Stop"] playwright_api.expect(page.locator("#stop-list .stop-meta").first).to_contain_text("m · Bus stop") playwright_api.expect(page.locator("#stop-list .stop-meta").first).to_contain_text("NW-bound") assert page.evaluate("map.getZoom()") > 6