diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b66a79b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +build/ +dist/ +.venv/ +*.geojson diff --git a/src/osm_geojson/pt/__pycache__/__init__.cpython-313.pyc b/src/osm_geojson/pt/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 78cb624..0000000 Binary files a/src/osm_geojson/pt/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/src/osm_geojson/pt/__pycache__/cli.cpython-313.pyc b/src/osm_geojson/pt/__pycache__/cli.cpython-313.pyc deleted file mode 100644 index 2fb996d..0000000 Binary files a/src/osm_geojson/pt/__pycache__/cli.cpython-313.pyc and /dev/null differ diff --git a/src/osm_geojson/pt/__pycache__/core.cpython-313.pyc b/src/osm_geojson/pt/__pycache__/core.cpython-313.pyc deleted file mode 100644 index 1434f58..0000000 Binary files a/src/osm_geojson/pt/__pycache__/core.cpython-313.pyc and /dev/null differ diff --git a/src/osm_geojson/pt/cli.py b/src/osm_geojson/pt/cli.py index 2fce1c4..e9766d7 100644 --- a/src/osm_geojson/pt/cli.py +++ b/src/osm_geojson/pt/cli.py @@ -6,12 +6,13 @@ import click from osm_geojson.pt.core import ( GeoJson, + OsmError, build_route_coords, fetch_relation_full, make_geojson, + nearest_coord_index, node_name, parse_elements, - nearest_coord_index, ) @@ -35,8 +36,12 @@ def cli() -> None: @click.argument("relation_id", type=int) def list_stations(relation_id: int) -> None: """List all stations in an OSM public transport route relation.""" - data = fetch_relation_full(relation_id) - nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + try: + 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"Stops ({len(stop_ids)}):") for i, sid in enumerate(stop_ids, 1): @@ -60,8 +65,13 @@ def route_between( no_stops: bool, ) -> None: """Output GeoJSON for the route segment between two named stations.""" - data = fetch_relation_full(relation_id) - nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + try: + 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) def find_stop(name: str) -> int | None: @@ -80,8 +90,8 @@ def route_between( if sid_to is None: errors.append(f"Station not found: {to_station!r}") if errors: - for e in errors: - click.echo(f"Error: {e}", err=True) + for msg in errors: + click.echo(f"Error: {msg}", err=True) click.echo("Available stations:", err=True) for sid in stop_ids: 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.") def full_route(relation_id: int, output: str | None, no_stops: bool) -> None: """Output GeoJSON for the entire route, end to end.""" - data = fetch_relation_full(relation_id) - nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + try: + 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) geojson = make_geojson(route_coords, stop_ids, nodes, tags, no_stops=no_stops) output_geojson(geojson, output) diff --git a/src/osm_geojson/pt/core.py b/src/osm_geojson/pt/core.py index c49d524..cef2e38 100644 --- a/src/osm_geojson/pt/core.py +++ b/src/osm_geojson/pt/core.py @@ -1,8 +1,7 @@ """Core data-fetching and processing functions for osm-pt-geojson.""" -import sys +import warnings from typing import Any -import click import requests OSM_API = "https://www.openstreetmap.org/api/0.6" @@ -14,17 +13,28 @@ OsmElement = 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]: """Fetch the full OSM API response for a relation, including all member ways and nodes.""" url = f"{OSM_API}/relation/{relation_id}/full.json" try: resp = requests.get(url, headers={"User-Agent": "osm-pt-geojson/1.0"}, timeout=30) except requests.RequestException as e: - click.echo(f"Error: {e}", err=True) - sys.exit(1) + 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: - click.echo(f"Error: HTTP {resp.status_code} fetching relation {relation_id}", err=True) - sys.exit(1) + raise OsmError( + f"Unexpected HTTP {resp.status_code} fetching relation {relation_id}.", 502 + ) result: dict[str, Any] = resp.json() return result @@ -48,8 +58,7 @@ def parse_elements( relation = elem if relation is None: - click.echo(f"Error: relation {relation_id} not found in API response", err=True) - sys.exit(1) + raise OsmError(f"Relation {relation_id} not found in API response.", 404) stop_ids: list[int] = [] way_ids: list[int] = [] @@ -92,7 +101,7 @@ def build_route_coords( elif chain[0] == wn[0]: chain = list(reversed(wn)) + chain[1:] 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) 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 +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: """Return a human-readable name for a node: name tag, ref tag, or node ID.""" tags: OsmTags = node.get("tags", {}) @@ -172,3 +231,73 @@ def make_geojson( ) 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 diff --git a/src/osm_geojson/py.typed b/src/osm_geojson/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_osm_pt_geojson.py b/tests/test_osm_pt_geojson.py index c89e236..afdd8e5 100644 --- a/tests/test_osm_pt_geojson.py +++ b/tests/test_osm_pt_geojson.py @@ -8,6 +8,7 @@ from click.testing import CliRunner from osm_geojson.pt import core from osm_geojson.pt.cli import cli, output_geojson +from osm_geojson.pt.core import OsmError FIXTURES = Path(__file__).parent / "fixtures" 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: - """Requesting a relation ID not present in the response exits with an error.""" - with pytest.raises(SystemExit): + """Requesting a relation ID not present in the response raises OsmError.""" + with pytest.raises(OsmError): core.parse_elements(full_data, 9999999) diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..65da89e --- /dev/null +++ b/web/app.py @@ -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("/") +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/") +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/") +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/") +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) diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..a94b6b5 --- /dev/null +++ b/web/static/app.js @@ -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: '© OpenStreetMap 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} + */ +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); +} diff --git a/web/static/favicon.svg b/web/static/favicon.svg new file mode 100644 index 0000000..f9190cd --- /dev/null +++ b/web/static/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..03f0101 --- /dev/null +++ b/web/static/style.css @@ -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; +} diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..ed496f0 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,126 @@ + + + + + + OSM Public Transport → GeoJSON + + + + + + + + + +
+
+ + + + + +
+ +
+
+ + + + + + +