Improve stop search and add documentation

This commit is contained in:
Edward Betts 2026-08-15 12:06:23 +01:00
parent 42484907ac
commit 71e1b57164
9 changed files with 358 additions and 37 deletions

View file

@ -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)

View file

@ -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]]:

View file

@ -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; }
}

View file

@ -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));
}
});

View file

@ -0,0 +1,129 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="About the UK Bus Stop Finder and its shareable URL parameters.">
<title>About UK Bus Stop Finder</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='about.css') }}">
</head>
<body>
<nav class="navbar navbar-dark bg-dark px-3">
<a class="navbar-brand" href="{{ url_for('index') }}">UK Bus Stop Finder</a>
<div class="d-flex align-items-center gap-3">
<a class="nav-link text-white small" href="{{ url_for('index') }}">Map</a>
<a class="nav-link text-white small" href="https://openstreetmap.tools/">OSM tools</a>
</div>
</nav>
<main class="container py-4 py-md-5">
<div class="doc-column">
<h1>About the UK Bus Stop Finder</h1>
<p class="lead">
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.
</p>
<p>
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.
</p>
<h2 id="urls">Shareable URL parameters</h2>
<p>Use one search form at a time; selecting a stop replaces search parameters with its OSM object ID.</p>
<div class="table-responsive">
<table class="table table-bordered align-middle share-table">
<thead><tr><th>Parameters</th><th>Meaning</th><th>Example</th></tr></thead>
<tbody>
<tr>
<td><code>q</code></td>
<td>A postcode, street, place name, or exact ATCO code. Place searches show up to 20 UK-only matches to choose from.</td>
<td><a href="{{ url_for('index', q='Bristol Temple Meads') }}"><code>{{ url_for('index') }}?q=Bristol+Temple+Meads</code></a></td>
</tr>
<tr>
<td><code>lat</code> and <code>lon</code></td>
<td>Decimal latitude and longitude. Both are required. The map opens around this point.</td>
<td><a href="{{ url_for('index', lat='51.4545', lon='-2.5879') }}"><code>{{ url_for('index') }}?lat=51.4545&amp;lon=-2.5879</code></a></td>
</tr>
<tr>
<td><code>node</code></td>
<td>An OpenStreetMap node ID. The stop opens with nearby stops visible.</td>
<td><a href="{{ url_for('index', node='485403178') }}"><code>{{ url_for('index') }}?node=485403178</code></a></td>
</tr>
<tr>
<td><code>way</code></td>
<td>An OSM way ID, for the less common stop mapped as an area.</td>
<td><code>{{ url_for('index') }}?way=123456</code></td>
</tr>
<tr>
<td><code>relation</code></td>
<td>An OSM relation ID used as a stop object.</td>
<td><code>{{ url_for('index') }}?relation=123456</code></td>
</tr>
</tbody>
</table>
</div>
<p>
You can also enter coordinates directly in the search box as
<code>latitude, longitude</code>, for example <code>51.4545, -2.5879</code>.
Browser location searches produce a shareable <code>lat</code>/<code>lon</code> URL.
</p>
<h2 id="map">Using the map</h2>
<ul>
<li>Search results are sorted by distance from the searched point.</li>
<li>After moving the map, results are sorted from the new map centre.</li>
<li>Text searches use the visible map as a Nominatim ranking preference, but matches outside the view remain eligible.</li>
<li>Stops in the visible area load automatically at zoom level 15 or closer.</li>
<li>Wider views do not query Overpass, to avoid requesting an excessively large area.</li>
<li>Selecting a stop highlights it and refreshes surrounding markers without closing its details.</li>
</ul>
<h2 id="data">Data and availability</h2>
<p>
Stop and route data comes from
<a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>.
Place searches use <a href="https://nominatim.org/">Nominatim</a>. Transport
queries use the Britain-and-Ireland Overpass instance operated at
<a href="https://overpass.atownsend.org.uk/">overpass.atownsend.org.uk</a>,
with <a href="https://overpass.private.coffee/">Private.coffee</a> as a fallback.
</p>
<p>
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.
</p>
<h2 id="privacy">Location and privacy</h2>
<p>
“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.
</p>
<h2 id="api">JSON endpoints</h2>
<p>The interface uses these endpoints. Errors have the form <code>{"error": "code", "message": "text"}</code>.</p>
<div class="table-responsive">
<table class="table table-sm table-striped endpoint-table">
<thead><tr><th>Endpoint</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td><code>GET {{ url_for('search') }}?q=…</code></td><td>Search a place, coordinates, or ATCO code.</td></tr>
<tr><td><code>GET {{ url_for('stops') }}?lat=…&amp;lon=…</code></td><td>Stops within 1 km of a point.</td></tr>
<tr><td><code>GET {{ url_for('bounded_stops') }}?south=…&amp;west=…&amp;north=…&amp;east=…</code></td><td>Stops in a safely limited map area.</td></tr>
<tr><td><code>GET {{ url_for('stop_detail', element_type='node', element_id=485403178) }}</code></td><td>One stop by OSM type and ID.</td></tr>
<tr><td><code>GET {{ url_for('stop_routes', element_type='node', element_id=485403178) }}</code></td><td>Stop-area-aware bus routes serving a stop.</td></tr>
</tbody>
</table>
</div>
<p class="mt-4"><a class="btn btn-primary" href="{{ url_for('index') }}">Open the stop finder</a></p>
</div>
</main>
</body>
</html>

View file

@ -12,7 +12,10 @@
<body>
<nav class="navbar navbar-dark bg-dark px-3">
<a class="navbar-brand" href="{{ url_for('index') }}">UK Bus Stop Finder</a>
<a class="nav-link text-white small" href="https://openstreetmap.tools/">OSM tools</a>
<div class="d-flex align-items-center gap-3">
<a class="nav-link text-white small" href="{{ url_for('about') }}">About</a>
<a class="nav-link text-white small" href="https://openstreetmap.tools/">OSM tools</a>
</div>
</nav>
<main id="main-row">
@ -41,6 +44,12 @@
<span class="spinner-border spinner-border-sm me-1"></span><span id="loading-text">Finding stops…</span>
</div>
<section id="geocode-panel" class="d-none">
<h2 class="h6 mb-1">Choose a location</h2>
<p id="geocode-summary" class="small text-muted mb-2"></p>
<div id="geocode-list"></div>
</section>
<section id="results-panel" class="d-none">
<div class="d-flex justify-content-between align-items-baseline mb-2">
<h2 id="results-heading" class="h6 mb-0">Nearby stops</h2>