Add web frontend and refactor core to use OsmError

- Refactor core.py: replace sys.exit() calls with OsmError exceptions
  so the library is safe to use from Flask and other callers
- Add fetch_sibling_routes and fetch_route_master_routes to core.py
- Add Flask web frontend (web/app.py, templates, static assets):
  - Map view with Leaflet; full route drawn in grey on load
  - Sidebar stop list; active-slot UX for From/To selection
  - Segment preview and download (full route or selected segment)
  - Include-stops toggle applied client-side
  - Bookmarkable URLs: GET /<relation_id>
  - Clear selection button
  - Other directions panel (sibling routes from same route_master)
  - route_master handling: draws all member routes in colour on map
    with links to each individual direction
- Add SVG favicon
- Add py.typed marker; add .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-02-27 18:18:12 +00:00
parent d3e6d7ac42
commit e0ade9e5ab
13 changed files with 1049 additions and 20 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
__pycache__/
*.pyc
*.pyo
*.egg-info/
build/
dist/
.venv/
*.geojson

View file

@ -6,12 +6,13 @@ import click
from osm_geojson.pt.core import ( from osm_geojson.pt.core import (
GeoJson, GeoJson,
OsmError,
build_route_coords, build_route_coords,
fetch_relation_full, fetch_relation_full,
make_geojson, make_geojson,
nearest_coord_index,
node_name, node_name,
parse_elements, parse_elements,
nearest_coord_index,
) )
@ -35,8 +36,12 @@ def cli() -> None:
@click.argument("relation_id", type=int) @click.argument("relation_id", type=int)
def list_stations(relation_id: int) -> None: def list_stations(relation_id: int) -> None:
"""List all stations in an OSM public transport route relation.""" """List all stations in an OSM public transport route relation."""
data = fetch_relation_full(relation_id) try:
nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) data = fetch_relation_full(relation_id)
nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id)
except OsmError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
click.echo(f"Route: {tags.get('name', relation_id)}") click.echo(f"Route: {tags.get('name', relation_id)}")
click.echo(f"Stops ({len(stop_ids)}):") click.echo(f"Stops ({len(stop_ids)}):")
for i, sid in enumerate(stop_ids, 1): for i, sid in enumerate(stop_ids, 1):
@ -60,8 +65,13 @@ def route_between(
no_stops: bool, no_stops: bool,
) -> None: ) -> None:
"""Output GeoJSON for the route segment between two named stations.""" """Output GeoJSON for the route segment between two named stations."""
data = fetch_relation_full(relation_id) try:
nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) data = fetch_relation_full(relation_id)
nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id)
except OsmError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
route_coords = build_route_coords(way_ids, ways, nodes) route_coords = build_route_coords(way_ids, ways, nodes)
def find_stop(name: str) -> int | None: def find_stop(name: str) -> int | None:
@ -80,8 +90,8 @@ def route_between(
if sid_to is None: if sid_to is None:
errors.append(f"Station not found: {to_station!r}") errors.append(f"Station not found: {to_station!r}")
if errors: if errors:
for e in errors: for msg in errors:
click.echo(f"Error: {e}", err=True) click.echo(f"Error: {msg}", err=True)
click.echo("Available stations:", err=True) click.echo("Available stations:", err=True)
for sid in stop_ids: for sid in stop_ids:
if sid in nodes: if sid in nodes:
@ -104,8 +114,12 @@ def route_between(
@click.option("--no-stops", is_flag=True, default=False, help="Omit stop points from output.") @click.option("--no-stops", is_flag=True, default=False, help="Omit stop points from output.")
def full_route(relation_id: int, output: str | None, no_stops: bool) -> None: def full_route(relation_id: int, output: str | None, no_stops: bool) -> None:
"""Output GeoJSON for the entire route, end to end.""" """Output GeoJSON for the entire route, end to end."""
data = fetch_relation_full(relation_id) try:
nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) data = fetch_relation_full(relation_id)
nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id)
except OsmError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
route_coords = build_route_coords(way_ids, ways, nodes) route_coords = build_route_coords(way_ids, ways, nodes)
geojson = make_geojson(route_coords, stop_ids, nodes, tags, no_stops=no_stops) geojson = make_geojson(route_coords, stop_ids, nodes, tags, no_stops=no_stops)
output_geojson(geojson, output) output_geojson(geojson, output)

View file

@ -1,8 +1,7 @@
"""Core data-fetching and processing functions for osm-pt-geojson.""" """Core data-fetching and processing functions for osm-pt-geojson."""
import sys import warnings
from typing import Any from typing import Any
import click
import requests import requests
OSM_API = "https://www.openstreetmap.org/api/0.6" OSM_API = "https://www.openstreetmap.org/api/0.6"
@ -14,17 +13,28 @@ OsmElement = dict[str, Any]
GeoJson = dict[str, Any] GeoJson = dict[str, Any]
class OsmError(Exception):
"""Raised when an OSM API request fails or returns unexpected data."""
def __init__(self, message: str, status_code: int = 500) -> None:
"""Initialise with a human-readable message and an HTTP-style status code."""
super().__init__(message)
self.status_code = status_code
def fetch_relation_full(relation_id: int) -> dict[str, Any]: def fetch_relation_full(relation_id: int) -> dict[str, Any]:
"""Fetch the full OSM API response for a relation, including all member ways and nodes.""" """Fetch the full OSM API response for a relation, including all member ways and nodes."""
url = f"{OSM_API}/relation/{relation_id}/full.json" url = f"{OSM_API}/relation/{relation_id}/full.json"
try: try:
resp = requests.get(url, headers={"User-Agent": "osm-pt-geojson/1.0"}, timeout=30) resp = requests.get(url, headers={"User-Agent": "osm-pt-geojson/1.0"}, timeout=30)
except requests.RequestException as e: except requests.RequestException as e:
click.echo(f"Error: {e}", err=True) raise OsmError(f"Network error fetching relation {relation_id}: {e}", 502) from e
sys.exit(1) if resp.status_code == 404:
raise OsmError(f"Relation {relation_id} not found on OpenStreetMap.", 404)
if resp.status_code != 200: if resp.status_code != 200:
click.echo(f"Error: HTTP {resp.status_code} fetching relation {relation_id}", err=True) raise OsmError(
sys.exit(1) f"Unexpected HTTP {resp.status_code} fetching relation {relation_id}.", 502
)
result: dict[str, Any] = resp.json() result: dict[str, Any] = resp.json()
return result return result
@ -48,8 +58,7 @@ def parse_elements(
relation = elem relation = elem
if relation is None: if relation is None:
click.echo(f"Error: relation {relation_id} not found in API response", err=True) raise OsmError(f"Relation {relation_id} not found in API response.", 404)
sys.exit(1)
stop_ids: list[int] = [] stop_ids: list[int] = []
way_ids: list[int] = [] way_ids: list[int] = []
@ -92,7 +101,7 @@ def build_route_coords(
elif chain[0] == wn[0]: elif chain[0] == wn[0]:
chain = list(reversed(wn)) + chain[1:] chain = list(reversed(wn)) + chain[1:]
else: else:
click.echo(f"Warning: gap before way {way_id}", err=True) warnings.warn(f"Gap in route geometry before way {way_id}", stacklevel=2)
chain.extend(wn) chain.extend(wn)
return [[nodes[nid]["lon"], nodes[nid]["lat"]] for nid in chain if nid in nodes] return [[nodes[nid]["lon"], nodes[nid]["lat"]] for nid in chain if nid in nodes]
@ -110,6 +119,56 @@ def nearest_coord_index(lon: float, lat: float, route_coords: list[Coord]) -> in
return best_i return best_i
def fetch_sibling_routes(relation_id: int) -> list[dict[str, Any]]:
"""Return sibling route relations from the same route_master, excluding self.
Fetches parent relations of relation_id, finds any route_master parents,
and returns the other member relations with their names. Returns an empty
list if there is no route_master parent or if any request fails.
"""
url = f"{OSM_API}/relation/{relation_id}/relations.json"
try:
resp = requests.get(url, headers={"User-Agent": "osm-pt-geojson/1.0"}, timeout=30)
except requests.RequestException:
return []
if resp.status_code != 200:
return []
data: dict[str, Any] = resp.json()
sibling_ids: list[int] = []
for elem in data.get("elements", []):
if elem.get("tags", {}).get("type") != "route_master":
continue
for member in elem.get("members", []):
if member["type"] == "relation" and member["ref"] != relation_id:
sibling_ids.append(member["ref"])
result: list[dict[str, Any]] = []
for sid in sibling_ids:
surl = f"{OSM_API}/relation/{sid}.json"
try:
sresp = requests.get(
surl, headers={"User-Agent": "osm-pt-geojson/1.0"}, timeout=30
)
except requests.RequestException:
continue
if sresp.status_code != 200:
continue
sdata: dict[str, Any] = sresp.json()
for elem in sdata.get("elements", []):
if elem["type"] == "relation" and elem["id"] == sid:
stags: OsmTags = elem.get("tags", {})
result.append(
{
"id": sid,
"name": stags.get("name", str(sid)),
"ref": stags.get("ref"),
}
)
break
return result
def node_name(node: OsmElement) -> str: def node_name(node: OsmElement) -> str:
"""Return a human-readable name for a node: name tag, ref tag, or node ID.""" """Return a human-readable name for a node: name tag, ref tag, or node ID."""
tags: OsmTags = node.get("tags", {}) tags: OsmTags = node.get("tags", {})
@ -172,3 +231,73 @@ def make_geojson(
) )
return {"type": "FeatureCollection", "features": features} return {"type": "FeatureCollection", "features": features}
def fetch_route_master_routes(
relation_id: int,
) -> tuple[OsmTags, list[dict[str, Any]]]:
"""Fetch a route_master relation and return (rm_tags, routes).
Raises OsmError if the relation is not found or is not a route_master.
Each entry in routes has: id, name, ref, from, to, geojson (lines only).
Members that cannot be fetched are included with null geojson.
"""
url = f"{OSM_API}/relation/{relation_id}.json"
try:
resp = requests.get(url, headers={"User-Agent": "osm-pt-geojson/1.0"}, timeout=30)
except requests.RequestException as e:
raise OsmError(f"Network error fetching relation {relation_id}: {e}", 502) from e
if resp.status_code == 404:
raise OsmError(f"Relation {relation_id} not found on OpenStreetMap.", 404)
if resp.status_code != 200:
raise OsmError(
f"Unexpected HTTP {resp.status_code} fetching relation {relation_id}.", 502
)
data: dict[str, Any] = resp.json()
rm_tags: OsmTags = {}
member_ids: list[int] = []
for elem in data.get("elements", []):
if elem["type"] == "relation" and elem["id"] == relation_id:
rm_tags = elem.get("tags", {})
for m in elem.get("members", []):
if m["type"] == "relation":
member_ids.append(m["ref"])
break
if not rm_tags:
raise OsmError(f"Relation {relation_id} not found in API response.", 404)
if rm_tags.get("type") != "route_master":
raise OsmError(
f"Relation {relation_id} is not a route_master "
f"(type={rm_tags.get('type')!r}).",
422,
)
routes: list[dict[str, Any]] = []
for mid in member_ids:
entry: dict[str, Any]
try:
full_data = fetch_relation_full(mid)
nodes, ways, stop_ids, way_ids, tags = parse_elements(full_data, mid)
route_coords = build_route_coords(way_ids, ways, nodes)
entry = {
"id": mid,
"name": tags.get("name", str(mid)),
"ref": tags.get("ref"),
"from": tags.get("from"),
"to": tags.get("to"),
"geojson": make_geojson(route_coords, stop_ids, nodes, tags, no_stops=True),
}
except OsmError:
entry = {
"id": mid,
"name": str(mid),
"ref": None,
"from": None,
"to": None,
"geojson": None,
}
routes.append(entry)
return rm_tags, routes

0
src/osm_geojson/py.typed Normal file
View file

View file

@ -8,6 +8,7 @@ from click.testing import CliRunner
from osm_geojson.pt import core from osm_geojson.pt import core
from osm_geojson.pt.cli import cli, output_geojson from osm_geojson.pt.cli import cli, output_geojson
from osm_geojson.pt.core import OsmError
FIXTURES = Path(__file__).parent / "fixtures" FIXTURES = Path(__file__).parent / "fixtures"
FULL_URL = "https://www.openstreetmap.org/api/0.6/relation/15083963/full.json" FULL_URL = "https://www.openstreetmap.org/api/0.6/relation/15083963/full.json"
@ -57,8 +58,8 @@ def test_parse_elements_tags(parsed: tuple) -> None:
def test_parse_elements_unknown_relation(full_data: dict) -> None: def test_parse_elements_unknown_relation(full_data: dict) -> None:
"""Requesting a relation ID not present in the response exits with an error.""" """Requesting a relation ID not present in the response raises OsmError."""
with pytest.raises(SystemExit): with pytest.raises(OsmError):
core.parse_elements(full_data, 9999999) core.parse_elements(full_data, 9999999)

210
web/app.py Normal file
View file

@ -0,0 +1,210 @@
"""Flask web frontend for osm-pt-geojson."""
import re
from typing import Any
from flask import Flask, jsonify, redirect, render_template, request, url_for
from flask.typing import ResponseReturnValue
from osm_geojson.pt.core import (
OsmError,
build_route_coords,
fetch_relation_full,
fetch_route_master_routes,
fetch_sibling_routes,
make_geojson,
nearest_coord_index,
node_name,
parse_elements,
)
app = Flask(__name__)
app.secret_key = "dev"
PUBLIC_TRANSPORT_ROUTE_TYPES = {
"bus", "trolleybus", "tram", "subway", "train",
"light_rail", "monorail", "ferry", "funicular",
}
def parse_relation_id(text: str) -> int | None:
"""Extract a relation ID from a bare integer, partial path, or full OSM URL."""
text = text.strip()
m = re.search(r"relation/(\d+)", text)
if m:
return int(str(m.group(1)))
if re.fullmatch(r"\d+", text):
return int(text)
return None
def check_route_tags(
tags: dict[str, str], relation_id: int
) -> tuple[dict[str, Any], int] | None:
"""Return a (error_dict, http_status) if the relation is not a supported public transport route."""
if tags.get("type") == "route_master":
return {
"error": "is_route_master",
"message": (
f"Relation {relation_id} is a route_master relation. "
"Select one of its individual routes."
),
}, 422
if tags.get("type") != "route":
kind = tags.get("type", "unknown")
return {
"error": "not_public_transport",
"message": (
f"Relation {relation_id} is a {kind!r} relation, not a route relation. "
"This tool only supports public transport route relations."
),
}, 422
route = tags.get("route")
if not route:
return {
"error": "not_public_transport",
"message": (
f"Relation {relation_id} is a route relation but has no 'route' tag. "
"Cannot determine the route type."
),
}, 422
if route not in PUBLIC_TRANSPORT_ROUTE_TYPES:
supported = ", ".join(sorted(PUBLIC_TRANSPORT_ROUTE_TYPES))
return {
"error": "not_public_transport",
"route_type": route,
"message": (
f"Relation {relation_id} is a {route!r} route, not a public transport route. "
f"This tool supports: {supported}."
),
}, 422
return None
@app.route("/")
def index() -> ResponseReturnValue:
"""Render the landing page with no relation loaded."""
error = request.args.get("error")
return render_template("index.html", relation_id=None, error=error)
@app.route("/<int:relation_id>")
def route_page(relation_id: int) -> ResponseReturnValue:
"""Render the page with a relation pre-loaded."""
return render_template("index.html", relation_id=relation_id, error=None)
@app.route("/load", methods=["POST"])
def load() -> ResponseReturnValue:
"""Parse user input and redirect to the relation page."""
raw = request.form.get("relation", "").strip()
relation_id = parse_relation_id(raw)
if relation_id is None:
return redirect(url_for("index", error="Could not find a relation ID in that input."))
return redirect(url_for("route_page", relation_id=relation_id))
@app.route("/api/route/<int:relation_id>")
def api_route(relation_id: int) -> ResponseReturnValue:
"""Return the full route as GeoJSON plus a list of stops."""
try:
data = fetch_relation_full(relation_id)
nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id)
except OsmError as e:
return jsonify({"error": "osm_error", "message": str(e)}), e.status_code
err = check_route_tags(tags, relation_id)
if err:
return jsonify(err[0]), err[1]
route_coords = build_route_coords(way_ids, ways, nodes)
geojson = make_geojson(route_coords, stop_ids, nodes, tags)
stops = []
for sid in stop_ids:
if sid in nodes:
n = nodes[sid]
stops.append({"name": node_name(n), "lat": n["lat"], "lon": n["lon"]})
other_directions = fetch_sibling_routes(relation_id)
return jsonify({
"name": tags.get("name", str(relation_id)),
"ref": tags.get("ref"),
"stops": stops,
"geojson": geojson,
"other_directions": other_directions,
})
@app.route("/api/segment/<int:relation_id>")
def api_segment(relation_id: int) -> ResponseReturnValue:
"""Return GeoJSON for the segment between two named stops."""
from_name = request.args.get("from", "").strip()
to_name = request.args.get("to", "").strip()
include_stops = request.args.get("stops", "1") != "0"
if not from_name or not to_name:
return jsonify({
"error": "missing_params",
"message": "Both 'from' and 'to' parameters are required.",
}), 400
try:
data = fetch_relation_full(relation_id)
nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id)
except OsmError as e:
return jsonify({"error": "osm_error", "message": str(e)}), e.status_code
err = check_route_tags(tags, relation_id)
if err:
return jsonify(err[0]), err[1]
route_coords = build_route_coords(way_ids, ways, nodes)
def find_stop(name: str) -> int | None:
"""Return the node ID of the stop matching name (case-insensitive), or None."""
for sid in stop_ids:
if sid in nodes and node_name(nodes[sid]).lower() == name.lower():
return sid
return None
sid_from = find_stop(from_name)
sid_to = find_stop(to_name)
missing = [n for n, s in ((from_name, sid_from), (to_name, sid_to)) if s is None]
if missing:
available = [node_name(nodes[s]) for s in stop_ids if s in nodes]
return jsonify({
"error": "station_not_found",
"message": f"Station(s) not found: {', '.join(repr(m) for m in missing)}",
"available": available,
}), 404
assert sid_from is not None and sid_to is not None
idx_from = nearest_coord_index(nodes[sid_from]["lon"], nodes[sid_from]["lat"], route_coords)
idx_to = nearest_coord_index(nodes[sid_to]["lon"], nodes[sid_to]["lat"], route_coords)
geojson = make_geojson(
route_coords, stop_ids, nodes, tags,
idx_from=idx_from, idx_to=idx_to,
no_stops=not include_stops,
)
return jsonify(geojson)
@app.route("/api/route_master/<int:relation_id>")
def api_route_master(relation_id: int) -> ResponseReturnValue:
"""Return all member routes of a route_master with their GeoJSON geometries."""
try:
rm_tags, routes = fetch_route_master_routes(relation_id)
except OsmError as e:
return jsonify({"error": "osm_error", "message": str(e)}), e.status_code
return jsonify({
"name": rm_tags.get("name", str(relation_id)),
"ref": rm_tags.get("ref"),
"routes": routes,
})
if __name__ == "__main__":
app.run(debug=True)

467
web/static/app.js Normal file
View file

@ -0,0 +1,467 @@
/**
* osm-pt-geojson web frontend
* Handles map rendering, stop selection, segment preview and GeoJSON download.
*/
'use strict';
// ── Map setup ──────────────────────────────────────────────────────────────
const map = L.map('map').setView([20, 0], 2);
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);
// Leaflet layers
let fullRouteLayer = null; // thin grey polyline for the full route
let segmentLayer = null; // bold coloured polyline for the selected segment
let markerLayer = null; // all stop markers
let routeMasterLayers = []; // coloured polylines when viewing a route_master
// ── State ─────────────────────────────────────────────────────────────────
let routeData = null; // response from /api/route/
let segmentGeoJson = null; // last response from /api/segment/ (always includes stops)
let activeSlot = 'from'; // 'from' | 'to' | null
let selectedFrom = null; // stop name string or null
let selectedTo = null; // stop name string or null
// ── Helpers ────────────────────────────────────────────────────────────────
/**
* Show an error alert in the sidebar.
* @param {string} msg
*/
function showError(msg) {
const el = document.getElementById('js-alert');
document.getElementById('js-alert-msg').textContent = msg;
el.classList.remove('d-none');
}
/**
* Trigger a file download of text content.
* @param {string} content
* @param {string} filename
*/
function downloadText(content, filename) {
const blob = new Blob([content], { type: 'application/geo+json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
/**
* Return a safe filename fragment from a stop name.
* @param {string} name
* @returns {string}
*/
function safeName(name) {
return name.replace(/[^a-zA-Z0-9_\-]/g, '_').replace(/_+/g, '_');
}
// ── Slot UI ────────────────────────────────────────────────────────────────
/**
* Update the From/To slot boxes to reflect current state.
*/
function renderSlots() {
const slots = { from: selectedFrom, to: selectedTo };
for (const [id, value] of Object.entries(slots)) {
const box = document.getElementById(`slot-${id}`);
const text = document.getElementById(`slot-${id}-text`);
const pencil = document.getElementById(`slot-${id}-pencil`);
const isActive = activeSlot === id;
box.classList.toggle('active', isActive);
pencil.classList.toggle('d-none', !isActive);
if (value) {
text.textContent = value;
text.classList.remove('placeholder');
} else {
text.textContent = 'Click a stop…';
text.classList.add('placeholder');
}
}
}
/**
* Set which slot is active. Pass null to deactivate both.
* @param {'from'|'to'|null} slot
*/
function setActiveSlot(slot) {
activeSlot = slot;
renderSlots();
}
// ── Stop selection ─────────────────────────────────────────────────────────
/**
* Handle a stop being selected (from list click or map marker click).
* @param {string} name
*/
function selectStop(name) {
if (activeSlot === 'from') {
selectedFrom = name;
setActiveSlot('to');
} else if (activeSlot === 'to') {
selectedTo = name;
setActiveSlot(null);
} else {
// Neither slot active — do nothing, user must click a slot first.
return;
}
renderStopList();
updateMarkers();
if (selectedFrom && selectedTo) {
loadSegment();
}
}
// ── Stop list ──────────────────────────────────────────────────────────────
/**
* Render the sidebar stop list from routeData.
*/
function renderStopList() {
if (!routeData) return;
const list = document.getElementById('stop-list');
document.getElementById('stop-count').textContent = `(${routeData.stops.length})`;
list.innerHTML = '';
for (const stop of routeData.stops) {
const div = document.createElement('div');
div.className = 'stop-item';
div.textContent = stop.name;
if (stop.name === selectedFrom) div.classList.add('is-from');
if (stop.name === selectedTo) div.classList.add('is-to');
div.addEventListener('click', () => selectStop(stop.name));
list.appendChild(div);
}
}
// ── Map rendering ──────────────────────────────────────────────────────────
/** Marker colours for stop states. */
const MARKER_COLOURS = {
from: '#198754', // green
to: '#dc3545', // red
segment: '#0d6efd', // blue
default: '#6c757d', // grey
};
/**
* Create a small circular Leaflet marker.
* @param {number} lat
* @param {number} lon
* @param {string} colour hex colour
* @param {string} title
* @returns {L.CircleMarker}
*/
function makeMarker(lat, lon, colour, title) {
return L.circleMarker([lat, lon], {
radius: 6,
color: colour,
fillColor: colour,
fillOpacity: 0.9,
weight: 2,
}).bindTooltip(title);
}
/**
* Determine which stops fall within the current segment (by index).
* Returns a Set of stop names.
* @returns {Set<string>}
*/
function segmentStopNames() {
if (!segmentGeoJson) return new Set();
return new Set(
segmentGeoJson.features
.filter(f => f.geometry.type === 'Point')
.map(f => f.properties.name)
);
}
/**
* Redraw all stop markers to reflect current selection state.
*/
function updateMarkers() {
if (!routeData) return;
if (markerLayer) markerLayer.remove();
markerLayer = L.layerGroup();
const inSegment = segmentStopNames();
for (const stop of routeData.stops) {
let colour;
if (stop.name === selectedFrom) colour = MARKER_COLOURS.from;
else if (stop.name === selectedTo) colour = MARKER_COLOURS.to;
else if (inSegment.has(stop.name)) colour = MARKER_COLOURS.segment;
else colour = MARKER_COLOURS.default;
const marker = makeMarker(stop.lat, stop.lon, colour, stop.name);
marker.on('click', () => selectStop(stop.name));
markerLayer.addLayer(marker);
}
markerLayer.addTo(map);
}
/**
* Draw or redraw the segment layer from segmentGeoJson, respecting the stops toggle.
*/
function renderSegment() {
if (segmentLayer) segmentLayer.remove();
if (!segmentGeoJson) return;
const includeStops = document.getElementById('include-stops').checked;
// Build a filtered GeoJSON: always keep LineString, conditionally keep Points.
const filtered = {
type: 'FeatureCollection',
features: segmentGeoJson.features.filter(f =>
f.geometry.type === 'LineString' || (f.geometry.type === 'Point' && includeStops)
),
};
segmentLayer = L.geoJSON(filtered, {
style: { color: '#0d6efd', weight: 5, opacity: 0.85 },
pointToLayer: (feature, latlng) =>
makeMarker(latlng.lat, latlng.lng, MARKER_COLOURS.segment, feature.properties.name),
}).addTo(map);
updateMarkers();
updateDownloadButtons();
}
// ── Download buttons ───────────────────────────────────────────────────────
/**
* Enable/disable the segment download button and wire up both download buttons.
*/
function updateDownloadButtons() {
const btn = document.getElementById('btn-download-segment');
if (segmentGeoJson && selectedFrom && selectedTo) {
btn.classList.remove('disabled');
} else {
btn.classList.add('disabled');
}
}
document.getElementById('btn-download-segment').addEventListener('click', () => {
if (!segmentGeoJson || !selectedFrom || !selectedTo) return;
const includeStops = document.getElementById('include-stops').checked;
const geojson = {
type: 'FeatureCollection',
features: segmentGeoJson.features.filter(f =>
f.geometry.type === 'LineString' || (f.geometry.type === 'Point' && includeStops)
),
};
const ref = routeData.ref || 'route';
const filename = `${safeName(ref)}-${safeName(selectedFrom)}-${safeName(selectedTo)}.geojson`;
downloadText(JSON.stringify(geojson, null, 2), filename);
});
document.getElementById('btn-download-full').addEventListener('click', () => {
if (!routeData) return;
const includeStops = document.getElementById('include-stops').checked;
const geojson = {
type: 'FeatureCollection',
features: routeData.geojson.features.filter(f =>
f.geometry.type === 'LineString' || (f.geometry.type === 'Point' && includeStops)
),
};
const ref = routeData.ref || 'route';
downloadText(JSON.stringify(geojson, null, 2), `${safeName(ref)}-full.geojson`);
});
// ── Clear button ───────────────────────────────────────────────────────────
document.getElementById('btn-clear').addEventListener('click', () => {
selectedFrom = null;
selectedTo = null;
segmentGeoJson = null;
if (segmentLayer) { segmentLayer.remove(); segmentLayer = null; }
setActiveSlot('from');
renderStopList();
updateMarkers();
updateDownloadButtons();
});
// ── Stops toggle ───────────────────────────────────────────────────────────
document.getElementById('include-stops').addEventListener('change', () => {
renderSegment(); // re-filters existing data, no network request
});
// ── Slot click handlers ────────────────────────────────────────────────────
document.getElementById('slot-from').addEventListener('click', () => setActiveSlot('from'));
document.getElementById('slot-to').addEventListener('click', () => setActiveSlot('to'));
// ── API calls ──────────────────────────────────────────────────────────────
/**
* Fetch the full route and render it on the map.
* @param {number} relationId
*/
async function loadRoute(relationId) {
try {
const resp = await fetch(`/api/route/${relationId}`);
const data = await resp.json();
if (!resp.ok) {
if (data.error === 'is_route_master') {
await loadRouteMaster(relationId);
return;
}
showError(data.message || `Error loading relation ${relationId}.`);
return;
}
routeData = data;
// Clean up any previous route_master view
for (const l of routeMasterLayers) l.remove();
routeMasterLayers = [];
document.getElementById('route-master-panel').classList.add('d-none');
selectedFrom = null;
selectedTo = null;
segmentGeoJson = null;
setActiveSlot('from');
// Show route panel
document.getElementById('route-panel').classList.remove('d-none');
document.getElementById('route-name').textContent = data.name;
// Draw full route in grey
if (fullRouteLayer) fullRouteLayer.remove();
fullRouteLayer = L.geoJSON(data.geojson, {
style: { color: '#adb5bd', weight: 3, opacity: 0.7 },
pointToLayer: () => null, // don't render stop points here
filter: f => f.geometry.type === 'LineString',
}).addTo(map);
map.fitBounds(fullRouteLayer.getBounds(), { padding: [20, 20] });
renderStopList();
updateMarkers();
updateDownloadButtons();
// Other directions (sibling routes from the same route_master)
const dirPanel = document.getElementById('other-directions-panel');
const dirList = document.getElementById('other-directions-list');
dirList.innerHTML = '';
if (data.other_directions && data.other_directions.length > 0) {
dirPanel.classList.remove('d-none');
for (const dir of data.other_directions) {
const a = document.createElement('a');
a.href = `/${dir.id}`;
a.className = 'stop-item d-block text-decoration-none';
a.textContent = dir.name;
dirList.appendChild(a);
}
} else {
dirPanel.classList.add('d-none');
}
} catch (e) {
showError('Network error loading route.');
}
}
/**
* Fetch the segment GeoJSON and render it.
*/
async function loadSegment() {
if (!selectedFrom || !selectedTo || !routeData) return;
const rid = RELATION_ID;
const params = new URLSearchParams({ from: selectedFrom, to: selectedTo, stops: '1' });
try {
const resp = await fetch(`/api/segment/${rid}?${params}`);
const data = await resp.json();
if (!resp.ok) {
showError(data.message || 'Error loading segment.');
return;
}
segmentGeoJson = data;
renderSegment();
} catch (e) {
showError('Network error loading segment.');
}
}
/**
* Fetch and display all member routes of a route_master relation.
* @param {number} relationId
*/
async function loadRouteMaster(relationId) {
try {
const resp = await fetch(`/api/route_master/${relationId}`);
const data = await resp.json();
if (!resp.ok) {
showError(data.message || `Error loading route master ${relationId}.`);
return;
}
// Clear individual route state
routeData = null;
selectedFrom = null;
selectedTo = null;
segmentGeoJson = null;
if (fullRouteLayer) { fullRouteLayer.remove(); fullRouteLayer = null; }
if (segmentLayer) { segmentLayer.remove(); segmentLayer = null; }
if (markerLayer) { markerLayer.remove(); markerLayer = null; }
for (const l of routeMasterLayers) l.remove();
routeMasterLayers = [];
// Hide individual route panel, show route_master panel
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;
const colours = ['#0d6efd', '#dc3545', '#198754', '#fd7e14', '#6f42c1'];
const list = document.getElementById('route-master-list');
list.innerHTML = '';
data.routes.forEach((route, i) => {
const colour = colours[i % colours.length];
if (route.geojson) {
const layer = L.geoJSON(route.geojson, {
style: { color: colour, weight: 4, opacity: 0.85 },
}).addTo(map);
routeMasterLayers.push(layer);
}
const div = document.createElement('div');
div.className = 'stop-item d-flex align-items-center gap-2';
const dot = document.createElement('span');
dot.style.cssText =
`display:inline-block;width:10px;height:10px;border-radius:50%;` +
`background:${colour};flex-shrink:0`;
const a = document.createElement('a');
a.href = `/${route.id}`;
a.className = 'text-decoration-none text-reset flex-grow-1';
a.textContent = route.name;
div.appendChild(dot);
div.appendChild(a);
list.appendChild(div);
});
if (routeMasterLayers.length > 0) {
let bounds = routeMasterLayers[0].getBounds();
for (const l of routeMasterLayers.slice(1)) bounds = bounds.extend(l.getBounds());
map.fitBounds(bounds, { padding: [20, 20] });
}
} catch (e) {
showError('Network error loading route master.');
}
}
// ── Init ───────────────────────────────────────────────────────────────────
if (RELATION_ID) {
loadRoute(RELATION_ID);
}

6
web/static/favicon.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#212529"/>
<line x1="7" y1="23" x2="25" y2="9" stroke="#adb5bd" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="7" cy="23" r="4" fill="#198754"/>
<circle cx="25" cy="9" r="4" fill="#dc3545"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

68
web/static/style.css Normal file
View file

@ -0,0 +1,68 @@
html, body {
height: 100%;
overflow: hidden;
}
#main-row {
height: calc(100vh - 56px); /* subtract navbar height */
}
#sidebar {
height: 100%;
overflow-y: auto;
padding: 1rem;
}
#map {
height: 100%;
}
.slot-box {
border: 2px solid #dee2e6;
border-radius: 6px;
padding: 0.4rem 0.6rem;
cursor: pointer;
min-height: 2.2rem;
display: flex;
align-items: center;
justify-content: space-between;
transition: border-color 0.15s;
}
.slot-box.active {
border-color: #0d6efd;
background: #f0f5ff;
}
.slot-box .placeholder {
color: #adb5bd;
font-style: italic;
}
.slot-box .pencil {
color: #0d6efd;
font-size: 0.8rem;
}
#stop-list .stop-item {
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
}
#stop-list .stop-item:hover {
background: #e9ecef;
}
#stop-list .stop-item.is-from {
background: #d1e7dd;
}
#stop-list .stop-item.is-to {
background: #f8d7da;
}
.leaflet-container {
font-family: inherit;
}

126
web/templates/index.html Normal file
View file

@ -0,0 +1,126 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>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">
<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" style="height:56px">
<a class="navbar-brand" href="/">OSM Public Transport → GeoJSON</a>
</nav>
<div class="container-fluid h-100 p-0">
<div class="row g-0 h-100" id="main-row">
<!-- Sidebar -->
<div class="col-3 border-end" id="sidebar">
<!-- Load form -->
<form method="post" action="/load" class="mb-3">
<label class="form-label fw-semibold small">Relation ID or OSM URL</label>
<div class="input-group input-group-sm">
<input type="text" name="relation" class="form-control"
placeholder="e.g. 15083963"
value="{{ relation_id or '' }}">
<button class="btn btn-primary" type="submit">Load</button>
</div>
</form>
<!-- Error alert -->
{% if error %}
<div class="alert alert-danger alert-dismissible py-2 small" role="alert">
{{ error }}
<button type="button" class="btn-close btn-sm" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<div id="js-alert" class="alert alert-danger alert-dismissible py-2 small d-none" role="alert">
<span id="js-alert-msg"></span>
<button type="button" class="btn-close btn-sm" data-bs-dismiss="alert"></button>
</div>
<!-- Route master panel (shown when a route_master relation is loaded) -->
<div id="route-master-panel" class="d-none">
<div class="mb-3">
<div class="fw-semibold" id="route-master-name"></div>
</div>
<div class="fw-semibold small mb-1">Routes</div>
<div id="route-master-list"></div>
</div>
<!-- Route info (hidden until loaded) -->
<div id="route-panel" class="d-none">
<div class="mb-3">
<div class="fw-semibold" id="route-name"></div>
</div>
<!-- From / To slots -->
<div class="mb-1">
<label class="form-label fw-semibold small mb-1">From</label>
<div class="slot-box active" id="slot-from" title="Click to make active, then click a stop">
<span class="placeholder" id="slot-from-text">Click a stop…</span>
<span class="pencil" id="slot-from-pencil"></span>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">To</label>
<div class="slot-box" id="slot-to" title="Click to make active, then click a stop">
<span class="placeholder" id="slot-to-text">Click a stop…</span>
<span class="pencil d-none" id="slot-to-pencil"></span>
</div>
</div>
<!-- Clear button -->
<button class="btn btn-outline-secondary btn-sm w-100 mb-3" id="btn-clear" type="button">
✕ Clear selection
</button>
<!-- Include stops toggle -->
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="include-stops" checked>
<label class="form-check-label small" for="include-stops">Include stops in GeoJSON</label>
</div>
<!-- Download buttons -->
<div class="d-grid gap-2 mb-3">
<button class="btn btn-outline-secondary btn-sm disabled" id="btn-download-segment">
↓ Download segment
</button>
<button class="btn btn-outline-secondary btn-sm" id="btn-download-full">
↓ Download full route
</button>
</div>
<!-- Other directions -->
<div id="other-directions-panel" class="d-none mb-3">
<div class="fw-semibold small mb-1">Other directions</div>
<div id="other-directions-list"></div>
</div>
<!-- Stop list -->
<div class="fw-semibold small mb-1">Stops <span id="stop-count" class="text-muted"></span></div>
<div id="stop-list"></div>
</div>
</div><!-- /sidebar -->
<!-- Map -->
<div class="col-9" id="map"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// Relation ID injected by Flask; null if none.
const RELATION_ID = {{ relation_id | tojson }};
</script>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>