Improve stop search and add documentation
This commit is contained in:
parent
42484907ac
commit
71e1b57164
9 changed files with 358 additions and 37 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
23
src/uk_bus_stops/static/about.css
Normal file
23
src/uk_bus_stops/static/about.css
Normal 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; }
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
129
src/uk_bus_stops/templates/about.html
Normal file
129
src/uk_bus_stops/templates/about.html
Normal 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&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=…&lon=…</code></td><td>Stops within 1 km of a point.</td></tr>
|
||||
<tr><td><code>GET {{ url_for('bounded_stops') }}?south=…&west=…&north=…&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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue