From 3223d4b0630f3b2fb9ec2f80a2fc701d9f8af3f5 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Fri, 27 Feb 2026 19:03:37 +0000 Subject: [PATCH 1/5] Add API docs page and update README/AGENTS - Add /docs route serving web/templates/api.html: full Bootstrap 5 documentation page covering all three API endpoints with parameter tables, example requests, and example responses - Add 'API docs' link to the navbar on the main map page - Update README.md: add web frontend section with feature list, dev server instructions, and API endpoint summary table - Update AGENTS.md: add web/ layout, API endpoint table, Flask run instructions, and route_master example relation IDs Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 48 ++++++- README.md | 44 ++++++- web/app.py | 6 + web/templates/api.html | 269 +++++++++++++++++++++++++++++++++++++++ web/templates/index.html | 1 + 5 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 web/templates/api.html diff --git a/AGENTS.md b/AGENTS.md index 11dbc37..950e309 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,9 +4,8 @@ Guidelines for AI coding agents working in this repository. ## Project overview -A collection of Python CLI tools for working with OpenStreetMap data. Each -tool is a standalone Python script using Click for the CLI interface and -Requests for HTTP calls. +A collection of Python tools for working with OpenStreetMap public transport +data. Includes a CLI tool and a Flask web frontend with a JSON API. ## Repository layout @@ -15,10 +14,20 @@ pyproject.toml - Package metadata and build configuration src/ osm_geojson/ __init__.py - Top-level package marker + py.typed - Marker for mypy typed package pt/ __init__.py - Public transport subpackage core.py - Data fetching and processing functions cli.py - Click CLI commands +web/ + app.py - Flask application (routes + API endpoints) + templates/ + index.html - Main map UI + api.html - API documentation page (served at /docs) + static/ + app.js - Frontend JavaScript + style.css - CSS for the map UI + favicon.svg - SVG favicon tests/ fixtures/ - Saved OSM API responses for offline testing test_osm_pt_geojson.py - Test suite @@ -35,10 +44,13 @@ point registered in `pyproject.toml` under `[project.scripts]`. - Python 3.11+ - CLI via [Click](https://click.palletsprojects.com/) - HTTP via [Requests](https://requests.readthedocs.io/) +- Web frontend via [Flask](https://flask.palletsprojects.com/) 3.x - Parse XML with lxml if needed; prefer the OSM JSON API where possible -- Errors go to stderr; data output goes to stdout +- Errors go to stderr; data output goes to stdout (CLI) - GeoJSON output uses `ensure_ascii=False` - All modules, functions, and test functions must have docstrings +- Library code (`core.py`) raises `OsmError` instead of calling `sys.exit()`; + the CLI and Flask views catch `OsmError` and handle it appropriately ## OSM API @@ -51,6 +63,30 @@ https://www.openstreetmap.org/api/0.6/ No authentication is required for read-only access. Include a descriptive `User-Agent` header in all requests. +## Web frontend API endpoints + +| Endpoint | Description | +|---|---| +| `GET /api/route/` | Full route GeoJSON, stop list, and sibling routes | +| `GET /api/segment/?from=NAME&to=NAME[&stops=0]` | Segment between two named stops | +| `GET /api/route_master/` | All member routes of a route_master | +| `GET /docs` | API documentation page | + +All API responses are JSON. Errors return `{"error": "", "message": ""}`. + +## Running the web frontend + +``` +cd web +flask --app app.py run +``` + +The web dependencies (Flask) are not in `pyproject.toml`; install separately: + +``` +pip install flask +``` + ## Type checking All code uses type hints. Run mypy in strict mode to check: @@ -74,7 +110,9 @@ API. Fixture data is stored in `tests/fixtures/` as saved API responses. | ID | Description | |----------|------------------------------------------| -| 15083963 | M11 Istanbul Metro (subway) | +| 15083963 | M11 Istanbul Metro (subway, direction 1) | +| 15083964 | M11 Istanbul Metro (subway, direction 2) | +| 15083966 | M11 Istanbul Metro (route_master) | | 18892969 | Bus A1: Bristol Airport → Bus Station | ## Dependencies diff --git a/README.md b/README.md index 063030f..9ee0234 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # openstreetmap-tools -A collection of command-line tools for working with OpenStreetMap data. +A collection of tools for working with OpenStreetMap public transport data. ## Tools @@ -9,7 +9,7 @@ A collection of command-line tools for working with OpenStreetMap data. Fetch an OSM public transport route relation and list its stops, or export the route as GeoJSON. -#### Usage +#### CLI usage ``` osm-pt-geojson list-stations @@ -67,6 +67,46 @@ cd openstreetmap-tools pip install -e . ``` +--- + +### Web frontend + +An interactive map interface for browsing and downloading public transport +routes as GeoJSON. + +#### Features + +- Enter a relation ID or OSM URL to load a route onto the map. +- Click stops in the sidebar list or on the map to set start and end points. +- Preview the selected segment highlighted on the map. +- Toggle stop points in/out of the GeoJSON before downloading. +- Download a segment or the full route as a `.geojson` file. +- Entering a `route_master` ID shows all directions on the map with links to + each individual route. +- Bookmarkable URLs: `/` loads that route directly. + +#### Running the dev server + +``` +cd web +flask --app app.py run +``` + +Open `http://127.0.0.1:5000`. + +#### API + +The web frontend exposes a JSON API. Full documentation is available at `/docs` +when the server is running. + +| Endpoint | Description | +|---|---| +| `GET /api/route/` | Full route GeoJSON, stop list, and sibling routes | +| `GET /api/segment/?from=NAME&to=NAME` | Segment between two named stops | +| `GET /api/route_master/` | All member routes of a route_master | + +--- + ## Licence MIT License. Copyright (c) 2026 Edward Betts. diff --git a/web/app.py b/web/app.py index 65da89e..d27babd 100644 --- a/web/app.py +++ b/web/app.py @@ -80,6 +80,12 @@ def check_route_tags( return None +@app.route("/docs") +def docs() -> ResponseReturnValue: + """Render the API documentation page.""" + return render_template("api.html") + + @app.route("/") def index() -> ResponseReturnValue: """Render the landing page with no relation loaded.""" diff --git a/web/templates/api.html b/web/templates/api.html new file mode 100644 index 0000000..74bf442 --- /dev/null +++ b/web/templates/api.html @@ -0,0 +1,269 @@ + + + + + + API Documentation – OSM Public Transport → GeoJSON + + + + + + + + +
+ +

API Documentation

+

JSON API for fetching OSM public transport routes as GeoJSON.

+ +

All endpoints are read-only and require no authentication. Data is fetched + live from the OSM API v0.6. + Relation IDs can be found on + openstreetmap.org — search for a + route and click through to its relation page.

+ +

All error responses return JSON with at least error (machine-readable + code) and message (human-readable description) fields.

+ + +

+ GET + /api/route/<relation_id> +

+

Returns the full route as a GeoJSON FeatureCollection, the + ordered list of stops, and links to sibling routes in the same + route_master (if any).

+ +

Path parameters

+ + + + + +
ParameterTypeDescription
relation_idintegerOSM relation ID of a public transport route.
+ +

Success response 200 OK

+
{
+  "name": "M11: Arnavutköy Hastane → Gayrettepe",
+  "ref": "M11",
+  "stops": [
+    { "name": "Arnavutköy Hastane", "lat": 41.1234, "lon": 28.7654 },
+    { "name": "İstanbul Havalimanı", "lat": 41.2701, "lon": 28.7519 },
+    ...
+  ],
+  "geojson": {
+    "type": "FeatureCollection",
+    "features": [
+      {
+        "type": "Feature",
+        "geometry": { "type": "LineString", "coordinates": [[28.765, 41.123], ...] },
+        "properties": { "name": "M11: Arnavutköy Hastane → Gayrettepe",
+                        "ref": "M11", "from": "Arnavutköy Hastane",
+                        "to": "Gayrettepe", "route": "subway" }
+      },
+      {
+        "type": "Feature",
+        "geometry": { "type": "Point", "coordinates": [28.765, 41.123] },
+        "properties": { "name": "Arnavutköy Hastane" }
+      },
+      ...
+    ]
+  },
+  "other_directions": [
+    { "id": 15083964, "name": "M11: Gayrettepe → Arnavutköy Hastane", "ref": "M11" }
+  ]
+}
+ +

The geojson field is a FeatureCollection containing + one LineString for the route geometry followed by one + Point per stop. The LineString carries the route tags + as properties (name, ref, from, + to, route). Each Point has a + name property.

+ +

other_directions lists sibling routes from the same + route_master relation. It is an empty array if the route has no + parent route_master.

+ +

Error responses

+ + + + + + + + +
StatuserrorWhen
404osm_errorRelation not found on OpenStreetMap.
422is_route_masterRelation is a route_master. Use /api/route_master/<id> instead.
422not_public_transportRelation exists but is not a supported public transport route.
502osm_errorOSM API returned an unexpected error.
+ +

Example

+
GET /api/route/15083963
+ + +

+ GET + /api/segment/<relation_id> +

+

Returns a GeoJSON FeatureCollection for the portion of the + route between two named stops. Stops are matched case-insensitively.

+ +

Path parameters

+ + + + + +
ParameterTypeDescription
relation_idintegerOSM relation ID of a public transport route.
+ +

Query parameters

+ + + + + + + +
ParameterRequiredDescription
fromYesName of the start stop (case-insensitive).
toYesName of the end stop (case-insensitive).
stopsNoInclude stop Point features. 1 (default) to include, 0 to omit.
+ +

Success response 200 OK

+
{
+  "type": "FeatureCollection",
+  "features": [
+    {
+      "type": "Feature",
+      "geometry": { "type": "LineString", "coordinates": [[28.800, 41.259], ...] },
+      "properties": { "name": "M11: Arnavutköy Hastane → Gayrettepe",
+                      "ref": "M11", "from": "Arnavutköy Hastane",
+                      "to": "Gayrettepe", "route": "subway" }
+    },
+    {
+      "type": "Feature",
+      "geometry": { "type": "Point", "coordinates": [28.800, 41.259] },
+      "properties": { "name": "İstanbul Havalimanı" }
+    },
+    {
+      "type": "Feature",
+      "geometry": { "type": "Point", "coordinates": [28.820, 41.247] },
+      "properties": { "name": "Hasdal" }
+    }
+  ]
+}
+ +

The LineString is trimmed to the shortest path between the + two stops along the route geometry. The from stop is treated as + the start regardless of the order the names are supplied — the segment always + follows the route direction.

+ +

Error responses

+ + + + + + + + + +
StatuserrorWhen
400missing_paramsfrom or to parameter is absent.
404osm_errorRelation not found on OpenStreetMap.
404station_not_foundOne or both stop names were not found. The response includes an available array listing valid stop names.
422not_public_transportRelation is not a supported public transport route.
502osm_errorOSM API returned an unexpected error.
+ +

Example

+
GET /api/segment/15083963?from=İstanbul+Havalimanı&to=Hasdal
+
GET /api/segment/15083963?from=İstanbul+Havalimanı&to=Hasdal&stops=0
+ + +

+ GET + /api/route_master/<relation_id> +

+

Returns all member routes of an OSM route_master relation, + each with its GeoJSON geometry (stops omitted). Use this to display all + directions of a line on a map.

+ +

Path parameters

+ + + + + +
ParameterTypeDescription
relation_idintegerOSM relation ID of a route_master relation.
+ +

Success response 200 OK

+
{
+  "name": "M11",
+  "ref": "M11",
+  "routes": [
+    {
+      "id": 15083963,
+      "name": "M11: Arnavutköy Hastane → Gayrettepe",
+      "ref": "M11",
+      "from": "Arnavutköy Hastane",
+      "to": "Gayrettepe",
+      "geojson": {
+        "type": "FeatureCollection",
+        "features": [
+          {
+            "type": "Feature",
+            "geometry": { "type": "LineString", "coordinates": [[28.765, 41.123], ...] },
+            "properties": { "name": "M11: Arnavutköy Hastane → Gayrettepe", ... }
+          }
+        ]
+      }
+    },
+    {
+      "id": 15083964,
+      "name": "M11: Gayrettepe → Arnavutköy Hastane",
+      "ref": "M11",
+      "from": "Gayrettepe",
+      "to": "Arnavutköy Hastane",
+      "geojson": { ... }
+    }
+  ]
+}
+ +

Each entry in routes contains the individual route's + id, name, ref, from, + to tags, and a geojson FeatureCollection + with only the LineString geometry (no stop points). If a member + route cannot be fetched, it is included with "geojson": null.

+ +

Error responses

+ + + + + + + +
StatuserrorWhen
404osm_errorRelation not found on OpenStreetMap.
422osm_errorRelation exists but is not a route_master.
502osm_errorOSM API returned an unexpected error.
+ +

Example

+
GET /api/route_master/15083966
+ + +

Supported route types

+

The following OSM route tag values are accepted:

+

+ bus   + ferry   + funicular   + light_rail   + monorail   + subway   + train   + tram   + trolleybus +

+ +
+ + diff --git a/web/templates/index.html b/web/templates/index.html index ed496f0..4eb6932 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -13,6 +13,7 @@
From f6dd68ed75564df141a0069290b4ddd565eeb5c0 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Fri, 27 Feb 2026 19:18:05 +0000 Subject: [PATCH 2/5] Avoid map zoom-out when navigating between routes Route links (route_master list, other directions) now intercept clicks and call loadRoute() + history.pushState() instead of doing a full page reload, so the map stays at its current position and zooms smoothly into the new route. Ctrl/cmd/middle-click still opens in a new tab via href. Introduce currentRelationId (mutable) to track the loaded relation so loadSegment() uses the correct ID after in-page navigation. Co-Authored-By: Claude Sonnet 4.6 --- web/static/app.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/web/static/app.js b/web/static/app.js index a94b6b5..a471acf 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -22,6 +22,7 @@ let routeMasterLayers = []; // coloured polylines when viewing a route_master // ── State ───────────────────────────────────────────────────────────────── +let currentRelationId = RELATION_ID; // tracks the currently loaded relation let routeData = null; // response from /api/route/ let segmentGeoJson = null; // last response from /api/segment/ (always includes stops) let activeSlot = 'from'; // 'from' | 'to' | null @@ -301,6 +302,21 @@ document.getElementById('include-stops').addEventListener('change', () => { document.getElementById('slot-from').addEventListener('click', () => setActiveSlot('from')); document.getElementById('slot-to').addEventListener('click', () => setActiveSlot('to')); +// ── Navigation ───────────────────────────────────────────────────────────── + +/** + * Load a relation without a full page reload, updating the browser URL. + * Allows middle-click / ctrl-click to still open in a new tab via the href. + * @param {number} id + * @param {MouseEvent} e + */ +function navigateTo(id, e) { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; + e.preventDefault(); + history.pushState(null, '', `/${id}`); + loadRoute(id); +} + // ── API calls ────────────────────────────────────────────────────────────── /** @@ -320,6 +336,7 @@ async function loadRoute(relationId) { return; } routeData = data; + currentRelationId = relationId; // Clean up any previous route_master view for (const l of routeMasterLayers) l.remove(); routeMasterLayers = []; @@ -358,6 +375,7 @@ async function loadRoute(relationId) { a.href = `/${dir.id}`; a.className = 'stop-item d-block text-decoration-none'; a.textContent = dir.name; + a.addEventListener('click', (e) => navigateTo(dir.id, e)); dirList.appendChild(a); } } else { @@ -373,7 +391,7 @@ async function loadRoute(relationId) { */ async function loadSegment() { if (!selectedFrom || !selectedTo || !routeData) return; - const rid = RELATION_ID; + const rid = currentRelationId; const params = new URLSearchParams({ from: selectedFrom, to: selectedTo, stops: '1' }); try { const resp = await fetch(`/api/segment/${rid}?${params}`); @@ -444,6 +462,7 @@ async function loadRouteMaster(relationId) { a.href = `/${route.id}`; a.className = 'text-decoration-none text-reset flex-grow-1'; a.textContent = route.name; + a.addEventListener('click', (e) => navigateTo(route.id, e)); div.appendChild(dot); div.appendChild(a); From 9b5a510d02de205435461c750408df1e5c328eb7 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Fri, 27 Feb 2026 19:21:31 +0000 Subject: [PATCH 3/5] Handle browser back/forward for in-page route navigation Listen for popstate events and reload the relation from the URL path, so the back button correctly returns to the route_master view after navigating to an individual route. Co-Authored-By: Claude Sonnet 4.6 --- web/static/app.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/web/static/app.js b/web/static/app.js index a471acf..a95604d 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -481,6 +481,11 @@ async function loadRouteMaster(relationId) { // ── Init ─────────────────────────────────────────────────────────────────── +window.addEventListener('popstate', () => { + const match = location.pathname.match(/^\/(\d+)$/); + if (match) loadRoute(parseInt(match[1], 10)); +}); + if (RELATION_ID) { loadRoute(RELATION_ID); } From 3a158c189c456f3068d95522ecefc0f489e310f3 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Fri, 27 Feb 2026 19:23:24 +0000 Subject: [PATCH 4/5] Add OSM website links for route and route_master panels Co-Authored-By: Claude Sonnet 4.6 --- web/static/app.js | 4 ++++ web/templates/index.html | 2 ++ 2 files changed, 6 insertions(+) diff --git a/web/static/app.js b/web/static/app.js index a95604d..8bb7913 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -349,6 +349,8 @@ async function loadRoute(relationId) { // Show route panel document.getElementById('route-panel').classList.remove('d-none'); document.getElementById('route-name').textContent = data.name; + document.getElementById('route-osm-link').href = + `https://www.openstreetmap.org/relation/${relationId}`; // Draw full route in grey if (fullRouteLayer) fullRouteLayer.remove(); @@ -435,6 +437,8 @@ async function loadRouteMaster(relationId) { document.getElementById('route-panel').classList.add('d-none'); document.getElementById('route-master-panel').classList.remove('d-none'); document.getElementById('route-master-name').textContent = data.name; + document.getElementById('route-master-osm-link').href = + `https://www.openstreetmap.org/relation/${relationId}`; const colours = ['#0d6efd', '#dc3545', '#198754', '#fd7e14', '#6f42c1']; const list = document.getElementById('route-master-list'); diff --git a/web/templates/index.html b/web/templates/index.html index 4eb6932..a1f86f6 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -49,6 +49,7 @@
Routes
@@ -58,6 +59,7 @@
From c37b463c0ee69f6c39b2dbb636d99478ac0e3d69 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Fri, 27 Feb 2026 19:37:06 +0000 Subject: [PATCH 5/5] Add homepage for openstreetmap.tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static index.html for the site root — lists the three tools (Public Transport → GeoJSON, OWL Places, OWL Map) with descriptions, includes an inline relation ID/URL form that redirects to the PT tool, and credits Edward Betts with a link to edwardbetts.com. Playfair Display headings + IBM Plex Sans body, dark navy header with subtle OSM-green grid, three cards (featured card spans full width). Co-Authored-By: Claude Sonnet 4.6 --- homepage/index.html | 392 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 homepage/index.html diff --git a/homepage/index.html b/homepage/index.html new file mode 100644 index 0000000..c4866ef --- /dev/null +++ b/homepage/index.html @@ -0,0 +1,392 @@ + + + + + + OpenStreetMap Tools + + + + + + + + +
+
+

openstreetmap.tools

+

OpenStreetMap
Tools

+

A collection of tools for working with OpenStreetMap data.

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

OWL Places

+ Wikidata +
+

+ Link OpenStreetMap places to Wikidata entries, one place at a time. + Search for an OSM object, find its Wikidata counterpart, and add the + connection — improving data quality across both datasets. +

+ Open OWL Places → +
+ +
+
+

OWL Map

+ Wikidata +
+

+ Map-based OSM/Wikidata linker. Pan to any area, see which objects + need Wikidata links, and connect them at speed — faster to get + started than OWL Places, built for covering ground at scale. +

+ Open OWL Map → +
+ +
+
+ + + + + + +