Add UK bus stop finder

This commit is contained in:
Edward Betts 2026-08-15 11:35:36 +01:00
parent 977008d6bd
commit a2e4292b46
12 changed files with 1569 additions and 0 deletions

View file

@ -0,0 +1,5 @@
"""Find UK bus stop ATCO codes using OpenStreetMap data."""
from uk_bus_stops.app import create_app
__all__ = ["create_app"]

158
src/uk_bus_stops/app.py Normal file
View file

@ -0,0 +1,158 @@
"""Flask application for finding UK bus stop ATCO codes."""
from __future__ import annotations
import re
from typing import Any
from flask import Flask, Response, jsonify, render_template, request
from flask.typing import ResponseReturnValue
from uk_bus_stops.core import (
UpstreamError,
fetch_stop,
find_atco_code,
geocode,
nearby_stops,
routes_for_stop,
stops_in_bounds,
)
ATCO_PATTERN = re.compile(r"^(?=[A-Za-z0-9]{10,16}$)(?=.*\d)[A-Za-z0-9]+$")
COORDINATE_PATTERN = re.compile(
r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*,\s*"
r"([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*$"
)
def parse_coordinates(value: str) -> tuple[float, float] | None:
"""Parse a valid ``latitude, longitude`` pair from user input."""
match = COORDINATE_PATTERN.fullmatch(value)
if match is None:
return None
lat, lon = float(match.group(1)), float(match.group(2))
if not -90 <= lat <= 90 or not -180 <= lon <= 180:
return None
return lat, lon
def create_app(test_config: dict[str, Any] | None = None) -> Flask:
"""Create and configure the bus stop finder application."""
app = Flask(__name__)
if test_config:
app.config.update(test_config)
@app.get("/")
def index() -> ResponseReturnValue:
"""Render the bus stop finder."""
return render_template("index.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:
coordinates = parse_coordinates(query)
if coordinates is not None:
lat, lon = coordinates
coordinate_location = {"lat": lat, "lon": lon, "label": f"{lat}, {lon}"}
return jsonify({
"kind": "location",
"location": coordinate_location,
"stops": nearby_stops(lat, lon),
})
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:
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})
except UpstreamError as exc:
return _error("upstream_error", str(exc), 502)
@app.get("/api/stops")
def stops() -> ResponseReturnValue:
"""Find stops around coordinates, primarily for browser geolocation."""
try:
lat = float(request.args["lat"])
lon = float(request.args["lon"])
except (KeyError, ValueError):
return _error("invalid_location", "Valid latitude and longitude are required.", 400)
if not -90 <= lat <= 90 or not -180 <= lon <= 180:
return _error("invalid_location", "Latitude or longitude is out of range.", 400)
try:
label = request.args.get("label", "").strip() or f"{lat}, {lon}"
return jsonify({
"kind": "location",
"location": {"lat": lat, "lon": lon, "label": label},
"stops": nearby_stops(lat, lon),
})
except UpstreamError as exc:
return _error("upstream_error", str(exc), 502)
@app.get("/api/stop/<element_type>/<int:element_id>/routes")
def stop_routes(element_type: str, element_id: int) -> ResponseReturnValue:
"""Return stop-area-aware OSM bus routes for one stop object."""
if element_type not in {"node", "way", "relation"}:
return _error("invalid_stop", "Unsupported OpenStreetMap object type.", 404)
try:
return jsonify({"routes": routes_for_stop(element_type, element_id)})
except UpstreamError as exc:
return _error("upstream_error", str(exc), 502)
@app.get("/api/stop/<element_type>/<int:element_id>")
def stop_detail(element_type: str, element_id: int) -> ResponseReturnValue:
"""Return one OSM stop for a shareable stop URL."""
if element_type not in {"node", "way", "relation"}:
return _error("invalid_stop", "Unsupported OpenStreetMap object type.", 404)
try:
stop = fetch_stop(element_type, element_id)
if stop is None:
return _error("stop_not_found", "That OpenStreetMap stop was not found.", 404)
return jsonify({"stop": stop})
except UpstreamError as exc:
return _error("upstream_error", str(exc), 502)
@app.get("/api/stops/in-bounds")
def bounded_stops() -> ResponseReturnValue:
"""Find stops in a safely limited visible map area."""
try:
south = float(request.args["south"])
west = float(request.args["west"])
north = float(request.args["north"])
east = float(request.args["east"])
except (KeyError, ValueError):
return _error("invalid_bounds", "Valid map bounds are required.", 400)
if not (-90 <= south < north <= 90 and -180 <= west < east <= 180):
return _error("invalid_bounds", "The map bounds are invalid.", 400)
lat_span = north - south
lon_span = east - west
if lat_span > 0.15 or lon_span > 0.2 or lat_span * lon_span > 0.015:
return _error("area_too_large", "Zoom in further to load transport stops.", 400)
try:
return jsonify({"kind": "map", "stops": stops_in_bounds(south, west, north, east)})
except UpstreamError as exc:
return _error("upstream_error", str(exc), 502)
return app
def _error(code: str, message: str, status: int) -> tuple[Response, int]:
"""Build the common JSON error shape."""
return jsonify({"error": code, "message": message}), status
app = create_app()
def main() -> None:
"""Run the development server from the console script."""
app.run(debug=True)
if __name__ == "__main__":
main()

223
src/uk_bus_stops/core.py Normal file
View file

@ -0,0 +1,223 @@
"""Nominatim and Overpass clients for the UK bus stop finder."""
from __future__ import annotations
from typing import Any, cast
import requests
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
OVERPASS_URL = "https://overpass.atownsend.org.uk/api/interpreter"
OVERPASS_FALLBACK_URLS = ("https://overpass.private.coffee/api/interpreter",)
USER_AGENT = "uk-bus-stops/0.1 (https://openstreetmap.tools/uk-bus-stops; edward@4angle.com)"
TIMEOUT = 30
class UpstreamError(Exception):
"""Report a failure while talking to an OpenStreetMap service."""
def _get_json(url: str, *, params: dict[str, Any]) -> Any:
"""Fetch JSON from an OSM service with the application's identity."""
try:
response = requests.get(
url,
params=params,
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
timeout=TIMEOUT,
)
response.raise_for_status()
return response.json()
except (requests.RequestException, ValueError) as exc:
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."""
results = _get_json(
NOMINATIM_URL,
params={
"q": query,
"format": "jsonv2",
"limit": 1,
"countrycodes": "gb",
"addressdetails": 1,
},
)
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),
}
def _overpass(query: str) -> list[dict[str, Any]]:
"""Run an Overpass query, trying a second public instance on failure."""
last_error: UpstreamError | None = None
for url in (OVERPASS_URL, *OVERPASS_FALLBACK_URLS):
try:
data = _get_json(url, params={"data": query})
if not isinstance(data, dict) or not isinstance(data.get("elements"), list):
raise UpstreamError("Overpass returned an unexpected response.")
return cast(list[dict[str, Any]], data["elements"])
except UpstreamError as exc:
last_error = exc
assert last_error is not None
raise last_error
def _normalise_stop(element: dict[str, Any]) -> dict[str, Any] | None:
"""Convert an Overpass element with a position into frontend stop data."""
center = element.get("center", {})
lat = element.get("lat", center.get("lat"))
lon = element.get("lon", center.get("lon"))
if lat is None or lon is None:
return None
tags = element.get("tags", {})
return {
"type": element["type"],
"id": element["id"],
"lat": lat,
"lon": lon,
"name": tags.get("name") or tags.get("naptan:CommonName") or "Unnamed bus stop",
"transport_type": transport_type(tags),
"atco_code": tags.get("naptan:AtcoCode"),
"indicator": tags.get("naptan:Indicator"),
"bearing": tags.get("naptan:Bearing") or tags.get("bearing") or tags.get("direction"),
"tags": tags,
}
def transport_type(tags: dict[str, str]) -> str:
"""Return a human-readable transport type inferred from OSM tags."""
if tags.get("amenity") == "ferry_terminal" or tags.get("ferry") == "yes":
return "Ferry terminal"
if tags.get("station") == "subway" or tags.get("subway") == "yes":
return "Underground station"
if tags.get("railway") == "tram_stop" or tags.get("tram") == "yes":
return "Tram stop"
if tags.get("light_rail") == "yes":
return "Light rail stop"
if tags.get("railway") == "station" or tags.get("train") == "yes":
return "Railway station"
if tags.get("railway") == "halt":
return "Railway halt"
if tags.get("highway") == "bus_stop" or tags.get("bus") == "yes":
return "Bus stop"
if tags.get("public_transport") == "station":
return "Public transport station"
if tags.get("public_transport") == "platform":
return "Public transport platform"
if tags.get("public_transport") == "stop_position":
return "Public transport stop"
return "Transport stop"
def nearby_stops(lat: float, lon: float, radius: int = 1000) -> list[dict[str, Any]]:
"""Return bus stop objects near a coordinate, nearest first."""
query = f"""[out:json][timeout:25];
(
nwr(around:{radius},{lat:.7f},{lon:.7f})[\"naptan:AtcoCode\"];
nwr(around:{radius},{lat:.7f},{lon:.7f})[\"highway\"=\"bus_stop\"];
nwr(around:{radius},{lat:.7f},{lon:.7f})[\"public_transport\"=\"platform\"][\"bus\"=\"yes\"];
);
out center tags;"""
found: dict[tuple[str, int], dict[str, Any]] = {}
for element in _overpass(query):
stop = _normalise_stop(element)
if stop is not None:
found[(stop["type"], stop["id"])] = stop
def distance_squared(stop: dict[str, Any]) -> float:
"""Give a cheap local-distance ordering without another dependency."""
return (float(stop["lat"]) - lat) ** 2 + (float(stop["lon"]) - lon) ** 2
return sorted(found.values(), key=distance_squared)
def stops_in_bounds(
south: float, west: float, north: float, east: float
) -> list[dict[str, Any]]:
"""Return transport stops inside a small map bounding box."""
bbox = f"{south:.7f},{west:.7f},{north:.7f},{east:.7f}"
query = f"""[out:json][timeout:25];
(
nwr({bbox})[\"naptan:AtcoCode\"];
nwr({bbox})[\"highway\"=\"bus_stop\"];
nwr({bbox})[\"public_transport\"=\"platform\"][\"bus\"=\"yes\"];
);
out center tags 1000;"""
found: dict[tuple[str, int], dict[str, Any]] = {}
for element in _overpass(query):
stop = _normalise_stop(element)
if stop is not None:
found[(stop["type"], stop["id"])] = stop
return sorted(found.values(), key=lambda stop: (stop["name"], stop["id"]))
def find_atco_code(code: str) -> list[dict[str, Any]]:
"""Find stops whose ATCO code exactly matches the supplied value."""
escaped = code.replace("\\", "\\\\").replace('"', '\\"')
query = f"""[out:json][timeout:25];
nwr[\"naptan:AtcoCode\"~\"^{escaped}$\",i];
out center tags;"""
return [stop for item in _overpass(query) if (stop := _normalise_stop(item))]
def fetch_stop(element_type: str, element_id: int) -> dict[str, Any] | None:
"""Fetch and normalise one OSM transport stop by object type and ID."""
selector = {"node": "node", "way": "way", "relation": "rel"}.get(element_type)
if selector is None:
return None
query = f"""[out:json][timeout:25];
{selector}({element_id});
out center tags;"""
for element in _overpass(query):
stop = _normalise_stop(element)
if stop is not None:
return stop
return None
def routes_for_stop(element_type: str, element_id: int) -> list[dict[str, Any]]:
"""Find bus routes using a stop or any member of its stop area."""
selector = {"node": "node", "way": "way", "relation": "rel"}.get(element_type)
membership = {"node": "bn", "way": "bw", "relation": "br"}.get(element_type)
if selector is None or membership is None:
return []
query = f"""[out:json][timeout:25];
{selector}({element_id})->.selected;
rel({membership}.selected)[\"public_transport\"=\"stop_area\"]->.parent_areas;
(.selected;.parent_areas;)->.areas;
nwr(r.areas)->.related;
rel(bn.selected)[\"route\"=\"bus\"]->.selected_node_routes;
rel(bw.selected)[\"route\"=\"bus\"]->.selected_way_routes;
rel(bn.related)[\"route\"=\"bus\"]->.related_node_routes;
rel(bw.related)[\"route\"=\"bus\"]->.related_way_routes;
(
.selected_node_routes;
.selected_way_routes;
.related_node_routes;
.related_way_routes;
);
out tags;"""
routes: list[dict[str, Any]] = []
seen: set[int] = set()
for item in _overpass(query):
route_id = int(item["id"])
if route_id in seen:
continue
seen.add(route_id)
tags = item.get("tags", {})
routes.append({
"id": route_id,
"ref": tags.get("ref"),
"name": tags.get("name"),
"operator": tags.get("operator"),
"from": tags.get("from"),
"to": tags.get("to"),
})
return sorted(routes, key=lambda route: (route.get("ref") or "", route.get("name") or ""))

View file

@ -0,0 +1,559 @@
/** Interactive map and accessible results UI for the UK bus stop finder. */
'use strict';
const map = L.map('map').setView([54.5, -3], 6);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map);
let stops = [];
let selectedStop = null;
// FeatureGroup provides getBounds(), which is used to frame search results.
let markers = L.featureGroup().addTo(map);
let searchMarker = null;
let selectedMarkerHalo = null;
let userMapInteractionPending = false;
let mapMoveTimer = null;
let mapRequestController = null;
let returnToResults = false;
let returnViaHistory = false;
const MIN_MAP_STOP_ZOOM = 15;
const byId = id => document.getElementById(id);
const isMobile = () => window.innerWidth < 768;
const show = (id, visible = true) => byId(id).classList.toggle('d-none', !visible);
function setPanel(open) {
byId('sidebar').classList.toggle('panel-open', open);
show('panel-button', !open);
setTimeout(() => map.invalidateSize(), 260);
}
function showError(message) {
byId('alert').textContent = message;
show('alert');
}
function setLoading(loading, message = 'Finding stops…') {
byId('loading-text').textContent = message;
show('loading', loading);
byId('search-input').disabled = loading;
}
/** Parse latitude, longitude input while rejecting out-of-range coordinates. */
function parseCoordinates(value) {
const match = value.match(/^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*,\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*$/);
if (!match) return null;
const lat = Number(match[1]);
const lon = Number(match[2]);
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
return {lat, lon};
}
/** Put the current search in the address bar without reloading the page. */
function setSearchUrl(params, replace = false) {
const query = params.toString();
const url = query ? `${location.pathname}?${query}` : location.pathname;
history[replace ? 'replaceState' : 'pushState'](null, '', url);
}
/** Perform a map movement without treating it as a user pan. */
function moveMap(callback, onComplete = null) {
userMapInteractionPending = false;
map.stop();
if (!onComplete) {
callback();
return;
}
let finished = false;
let fallbackTimer = null;
const finish = () => {
if (finished) return;
finished = true;
map.off('moveend', finish);
clearTimeout(fallbackTimer);
onComplete();
};
map.once('moveend', finish);
callback();
if (!finished) fallbackTimer = setTimeout(finish, 500);
}
/** Run the search encoded in q or lat/lon URL parameters. */
function restoreSearchFromUrl() {
const params = new URLSearchParams(location.search);
const stopReference = parseStopReference(params);
if (stopReference) {
returnToResults = false;
returnViaHistory = false;
byId('search-input').value = '';
loadSharedStop(stopReference.type, stopReference.id);
return;
}
const query = params.get('q');
if (query) {
byId('search-input').value = query;
loadUrl(`${API_URLS.search}?${new URLSearchParams({q: query})}`);
return;
}
const latText = params.get('lat');
const lonText = params.get('lon');
const coordinates = latText !== null && lonText !== null
? parseCoordinates(`${latText},${lonText}`)
: null;
if (coordinates) {
byId('search-input').value = `${coordinates.lat}, ${coordinates.lon}`;
loadUrl(`${API_URLS.stops}?${new URLSearchParams(coordinates)}`);
return;
}
byId('search-input').value = '';
markers.clearLayers();
if (searchMarker) { searchMarker.remove(); searchMarker = null; }
if (selectedMarkerHalo) { selectedMarkerHalo.remove(); selectedMarkerHalo = null; }
selectedStop = null;
show('results-panel', false);
show('stop-detail', false);
const center = map.getCenter();
if (map.getZoom() !== 6 || Math.abs(center.lat - 54.5) > 0.0001 || Math.abs(center.lng + 3) > 0.0001) {
moveMap(() => map.setView([54.5, -3], 6));
}
}
/** Parse shareable OSM node, way, or relation parameters. */
function parseStopReference(params) {
for (const type of ['node', 'way', 'relation']) {
const id = params.get(type);
if (id && /^\d+$/.test(id)) return {type, id: Number(id)};
}
return null;
}
/** Build a mounted-path-safe API URL for a particular OSM stop. */
function stopApiUrl(template, type, id) {
return template.replace('/TYPE/', `/${type}/`).replace('/0', `/${id}`);
}
/** Fetch and display the stop named by a shareable URL. */
async function loadSharedStop(type, id) {
show('alert', false);
setLoading(true, 'Loading stop…');
try {
const response = await fetch(stopApiUrl(API_URLS.stopTemplate, type, id));
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Could not load that stop.');
let nearbyData = {
kind: 'location',
location: {lat: data.stop.lat, lon: data.stop.lon, label: data.stop.name},
stops: [data.stop],
};
try {
const nearbyParams = new URLSearchParams({lat: data.stop.lat, lon: data.stop.lon});
const nearbyResponse = await fetch(`${API_URLS.stops}?${nearbyParams}`);
const nearbyResult = await nearbyResponse.json();
if (nearbyResponse.ok) nearbyData = nearbyResult;
} catch (_) {
// The selected stop is still useful if the optional nearby lookup fails.
}
const selectedIndex = nearbyData.stops.findIndex(
stop => stop.type === data.stop.type && stop.id === data.stop.id
);
if (selectedIndex === -1) nearbyData.stops.unshift(data.stop);
else nearbyData.stops[selectedIndex] = data.stop;
renderStops(nearbyData, true);
returnToResults = true;
returnViaHistory = false;
await selectStop(data.stop, false);
} catch (error) {
showError(error.message || 'Network error. Please try again.');
} finally {
setLoading(false);
}
}
function stopSubtitle(stop) {
const parts = [stop.transport_type || 'Transport stop'];
const bearing = bearingLabel(stop);
if (bearing) parts.push(bearing);
if (stop.indicator && !parts.includes(stop.indicator)) parts.push(stop.indicator);
if (stop.atco_code) parts.push(stop.atco_code);
else parts.push('No ATCO code recorded');
return parts.join(' · ');
}
/** Format a compass bearing for concise marker hover text. */
function bearingLabel(stop) {
if (!stop.bearing) return null;
const bearing = String(stop.bearing).trim();
if (!bearing) return null;
return /-bound$/i.test(bearing) ? bearing : `${bearing.toUpperCase()}-bound`;
}
/** Calculate great-circle distance between a stop and a reference point. */
function distanceMetres(stop, origin) {
if (!origin) return null;
const originLon = origin.lon ?? origin.lng;
if (!Number.isFinite(Number(origin.lat)) || !Number.isFinite(Number(originLon))) return null;
const radians = degrees => degrees * Math.PI / 180;
const lat1 = radians(Number(origin.lat));
const lat2 = radians(Number(stop.lat));
const deltaLat = lat2 - lat1;
const deltaLon = radians(Number(stop.lon) - Number(originLon));
const a = Math.sin(deltaLat / 2) ** 2
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/** Format a stop distance compactly for the result list. */
function distanceLabel(distance) {
if (distance === null) return null;
if (distance < 1000) return `${Math.round(distance / 10) * 10} m`;
return `${(distance / 1000).toFixed(1)} km`;
}
function renderStops(data, fitMap = true, sortOrigin = null, preserveSelection = false) {
const origin = data.location || sortOrigin;
stops = [...data.stops]
.map(stop => ({...stop, distance_metres: distanceMetres(stop, origin)}))
.sort((first, second) => {
if (first.distance_metres === null && second.distance_metres === null) return 0;
if (first.distance_metres === null) return 1;
if (second.distance_metres === null) return -1;
return first.distance_metres - second.distance_metres;
});
if (!preserveSelection) {
selectedStop = null;
if (selectedMarkerHalo) { selectedMarkerHalo.remove(); selectedMarkerHalo = null; }
}
if (fitMap) show('map-status', false);
markers.clearLayers();
if (!preserveSelection) {
show('stop-detail', false);
show('results-panel');
}
byId('results-heading').textContent = data.kind === 'atco'
? `ATCO code ${data.label}`
: data.kind === 'map' ? 'Stops in this map area' : 'Nearby stops';
byId('stop-count').textContent = `${stops.length} found`;
const list = byId('stop-list');
list.replaceChildren();
if (searchMarker) { searchMarker.remove(); searchMarker = null; }
if (data.location) {
searchMarker = L.circleMarker([data.location.lat, data.location.lon], {
radius: 7, color: '#0d6efd', fillColor: '#fff', fillOpacity: 1, weight: 3,
}).bindTooltip(data.location.label).addTo(map);
}
for (const stop of stops) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'stop-item';
const title = document.createElement('span');
title.className = 'stop-title';
title.textContent = stop.name;
const meta = document.createElement('span');
meta.className = 'stop-meta';
meta.textContent = [distanceLabel(stop.distance_metres), stopSubtitle(stop)]
.filter(Boolean).join(' · ');
button.append(title, meta);
button.addEventListener('click', () => selectStop(stop));
list.appendChild(button);
const tooltipParts = [stop.name, bearingLabel(stop), stop.transport_type || 'Transport stop'];
const marker = L.circleMarker([stop.lat, stop.lon], {
radius: 7, color: '#fff', weight: 2, fillColor: markerColour(stop), fillOpacity: .95,
}).bindTooltip(tooltipParts.filter(Boolean).join(' · '));
marker.on('click', () => selectStop(stop));
markers.addLayer(marker);
}
if (!stops.length) {
const empty = document.createElement('p');
empty.className = 'small text-muted';
empty.textContent = data.kind === 'map'
? 'No transport stops were found in this map area.'
: 'No transport stops were found within 1 km.';
list.appendChild(empty);
if (fitMap && data.location) moveMap(() => map.setView([data.location.lat, data.location.lon], 16));
} else if (fitMap) {
const bounds = markers.getBounds();
if (searchMarker) bounds.extend(searchMarker.getLatLng());
moveMap(() => map.fitBounds(bounds, {padding: [30, 30], maxZoom: 17}));
if (data.kind === 'atco' && stops.length === 1) selectStop(stops[0]);
}
if (isMobile() && data.kind !== 'map') setPanel(true);
}
/** Choose marker colours that make different transport modes easy to scan. */
function markerColour(stop) {
const type = stop.transport_type || '';
if (type.includes('Railway')) return '#6f42c1';
if (type.includes('Tram')) return '#dc3545';
if (type.includes('Underground')) return '#0d6efd';
if (type.includes('Ferry')) return '#0dcaf0';
if (type.includes('Bus')) return '#198754';
return stop.atco_code ? '#198754' : '#fd7e14';
}
/** Draw a persistent high-contrast ring around the active stop. */
function highlightSelectedStop(stop) {
if (selectedMarkerHalo) selectedMarkerHalo.remove();
selectedMarkerHalo = L.circleMarker([stop.lat, stop.lon], {
radius: 15,
color: '#ffc107',
fillColor: '#ffc107',
fillOpacity: 0.22,
weight: 5,
opacity: 1,
interactive: false,
className: 'selected-stop-marker',
}).addTo(map);
selectedMarkerHalo.bringToFront();
}
function osmUrl(stop, edit = false) {
return `https://www.openstreetmap.org/${edit ? 'edit?' : ''}${edit ? `${stop.type}=${stop.id}` : `${stop.type}/${stop.id}`}`;
}
function renderTags(tags) {
const list = byId('tag-list');
list.replaceChildren();
for (const [key, value] of Object.entries(tags).sort(([a], [b]) => a.localeCompare(b))) {
const term = document.createElement('dt');
term.textContent = key;
const detail = document.createElement('dd');
detail.textContent = value;
list.append(term, detail);
}
}
async function selectStop(stop, updateUrl = true) {
selectedStop = stop;
highlightSelectedStop(stop);
userMapInteractionPending = false;
clearTimeout(mapMoveTimer);
if (mapRequestController) {
mapRequestController.abort();
mapRequestController = null;
}
if (updateUrl) {
const currentParams = new URLSearchParams(location.search);
const alreadyShowingStop = Boolean(parseStopReference(currentParams));
if (!alreadyShowingStop) {
returnToResults = true;
returnViaHistory = true;
}
setSearchUrl(new URLSearchParams({[stop.type]: String(stop.id)}), alreadyShowingStop);
}
show('back-button', returnToResults);
show('results-panel', false);
show('stop-detail');
byId('stop-name').textContent = stop.name;
byId('stop-type').textContent = stop.transport_type || 'Transport stop';
byId('stop-bearing').textContent = bearingLabel(stop) || '';
show('stop-bearing', Boolean(bearingLabel(stop)));
byId('stop-indicator').textContent = stop.indicator ? `Stop ${stop.indicator}` : '';
show('stop-indicator', Boolean(stop.indicator));
byId('coordinates').textContent = `${Number(stop.lat).toFixed(6)}, ${Number(stop.lon).toFixed(6)}`;
byId('osm-link').href = osmUrl(stop);
byId('edit-link').href = osmUrl(stop, true);
byId('atco-code').textContent = stop.atco_code || '';
show('atco-present', Boolean(stop.atco_code));
show('atco-missing', !stop.atco_code);
byId('copy-status').textContent = '';
renderTags(stop.tags);
moveMap(
() => map.setView([stop.lat, stop.lon], Math.max(map.getZoom(), 17)),
() => {
if (selectedStop && selectedStop.type === stop.type && selectedStop.id === stop.id) {
loadVisibleMapStops();
}
}
);
if (isMobile()) setPanel(true);
byId('route-list').replaceChildren();
show('route-loading');
try {
const url = stopApiUrl(API_URLS.routeTemplate, stop.type, stop.id);
const response = await fetch(url);
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Could not load routes.');
renderRoutes(data.routes);
} catch (error) {
byId('route-list').textContent = error.message;
} finally {
show('route-loading', false);
}
}
function renderRoutes(routes) {
const list = byId('route-list');
list.replaceChildren();
if (!routes.length) {
list.textContent = 'No bus route relations were found in OpenStreetMap.';
list.className = 'small text-muted';
return;
}
list.className = '';
for (const route of routes) {
const card = document.createElement('div');
card.className = 'route-card small';
const line = document.createElement('div');
const link = document.createElement('a');
link.href = `https://www.openstreetmap.org/relation/${route.id}`;
link.target = '_blank';
link.rel = 'noopener';
link.textContent = route.ref || route.name || `Route relation ${route.id}`;
link.className = 'route-ref';
line.appendChild(link);
if (route.name && route.name !== route.ref) line.append(` ${route.name}`);
card.appendChild(line);
const details = [route.from && route.to ? `${route.from}${route.to}` : null, route.operator].filter(Boolean);
if (details.length) {
const meta = document.createElement('div');
meta.className = 'text-muted';
meta.textContent = details.join(' · ');
card.appendChild(meta);
}
list.appendChild(card);
}
}
async function loadUrl(url) {
show('alert', false);
setLoading(true);
try {
const response = await fetch(url);
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Search failed.');
renderStops(data);
} catch (error) {
showError(error.message || 'Network error. Please try again.');
} finally {
setLoading(false);
}
}
/** Load stops for the current viewport after a user pan or zoom. */
async function loadVisibleMapStops() {
if (map.getZoom() < MIN_MAP_STOP_ZOOM) {
markers.clearLayers();
byId('map-status').textContent = 'Zoom in to load transport stops';
show('map-status');
return;
}
const bounds = map.getBounds();
const params = new URLSearchParams({
south: bounds.getSouth().toFixed(7),
west: bounds.getWest().toFixed(7),
north: bounds.getNorth().toFixed(7),
east: bounds.getEast().toFixed(7),
});
const center = map.getCenter();
if (!selectedStop) {
setSearchUrl(new URLSearchParams({
lat: center.lat.toFixed(6), lon: center.lng.toFixed(6),
}), true);
}
byId('map-status').textContent = 'Loading stops in this area…';
show('map-status');
if (mapRequestController) mapRequestController.abort();
mapRequestController = new AbortController();
try {
const response = await fetch(`${API_URLS.boundedStops}?${params}`, {
signal: mapRequestController.signal,
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Could not load this map area.');
renderStops(data, false, map.getCenter(), Boolean(selectedStop));
show('map-status', false);
} catch (error) {
if (error.name === 'AbortError') return;
byId('map-status').textContent = error.message || 'Could not load this map area.';
}
}
byId('search-form').addEventListener('submit', event => {
event.preventDefault();
const query = byId('search-input').value.trim();
if (!query) return;
const coordinates = parseCoordinates(query);
if (coordinates) {
const params = new URLSearchParams(coordinates);
setSearchUrl(params);
loadUrl(`${API_URLS.stops}?${params}`);
} else {
const params = new URLSearchParams({q: query});
setSearchUrl(params);
loadUrl(`${API_URLS.search}?${params}`);
}
});
byId('locate-button').addEventListener('click', () => {
show('alert', false);
if (!navigator.geolocation) return showError('Your browser does not support location access.');
setLoading(true, 'Getting your location…');
navigator.geolocation.getCurrentPosition(
position => {
const coordinates = {lat: position.coords.latitude, lon: position.coords.longitude};
const params = new URLSearchParams(coordinates);
setSearchUrl(params);
byId('search-input').value = `${coordinates.lat}, ${coordinates.lon}`;
const apiParams = new URLSearchParams({...coordinates, label: 'Your location'});
loadUrl(`${API_URLS.stops}?${apiParams}`);
},
() => { setLoading(false); showError('Location access was denied or unavailable.'); },
{enableHighAccuracy: true, timeout: 10000}
);
});
byId('back-button').addEventListener('click', () => {
if (returnToResults && returnViaHistory) {
history.back();
return;
}
selectedStop = null;
if (selectedMarkerHalo) {
selectedMarkerHalo.remove();
selectedMarkerHalo = null;
}
show('stop-detail', false);
show('results-panel');
if (returnToResults && stops.length) {
const center = map.getCenter();
setSearchUrl(new URLSearchParams({
lat: center.lat.toFixed(6), lon: center.lng.toFixed(6),
}), true);
}
});
byId('copy-button').addEventListener('click', async () => {
if (!selectedStop || !selectedStop.atco_code) return;
try {
await navigator.clipboard.writeText(selectedStop.atco_code);
byId('copy-status').textContent = 'Copied';
} catch (_) {
byId('copy-status').textContent = 'Copy failed';
}
});
byId('panel-button').addEventListener('click', () => setPanel(true));
byId('mobile-handle').addEventListener('click', () => setPanel(false));
map.on('moveend', () => {
if (!userMapInteractionPending) return;
userMapInteractionPending = false;
clearTimeout(mapMoveTimer);
mapMoveTimer = setTimeout(loadVisibleMapStops, 350);
});
for (const eventName of ['pointerdown', 'wheel', 'keydown']) {
map.getContainer().addEventListener(eventName, () => {
userMapInteractionPending = true;
}, {passive: true});
}
if (isMobile()) setPanel(true);
window.addEventListener('popstate', restoreSearchFromUrl);
restoreSearchFromUrl();

View file

@ -0,0 +1,43 @@
html, body { height: 100%; overflow: hidden; }
body { color: #1c2333; }
.navbar { height: 56px; }
.navbar-brand { font-size: 1.05rem; }
#main-row { display: flex; height: calc(100dvh - 56px); }
#sidebar { width: 380px; flex: 0 0 380px; border-right: 1px solid #dee2e6; background: #fff; z-index: 1000; }
#sidebar-inner { height: 100%; overflow-y: auto; padding: 1rem; }
#map { flex: 1; min-width: 0; height: 100%; position: relative; }
#stop-list { padding-bottom: 1rem; }
.stop-item { width: 100%; text-align: left; border: 0; border-bottom: 1px solid #edf0f2; background: #fff; padding: .7rem .5rem; }
.stop-item:hover, .stop-item:focus { background: #f0f5ff; }
.stop-title { display: block; font-weight: 600; }
.stop-meta { display: block; color: #6c757d; font-size: .8rem; margin-top: .1rem; }
.transport-type { display: inline-block; border-radius: 1rem; padding: .15rem .55rem; background: #e9ecef; color: #343a40; font-size: .78rem; font-weight: 600; }
.atco-card { border-radius: .5rem; color: #fff; background: #0f1c2e; padding: .85rem 1rem; }
.atco-card code { color: #b9edac; font-size: 1.35rem; font-weight: 700; letter-spacing: .03em; }
.atco-card .text-uppercase { color: #b9c3cf; font-size: .7rem; letter-spacing: .08em; }
.route-card { border-left: 3px solid #0d6efd; padding: .45rem .6rem; margin: .45rem 0; background: #f8f9fa; }
.route-ref { font-weight: 700; margin-right: .35rem; }
.tag-list { display: grid; grid-template-columns: minmax(7rem, auto) 1fr; gap: .25rem .6rem; overflow-wrap: anywhere; }
.tag-list dt, .tag-list dd { margin: 0; }
.tag-list dt { font-family: monospace; color: #495057; }
.leaflet-container { font-family: inherit; }
.selected-stop-marker { animation: selected-stop-pulse 1.5s ease-in-out infinite; }
@keyframes selected-stop-pulse {
0%, 100% { stroke-opacity: 1; fill-opacity: .25; stroke-width: 5; }
50% { stroke-opacity: .55; fill-opacity: .08; stroke-width: 8; }
}
@media (prefers-reduced-motion: reduce) {
.selected-stop-marker { animation: none; }
}
#panel-button { position: absolute; z-index: 900; right: 12px; bottom: 42px; border: 0; border-radius: 24px; padding: .65rem 1rem; background: #fff; box-shadow: 0 2px 9px #0005; font-weight: 600; }
#map-status { position: absolute; z-index: 900; top: 12px; left: 50%; transform: translateX(-50%); max-width: calc(100% - 100px); border-radius: 5px; padding: .4rem .7rem; background: rgba(255,255,255,.94); box-shadow: 0 1px 5px #0004; color: #495057; font-size: .8rem; text-align: center; }
#mobile-handle { height: 25px; align-items: center; justify-content: center; }
#mobile-handle span { width: 38px; height: 4px; border-radius: 2px; background: #ced4da; }
@media (max-width: 767.98px) {
#sidebar { position: fixed; width: 100%; height: 68dvh; left: 0; right: 0; bottom: 0; border: 0; border-radius: 16px 16px 0 0; box-shadow: 0 -4px 18px #0003; transform: translateY(100%); transition: transform .25s ease; padding-bottom: env(safe-area-inset-bottom); }
#sidebar.panel-open { transform: translateY(0); }
#mobile-handle { display: flex !important; cursor: pointer; }
#sidebar-inner { height: calc(100% - 25px); padding-top: .4rem; }
#map { width: 100%; flex: 0 0 100%; }
}

View file

@ -0,0 +1,110 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Find the ATCO code and route information for UK bus stops using OpenStreetMap.">
<title>UK Bus Stop ATCO Code Finder</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<link rel="stylesheet" href="{{ url_for('static', filename='style.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>
<a class="nav-link text-white small" href="https://openstreetmap.tools/">OSM tools</a>
</nav>
<main id="main-row">
<section id="sidebar" aria-label="Bus stop search and results">
<div id="mobile-handle" class="d-md-none" aria-hidden="true"><span></span></div>
<div id="sidebar-inner">
<header class="mb-3">
<h1 class="h5 mb-1">Find a UK bus stop code</h1>
<p class="small text-muted mb-0">Search by postcode, street, place or ATCO code.</p>
</header>
<form id="search-form" class="mb-2" role="search">
<label for="search-input" class="visually-hidden">Location or ATCO code</label>
<div class="input-group">
<input id="search-input" class="form-control" type="search"
placeholder="Postcode, place, ATCO code or lat, lon" autocomplete="off" required>
<button class="btn btn-primary" type="submit">Search</button>
</div>
</form>
<button id="locate-button" class="btn btn-outline-secondary btn-sm w-100 mb-3" type="button">
◎ Use my current location
</button>
<div id="alert" class="alert alert-danger py-2 small d-none" role="alert"></div>
<div id="loading" class="small text-muted d-none" role="status">
<span class="spinner-border spinner-border-sm me-1"></span><span id="loading-text">Finding stops…</span>
</div>
<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>
<span id="stop-count" class="small text-muted"></span>
</div>
<div id="stop-list"></div>
</section>
<article id="stop-detail" class="d-none" aria-live="polite">
<button id="back-button" class="btn btn-link btn-sm px-0 mb-2" type="button">← All nearby stops</button>
<h2 id="stop-name" class="h5 mb-1"></h2>
<p id="stop-type" class="transport-type mb-1"></p>
<p id="stop-bearing" class="small fw-semibold mb-1 d-none"></p>
<p id="stop-indicator" class="text-muted small mb-3 d-none"></p>
<div id="atco-present" class="atco-card mb-3">
<div class="small text-uppercase fw-semibold">ATCO code</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<code id="atco-code"></code>
<button id="copy-button" class="btn btn-sm btn-light" type="button">Copy</button>
</div>
<span id="copy-status" class="small" aria-live="polite"></span>
</div>
<div id="atco-missing" class="alert alert-warning small d-none">
No ATCO code is recorded in OpenStreetMap for this stop.
<a id="edit-link" target="_blank" rel="noopener">Edit the stop on OSM ↗</a>
</div>
<dl class="row small mb-3">
<dt class="col-4">Coordinates</dt>
<dd id="coordinates" class="col-8"></dd>
</dl>
<p><a id="osm-link" target="_blank" rel="noopener">View stop on OpenStreetMap ↗</a></p>
<section class="mb-3">
<h3 class="h6">Bus routes serving this stop</h3>
<div id="route-loading" class="small text-muted">Loading routes…</div>
<div id="route-list"></div>
</section>
<details>
<summary class="small fw-semibold">Other OpenStreetMap tags</summary>
<dl id="tag-list" class="tag-list small mt-2"></dl>
</details>
</article>
</div>
</section>
<section id="map" aria-label="Map of transport stops">
<div id="map-status" class="d-none" role="status"></div>
<button id="panel-button" class="d-md-none" type="button">☰ Stops</button>
</section>
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const API_URLS = {
search: {{ url_for('search') | tojson }},
stops: {{ url_for('stops') | tojson }},
boundedStops: {{ url_for('bounded_stops') | tojson }},
stopTemplate: {{ url_for('stop_detail', element_type='TYPE', element_id=0) | tojson }},
routeTemplate: {{ url_for('stop_routes', element_type='TYPE', element_id=0) | tojson }},
};
</script>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>

9
src/uk_bus_stops/wsgi.py Normal file
View file

@ -0,0 +1,9 @@
"""Production WSGI entry point for the UK bus stop finder."""
from werkzeug.middleware.proxy_fix import ProxyFix
from uk_bus_stops.app import app
app.wsgi_app = ProxyFix( # type: ignore[method-assign]
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
)