Compare commits

..

5 commits

Author SHA1 Message Date
c37b463c0e Add homepage for openstreetmap.tools
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 <noreply@anthropic.com>
2026-02-27 19:37:06 +00:00
3a158c189c Add OSM website links for route and route_master panels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 19:23:24 +00:00
9b5a510d02 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 <noreply@anthropic.com>
2026-02-27 19:21:31 +00:00
f6dd68ed75 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 <noreply@anthropic.com>
2026-02-27 19:18:05 +00:00
3223d4b063 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 <noreply@anthropic.com>
2026-02-27 19:03:37 +00:00
7 changed files with 784 additions and 8 deletions

View file

@ -4,9 +4,8 @@ Guidelines for AI coding agents working in this repository.
## Project overview ## Project overview
A collection of Python CLI tools for working with OpenStreetMap data. Each A collection of Python tools for working with OpenStreetMap public transport
tool is a standalone Python script using Click for the CLI interface and data. Includes a CLI tool and a Flask web frontend with a JSON API.
Requests for HTTP calls.
## Repository layout ## Repository layout
@ -15,10 +14,20 @@ pyproject.toml - Package metadata and build configuration
src/ src/
osm_geojson/ osm_geojson/
__init__.py - Top-level package marker __init__.py - Top-level package marker
py.typed - Marker for mypy typed package
pt/ pt/
__init__.py - Public transport subpackage __init__.py - Public transport subpackage
core.py - Data fetching and processing functions core.py - Data fetching and processing functions
cli.py - Click CLI commands 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/ tests/
fixtures/ - Saved OSM API responses for offline testing fixtures/ - Saved OSM API responses for offline testing
test_osm_pt_geojson.py - Test suite test_osm_pt_geojson.py - Test suite
@ -35,10 +44,13 @@ point registered in `pyproject.toml` under `[project.scripts]`.
- Python 3.11+ - Python 3.11+
- CLI via [Click](https://click.palletsprojects.com/) - CLI via [Click](https://click.palletsprojects.com/)
- HTTP via [Requests](https://requests.readthedocs.io/) - 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 - 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` - GeoJSON output uses `ensure_ascii=False`
- All modules, functions, and test functions must have docstrings - 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 ## OSM API
@ -51,6 +63,30 @@ https://www.openstreetmap.org/api/0.6/
No authentication is required for read-only access. Include a descriptive No authentication is required for read-only access. Include a descriptive
`User-Agent` header in all requests. `User-Agent` header in all requests.
## Web frontend API endpoints
| Endpoint | Description |
|---|---|
| `GET /api/route/<id>` | Full route GeoJSON, stop list, and sibling routes |
| `GET /api/segment/<id>?from=NAME&to=NAME[&stops=0]` | Segment between two named stops |
| `GET /api/route_master/<id>` | All member routes of a route_master |
| `GET /docs` | API documentation page |
All API responses are JSON. Errors return `{"error": "<code>", "message": "<text>"}`.
## 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 ## Type checking
All code uses type hints. Run mypy in strict mode to check: 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 | | 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 | | 18892969 | Bus A1: Bristol Airport → Bus Station |
## Dependencies ## Dependencies

View file

@ -1,6 +1,6 @@
# openstreetmap-tools # 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 ## 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 Fetch an OSM public transport route relation and list its stops, or export the
route as GeoJSON. route as GeoJSON.
#### Usage #### CLI usage
``` ```
osm-pt-geojson list-stations <relation_id> osm-pt-geojson list-stations <relation_id>
@ -67,6 +67,46 @@ cd openstreetmap-tools
pip install -e . 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: `/<relation_id>` 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/<id>` | Full route GeoJSON, stop list, and sibling routes |
| `GET /api/segment/<id>?from=NAME&to=NAME` | Segment between two named stops |
| `GET /api/route_master/<id>` | All member routes of a route_master |
---
## Licence ## Licence
MIT License. Copyright (c) 2026 Edward Betts. MIT License. Copyright (c) 2026 Edward Betts.

392
homepage/index.html Normal file
View file

@ -0,0 +1,392 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenStreetMap Tools</title>
<meta name="description" content="A collection of tools for working with OpenStreetMap data, built by Edward Betts.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;1,400&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #7EBC6F;
--green-light: #f0f9ec;
--green-border: #c6e6bb;
--green-text: #4a7c40;
--navy: #0f1c2e;
--navy-mid: #1a3048;
--text: #1c2333;
--muted: #6b7280;
--bg: #f7f6f3;
--card: #ffffff;
--border: #e5e7eb;
}
body {
font-family: 'IBM Plex Sans', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Header ──────────────────────────────────────────────────── */
header {
background: var(--navy);
color: white;
padding: 3.5rem 2rem 3rem;
position: relative;
overflow: hidden;
}
header::before {
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(126,188,111,0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(126,188,111,0.08) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
.header-inner {
max-width: 960px;
margin: 0 auto;
position: relative;
}
.site-label {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--green);
margin-bottom: 0.8rem;
}
h1 {
font-family: 'Playfair Display', Georgia, serif;
font-size: clamp(2.2rem, 5vw, 3.5rem);
font-weight: 700;
line-height: 1.08;
color: white;
margin-bottom: 1rem;
}
.tagline {
font-size: 1rem;
color: rgba(255,255,255,0.6);
max-width: 480px;
}
/* ── Main ────────────────────────────────────────────────────── */
main {
flex: 1;
max-width: 960px;
margin: 0 auto;
width: 100%;
padding: 3rem 2rem;
}
.section-label {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
padding-bottom: 0.85rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1.75rem;
}
/* ── Grid ────────────────────────────────────────────────────── */
.tools-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.tool-card--featured {
grid-column: 1 / -1;
}
/* ── Cards ───────────────────────────────────────────────────── */
.tool-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
transition: box-shadow 0.2s;
}
.tool-card:hover {
box-shadow: 0 4px 24px rgba(0,0,0,0.07);
}
.tool-card--featured {
border-left: 3px solid var(--green);
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.tool-name {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.45rem;
font-weight: 700;
line-height: 1.2;
color: var(--text);
}
.tool-tag {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.68rem;
letter-spacing: 0.04em;
background: var(--green-light);
color: var(--green-text);
border: 1px solid var(--green-border);
border-radius: 4px;
padding: 0.2rem 0.55rem;
white-space: nowrap;
flex-shrink: 0;
margin-top: 0.2rem;
}
.tool-desc {
color: var(--muted);
font-size: 0.95rem;
line-height: 1.7;
}
/* ── PT form ─────────────────────────────────────────────────── */
.pt-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.pt-form label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text);
letter-spacing: 0.01em;
}
.pt-form-row {
display: flex;
gap: 0.5rem;
}
.pt-input {
flex: 1;
min-width: 0;
padding: 0.6rem 0.9rem;
border: 1.5px solid var(--border);
border-radius: 6px;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.85rem;
background: #fafaf8;
color: var(--text);
outline: none;
transition: border-color 0.15s, background 0.15s;
}
.pt-input:focus {
border-color: var(--green);
background: white;
}
.pt-input::placeholder { color: #b0b8c4; }
.btn-go {
padding: 0.6rem 1.3rem;
background: var(--navy);
color: white;
border: none;
border-radius: 6px;
font-family: 'IBM Plex Sans', sans-serif;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.btn-go:hover { background: var(--green); color: var(--navy); }
.form-error {
font-size: 0.8rem;
color: #dc3545;
display: none;
}
.form-error.visible { display: block; }
/* ── Card link ───────────────────────────────────────────────── */
.card-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--navy);
font-weight: 600;
font-size: 0.875rem;
text-decoration: none;
border-bottom: 1.5px solid var(--green);
padding-bottom: 1px;
transition: color 0.15s;
margin-top: auto;
align-self: flex-start;
}
.card-link:hover { color: var(--green-text); }
/* ── Footer ──────────────────────────────────────────────────── */
footer {
border-top: 1px solid var(--border);
padding: 1.5rem 2rem;
text-align: center;
font-size: 0.82rem;
color: var(--muted);
}
footer a {
color: var(--text);
text-decoration: none;
font-weight: 500;
}
footer a:hover { color: var(--green-text); }
.sep { margin: 0 0.65rem; color: var(--border); }
/* ── Responsive ──────────────────────────────────────────────── */
@media (max-width: 620px) {
.tools-grid { grid-template-columns: 1fr; }
.tool-card--featured { grid-column: 1; }
.pt-form-row { flex-direction: column; }
.btn-go { width: 100%; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<p class="site-label">openstreetmap.tools</p>
<h1>OpenStreetMap<br>Tools</h1>
<p class="tagline">A collection of tools for working with OpenStreetMap data.</p>
</div>
</header>
<main>
<p class="section-label">Tools</p>
<div class="tools-grid">
<div class="tool-card tool-card--featured">
<div class="card-header">
<h2 class="tool-name">Public Transport → GeoJSON</h2>
<span class="tool-tag">GeoJSON</span>
</div>
<p class="tool-desc">
Fetch an OSM public transport route relation and export it as GeoJSON.
Visualise the route on a map, select start and end stops to extract a
segment, and download the result.
</p>
<div class="pt-form">
<label for="relation-input">Relation ID or OSM URL</label>
<div class="pt-form-row">
<input class="pt-input" type="text" id="relation-input"
placeholder="e.g. 15083963 or openstreetmap.org/relation/…"
autocomplete="off" spellcheck="false">
<button class="btn-go" id="btn-go">Go →</button>
</div>
<span class="form-error" id="form-error">Could not find a relation ID in that input.</span>
</div>
</div>
<div class="tool-card">
<div class="card-header">
<h2 class="tool-name">OWL Places</h2>
<span class="tool-tag">Wikidata</span>
</div>
<p class="tool-desc">
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.
</p>
<a class="card-link" href="https://osm.wikidata.link/">Open OWL Places →</a>
</div>
<div class="tool-card">
<div class="card-header">
<h2 class="tool-name">OWL Map</h2>
<span class="tool-tag">Wikidata</span>
</div>
<p class="tool-desc">
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.
</p>
<a class="card-link" href="https://map.osm.wikidata.link/">Open OWL Map →</a>
</div>
</div>
</main>
<footer>
Made by <a href="https://edwardbetts.com/">Edward Betts</a>
<span class="sep">·</span>
Map data © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>
</footer>
<script>
function parseRelationId(text) {
text = text.trim();
const m = text.match(/relation\/(\d+)/);
if (m) return m[1];
if (/^\d+$/.test(text)) return text;
return null;
}
function goToRelation() {
const input = document.getElementById('relation-input');
const error = document.getElementById('form-error');
const id = parseRelationId(input.value);
if (id) {
error.classList.remove('visible');
window.location.href = '/public_transport_geojson/' + id;
} else {
error.classList.add('visible');
input.focus();
}
}
document.getElementById('btn-go').addEventListener('click', goToRelation);
document.getElementById('relation-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter') goToRelation();
});
</script>
</body>
</html>

View file

@ -80,6 +80,12 @@ def check_route_tags(
return None return None
@app.route("/docs")
def docs() -> ResponseReturnValue:
"""Render the API documentation page."""
return render_template("api.html")
@app.route("/") @app.route("/")
def index() -> ResponseReturnValue: def index() -> ResponseReturnValue:
"""Render the landing page with no relation loaded.""" """Render the landing page with no relation loaded."""

View file

@ -22,6 +22,7 @@ let routeMasterLayers = []; // coloured polylines when viewing a route_master
// ── State ───────────────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────────────
let currentRelationId = RELATION_ID; // tracks the currently loaded relation
let routeData = null; // response from /api/route/ let routeData = null; // response from /api/route/
let segmentGeoJson = null; // last response from /api/segment/ (always includes stops) let segmentGeoJson = null; // last response from /api/segment/ (always includes stops)
let activeSlot = 'from'; // 'from' | 'to' | null 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-from').addEventListener('click', () => setActiveSlot('from'));
document.getElementById('slot-to').addEventListener('click', () => setActiveSlot('to')); 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 ────────────────────────────────────────────────────────────── // ── API calls ──────────────────────────────────────────────────────────────
/** /**
@ -320,6 +336,7 @@ async function loadRoute(relationId) {
return; return;
} }
routeData = data; routeData = data;
currentRelationId = relationId;
// Clean up any previous route_master view // Clean up any previous route_master view
for (const l of routeMasterLayers) l.remove(); for (const l of routeMasterLayers) l.remove();
routeMasterLayers = []; routeMasterLayers = [];
@ -332,6 +349,8 @@ async function loadRoute(relationId) {
// Show route panel // Show route panel
document.getElementById('route-panel').classList.remove('d-none'); document.getElementById('route-panel').classList.remove('d-none');
document.getElementById('route-name').textContent = data.name; document.getElementById('route-name').textContent = data.name;
document.getElementById('route-osm-link').href =
`https://www.openstreetmap.org/relation/${relationId}`;
// Draw full route in grey // Draw full route in grey
if (fullRouteLayer) fullRouteLayer.remove(); if (fullRouteLayer) fullRouteLayer.remove();
@ -358,6 +377,7 @@ async function loadRoute(relationId) {
a.href = `/${dir.id}`; a.href = `/${dir.id}`;
a.className = 'stop-item d-block text-decoration-none'; a.className = 'stop-item d-block text-decoration-none';
a.textContent = dir.name; a.textContent = dir.name;
a.addEventListener('click', (e) => navigateTo(dir.id, e));
dirList.appendChild(a); dirList.appendChild(a);
} }
} else { } else {
@ -373,7 +393,7 @@ async function loadRoute(relationId) {
*/ */
async function loadSegment() { async function loadSegment() {
if (!selectedFrom || !selectedTo || !routeData) return; if (!selectedFrom || !selectedTo || !routeData) return;
const rid = RELATION_ID; const rid = currentRelationId;
const params = new URLSearchParams({ from: selectedFrom, to: selectedTo, stops: '1' }); const params = new URLSearchParams({ from: selectedFrom, to: selectedTo, stops: '1' });
try { try {
const resp = await fetch(`/api/segment/${rid}?${params}`); const resp = await fetch(`/api/segment/${rid}?${params}`);
@ -417,6 +437,8 @@ async function loadRouteMaster(relationId) {
document.getElementById('route-panel').classList.add('d-none'); document.getElementById('route-panel').classList.add('d-none');
document.getElementById('route-master-panel').classList.remove('d-none'); document.getElementById('route-master-panel').classList.remove('d-none');
document.getElementById('route-master-name').textContent = data.name; 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 colours = ['#0d6efd', '#dc3545', '#198754', '#fd7e14', '#6f42c1'];
const list = document.getElementById('route-master-list'); const list = document.getElementById('route-master-list');
@ -444,6 +466,7 @@ async function loadRouteMaster(relationId) {
a.href = `/${route.id}`; a.href = `/${route.id}`;
a.className = 'text-decoration-none text-reset flex-grow-1'; a.className = 'text-decoration-none text-reset flex-grow-1';
a.textContent = route.name; a.textContent = route.name;
a.addEventListener('click', (e) => navigateTo(route.id, e));
div.appendChild(dot); div.appendChild(dot);
div.appendChild(a); div.appendChild(a);
@ -462,6 +485,11 @@ async function loadRouteMaster(relationId) {
// ── Init ─────────────────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────────────────
window.addEventListener('popstate', () => {
const match = location.pathname.match(/^\/(\d+)$/);
if (match) loadRoute(parseInt(match[1], 10));
});
if (RELATION_ID) { if (RELATION_ID) {
loadRoute(RELATION_ID); loadRoute(RELATION_ID);
} }

269
web/templates/api.html Normal file
View file

@ -0,0 +1,269 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API Documentation OSM Public Transport → GeoJSON</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<style>
pre { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 1rem; font-size: 0.85rem; }
.endpoint-url { font-family: monospace; font-size: 1rem; }
.badge-get { background: #0d6efd; }
h2 { margin-top: 2.5rem; }
h3 { margin-top: 2rem; font-size: 1.1rem; }
</style>
</head>
<body>
<nav class="navbar navbar-dark bg-dark px-3" style="height:56px">
<a class="navbar-brand" href="/">OSM Public Transport → GeoJSON</a>
<a class="nav-link text-white" href="/docs">API docs</a>
</nav>
<div class="container py-5" style="max-width:860px">
<h1 class="mb-1">API Documentation</h1>
<p class="text-muted mb-4">JSON API for fetching OSM public transport routes as GeoJSON.</p>
<p>All endpoints are read-only and require no authentication. Data is fetched
live from the <a href="https://wiki.openstreetmap.org/wiki/API_v0.6">OSM API v0.6</a>.
Relation IDs can be found on
<a href="https://www.openstreetmap.org">openstreetmap.org</a> — search for a
route and click through to its relation page.</p>
<p>All error responses return JSON with at least <code>error</code> (machine-readable
code) and <code>message</code> (human-readable description) fields.</p>
<!-- ── GET /api/route/<relation_id> ──────────────────────────────────── -->
<h2>
<span class="badge badge-get me-2" style="background:#0d6efd;font-size:0.75rem;vertical-align:middle">GET</span>
<span class="endpoint-url">/api/route/<em>&lt;relation_id&gt;</em></span>
</h2>
<p>Returns the full route as a GeoJSON <code>FeatureCollection</code>, the
ordered list of stops, and links to sibling routes in the same
<code>route_master</code> (if any).</p>
<h3>Path parameters</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>relation_id</code></td><td>integer</td><td>OSM relation ID of a public transport route.</td></tr>
</tbody>
</table>
<h3>Success response <span class="badge bg-success">200 OK</span></h3>
<pre>{
"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" }
]
}</pre>
<p>The <code>geojson</code> field is a <code>FeatureCollection</code> containing
one <code>LineString</code> for the route geometry followed by one
<code>Point</code> per stop. The <code>LineString</code> carries the route tags
as properties (<code>name</code>, <code>ref</code>, <code>from</code>,
<code>to</code>, <code>route</code>). Each <code>Point</code> has a
<code>name</code> property.</p>
<p><code>other_directions</code> lists sibling routes from the same
<code>route_master</code> relation. It is an empty array if the route has no
parent <code>route_master</code>.</p>
<h3>Error responses</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Status</th><th><code>error</code></th><th>When</th></tr></thead>
<tbody>
<tr><td>404</td><td><code>osm_error</code></td><td>Relation not found on OpenStreetMap.</td></tr>
<tr><td>422</td><td><code>is_route_master</code></td><td>Relation is a <code>route_master</code>. Use <code>/api/route_master/&lt;id&gt;</code> instead.</td></tr>
<tr><td>422</td><td><code>not_public_transport</code></td><td>Relation exists but is not a supported public transport route.</td></tr>
<tr><td>502</td><td><code>osm_error</code></td><td>OSM API returned an unexpected error.</td></tr>
</tbody>
</table>
<h3>Example</h3>
<pre>GET /api/route/15083963</pre>
<!-- ── GET /api/segment/<relation_id> ────────────────────────────────── -->
<h2>
<span class="badge me-2" style="background:#0d6efd;font-size:0.75rem;vertical-align:middle">GET</span>
<span class="endpoint-url">/api/segment/<em>&lt;relation_id&gt;</em></span>
</h2>
<p>Returns a GeoJSON <code>FeatureCollection</code> for the portion of the
route between two named stops. Stops are matched case-insensitively.</p>
<h3>Path parameters</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>relation_id</code></td><td>integer</td><td>OSM relation ID of a public transport route.</td></tr>
</tbody>
</table>
<h3>Query parameters</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Parameter</th><th>Required</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>from</code></td><td>Yes</td><td>Name of the start stop (case-insensitive).</td></tr>
<tr><td><code>to</code></td><td>Yes</td><td>Name of the end stop (case-insensitive).</td></tr>
<tr><td><code>stops</code></td><td>No</td><td>Include stop <code>Point</code> features. <code>1</code> (default) to include, <code>0</code> to omit.</td></tr>
</tbody>
</table>
<h3>Success response <span class="badge bg-success">200 OK</span></h3>
<pre>{
"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" }
}
]
}</pre>
<p>The <code>LineString</code> is trimmed to the shortest path between the
two stops along the route geometry. The <code>from</code> stop is treated as
the start regardless of the order the names are supplied — the segment always
follows the route direction.</p>
<h3>Error responses</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Status</th><th><code>error</code></th><th>When</th></tr></thead>
<tbody>
<tr><td>400</td><td><code>missing_params</code></td><td><code>from</code> or <code>to</code> parameter is absent.</td></tr>
<tr><td>404</td><td><code>osm_error</code></td><td>Relation not found on OpenStreetMap.</td></tr>
<tr><td>404</td><td><code>station_not_found</code></td><td>One or both stop names were not found. The response includes an <code>available</code> array listing valid stop names.</td></tr>
<tr><td>422</td><td><code>not_public_transport</code></td><td>Relation is not a supported public transport route.</td></tr>
<tr><td>502</td><td><code>osm_error</code></td><td>OSM API returned an unexpected error.</td></tr>
</tbody>
</table>
<h3>Example</h3>
<pre>GET /api/segment/15083963?from=İstanbul+Havalimanı&to=Hasdal</pre>
<pre>GET /api/segment/15083963?from=İstanbul+Havalimanı&to=Hasdal&stops=0</pre>
<!-- ── GET /api/route_master/<relation_id> ────────────────────────────── -->
<h2>
<span class="badge me-2" style="background:#0d6efd;font-size:0.75rem;vertical-align:middle">GET</span>
<span class="endpoint-url">/api/route_master/<em>&lt;relation_id&gt;</em></span>
</h2>
<p>Returns all member routes of an OSM <code>route_master</code> relation,
each with its GeoJSON geometry (stops omitted). Use this to display all
directions of a line on a map.</p>
<h3>Path parameters</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>relation_id</code></td><td>integer</td><td>OSM relation ID of a <code>route_master</code> relation.</td></tr>
</tbody>
</table>
<h3>Success response <span class="badge bg-success">200 OK</span></h3>
<pre>{
"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": { ... }
}
]
}</pre>
<p>Each entry in <code>routes</code> contains the individual route's
<code>id</code>, <code>name</code>, <code>ref</code>, <code>from</code>,
<code>to</code> tags, and a <code>geojson</code> <code>FeatureCollection</code>
with only the <code>LineString</code> geometry (no stop points). If a member
route cannot be fetched, it is included with <code>"geojson": null</code>.</p>
<h3>Error responses</h3>
<table class="table table-sm table-bordered">
<thead class="table-light"><tr><th>Status</th><th><code>error</code></th><th>When</th></tr></thead>
<tbody>
<tr><td>404</td><td><code>osm_error</code></td><td>Relation not found on OpenStreetMap.</td></tr>
<tr><td>422</td><td><code>osm_error</code></td><td>Relation exists but is not a <code>route_master</code>.</td></tr>
<tr><td>502</td><td><code>osm_error</code></td><td>OSM API returned an unexpected error.</td></tr>
</tbody>
</table>
<h3>Example</h3>
<pre>GET /api/route_master/15083966</pre>
<!-- ── Supported route types ─────────────────────────────────────────── -->
<h2>Supported route types</h2>
<p>The following OSM <code>route</code> tag values are accepted:</p>
<p>
<code>bus</code> &nbsp;
<code>ferry</code> &nbsp;
<code>funicular</code> &nbsp;
<code>light_rail</code> &nbsp;
<code>monorail</code> &nbsp;
<code>subway</code> &nbsp;
<code>train</code> &nbsp;
<code>tram</code> &nbsp;
<code>trolleybus</code>
</p>
</div>
</body>
</html>

View file

@ -13,6 +13,7 @@
<nav class="navbar navbar-dark bg-dark px-3" style="height:56px"> <nav class="navbar navbar-dark bg-dark px-3" style="height:56px">
<a class="navbar-brand" href="/">OSM Public Transport → GeoJSON</a> <a class="navbar-brand" href="/">OSM Public Transport → GeoJSON</a>
<a class="nav-link text-white" href="/docs">API docs</a>
</nav> </nav>
<div class="container-fluid h-100 p-0"> <div class="container-fluid h-100 p-0">
@ -48,6 +49,7 @@
<div id="route-master-panel" class="d-none"> <div id="route-master-panel" class="d-none">
<div class="mb-3"> <div class="mb-3">
<div class="fw-semibold" id="route-master-name"></div> <div class="fw-semibold" id="route-master-name"></div>
<a id="route-master-osm-link" class="small text-muted" target="_blank" rel="noopener">View on OSM ↗</a>
</div> </div>
<div class="fw-semibold small mb-1">Routes</div> <div class="fw-semibold small mb-1">Routes</div>
<div id="route-master-list"></div> <div id="route-master-list"></div>
@ -57,6 +59,7 @@
<div id="route-panel" class="d-none"> <div id="route-panel" class="d-none">
<div class="mb-3"> <div class="mb-3">
<div class="fw-semibold" id="route-name"></div> <div class="fw-semibold" id="route-name"></div>
<a id="route-osm-link" class="small text-muted" target="_blank" rel="noopener">View on OSM ↗</a>
</div> </div>
<!-- From / To slots --> <!-- From / To slots -->