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:
parent
d3e6d7ac42
commit
e0ade9e5ab
13 changed files with 1049 additions and 20 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
*.geojson
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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."""
|
||||
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."""
|
||||
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."""
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
0
src/osm_geojson/py.typed
Normal file
0
src/osm_geojson/py.typed
Normal file
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
210
web/app.py
Normal file
210
web/app.py
Normal 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
467
web/static/app.js
Normal 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
6
web/static/favicon.svg
Normal 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
68
web/static/style.css
Normal 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
126
web/templates/index.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue