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.
+
+
+
Parameters
Meaning
Example
+
+
+
q
+
A postcode, street, place name, or exact ATCO code. Place searches show up to 20 UK-only matches to choose from.
An OSM way ID, for the less common stop mapped as an area.
+
{{ url_for('index') }}?way=123456
+
+
+
relation
+
An OSM relation ID used as a stop object.
+
{{ url_for('index') }}?relation=123456
+
+
+
+
+
+ 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.
+ 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"}.
+
+
+
Endpoint
Purpose
+
+
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) }}