@@ -25,6 +25,11 @@ Die Felder „Von" und „Nach" haben eine Autovervollständigung aus allen
|
|||||||
(© OpenStreetMap-Mitwirkende, ODbL) und liegen als Snapshot im Repo
|
(© OpenStreetMap-Mitwirkende, ODbL) und liegen als Snapshot im Repo
|
||||||
(`data/stops_at.csv.gz`, ~37 000 Haltestellen).
|
(`data/stops_at.csv.gz`, ~37 000 Haltestellen).
|
||||||
|
|
||||||
|
Zusätzlich ist pro Haltestelle die Menge der Linien hinterlegt (aus den
|
||||||
|
OSM-`route`-Relationen, ~87 % der Halte). Das Feld „Linie" schlägt daraus die
|
||||||
|
Linien vor, die an den gewählten Halten verkehren, und füllt sich selbst aus,
|
||||||
|
wenn nur eine Linie in Frage kommt. Freitext bleibt immer möglich.
|
||||||
|
|
||||||
Nach dem Anlegen der Tabellen den Snapshot in die DB laden:
|
Nach dem Anlegen der Tabellen den Snapshot in die DB laden:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -35,7 +40,8 @@ Der Import ist idempotent (Upsert) und braucht kein Internet. Im Produktivbetrie
|
|||||||
übernimmt das die Unit `pbt-import-stops.service` (wird bei jedem Deploy
|
übernimmt das die Unit `pbt-import-stops.service` (wird bei jedem Deploy
|
||||||
angestoßen, s. u.).
|
angestoßen, s. u.).
|
||||||
|
|
||||||
Snapshot neu von OpenStreetMap holen (dauert 1–2 min, braucht Netzugang):
|
Snapshot neu von OpenStreetMap holen (Halte + Linien, ~5 min, braucht Netzugang
|
||||||
|
und ~1 GB RAM):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/fetch_stops.py # überschreibt data/stops_at.csv.gz
|
python scripts/fetch_stops.py # überschreibt data/stops_at.csv.gz
|
||||||
|
|||||||
@@ -146,6 +146,43 @@ def register_routes(app):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _line_sort_key(ref):
|
||||||
|
head = ""
|
||||||
|
i = 0
|
||||||
|
while i < len(ref) and not ref[i].isdigit():
|
||||||
|
head += ref[i]
|
||||||
|
i += 1
|
||||||
|
num = ref[i:]
|
||||||
|
digits = ""
|
||||||
|
while num and num[0].isdigit():
|
||||||
|
digits += num[0]
|
||||||
|
num = num[1:]
|
||||||
|
return (head.lower(), int(digits) if digits else -1, num.lower())
|
||||||
|
|
||||||
|
@app.route("/api/lines")
|
||||||
|
@login_required
|
||||||
|
def api_lines():
|
||||||
|
"""Line suggestions for the "Linie" field, scoped to the chosen stops."""
|
||||||
|
def stop_lines(param):
|
||||||
|
stop = db.session.get(Stop, int(param)) if (param or "").isdigit() else None
|
||||||
|
return set(stop.line_list()) if stop else set()
|
||||||
|
|
||||||
|
origin = stop_lines(request.args.get("origin_stop_id"))
|
||||||
|
destination = stop_lines(request.args.get("destination_stop_id"))
|
||||||
|
candidates = origin | destination
|
||||||
|
if not candidates:
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
needle = request.args.get("q", "").strip().lower()
|
||||||
|
both = origin & destination
|
||||||
|
refs = sorted(
|
||||||
|
(r for r in candidates if not needle or r.lower().startswith(needle)),
|
||||||
|
key=lambda r: (r not in both, _line_sort_key(r)),
|
||||||
|
)
|
||||||
|
return jsonify(
|
||||||
|
[{"ref": r, "both": r in both} for r in refs[:20]]
|
||||||
|
)
|
||||||
|
|
||||||
def parse_trip_date(raw_value):
|
def parse_trip_date(raw_value):
|
||||||
raw_value = (raw_value or "").strip()
|
raw_value = (raw_value or "").strip()
|
||||||
try:
|
try:
|
||||||
|
|||||||
Binary file not shown.
@@ -55,6 +55,12 @@ class Stop(db.Model):
|
|||||||
municipality = db.Column(db.String(120))
|
municipality = db.Column(db.String(120))
|
||||||
latitude = db.Column(db.Float, nullable=False)
|
latitude = db.Column(db.Float, nullable=False)
|
||||||
longitude = db.Column(db.Float, nullable=False)
|
longitude = db.Column(db.Float, nullable=False)
|
||||||
|
# ";"-joined line refs serving this stop (e.g. "693" or "S2;U4;WLB"), from
|
||||||
|
# OSM route relations; empty when unknown. Used by the "Linie" autocomplete.
|
||||||
|
lines = db.Column(db.String(255), nullable=False, default="")
|
||||||
|
|
||||||
|
def line_list(self) -> list[str]:
|
||||||
|
return [ref for ref in self.lines.split(";") if ref]
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
db.UniqueConstraint("osm_type", "osm_id", name="uq_stops_osm"),
|
db.UniqueConstraint("osm_type", "osm_id", name="uq_stops_osm"),
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ WorkingDirectory=/opt/pbt
|
|||||||
# Secrets live in a root-owned file on the host, not in this tracked unit.
|
# Secrets live in a root-owned file on the host, not in this tracked unit.
|
||||||
# Copy pbt.env.example to /etc/pbt/pbt.env and fill in the real values.
|
# Copy pbt.env.example to /etc/pbt/pbt.env and fill in the real values.
|
||||||
EnvironmentFile=/etc/pbt/pbt.env
|
EnvironmentFile=/etc/pbt/pbt.env
|
||||||
ExecStart=/opt/pbt/venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 app:app
|
ExecStart=/opt/pbt/venv/bin/gunicorn --workers 2 --bind 0.0.0.0:8000 app:app
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
+184
-58
@@ -1,11 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Fetch all Austrian public-transport stops from OpenStreetMap (Overpass API)
|
"""Fetch all Austrian public-transport stops (and the lines serving them) from
|
||||||
and write a deduplicated snapshot to data/stops_at.csv.gz.
|
OpenStreetMap via the Overpass API and write a deduplicated snapshot to
|
||||||
|
data/stops_at.csv.gz.
|
||||||
|
|
||||||
Data © OpenStreetMap contributors, licensed under the ODbL.
|
Data © OpenStreetMap contributors, licensed under the ODbL.
|
||||||
|
|
||||||
Run this only when you want to refresh the snapshot; the import into the
|
Run this only when you want to refresh the snapshot; the import into the
|
||||||
database reads the checked-in CSV and needs no network access.
|
database reads the checked-in CSV and needs no network access. The route pass
|
||||||
|
downloads a few hundred MB in ~25 tiles and needs roughly 1 GB of RAM.
|
||||||
|
|
||||||
python scripts/fetch_stops.py
|
python scripts/fetch_stops.py
|
||||||
"""
|
"""
|
||||||
@@ -14,7 +16,9 @@ from __future__ import annotations
|
|||||||
import csv
|
import csv
|
||||||
import gzip
|
import gzip
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -29,7 +33,7 @@ OVERPASS_ENDPOINTS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Named nodes in Austria that represent a boardable stop ("Haltestelle").
|
# Named nodes in Austria that represent a boardable stop ("Haltestelle").
|
||||||
OVERPASS_QUERY = """
|
STOPS_QUERY = """
|
||||||
[out:json][timeout:600];
|
[out:json][timeout:600];
|
||||||
area["ISO3166-1"="AT"][admin_level=2]->.at;
|
area["ISO3166-1"="AT"][admin_level=2]->.at;
|
||||||
(
|
(
|
||||||
@@ -43,16 +47,30 @@ area["ISO3166-1"="AT"][admin_level=2]->.at;
|
|||||||
out body;
|
out body;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
ROUTE_MODES = "bus|trolleybus|tram|light_rail|subway|train|monorail|share_taxi"
|
||||||
|
|
||||||
|
# Austria bbox, tiled so each Overpass response stays small enough to parse.
|
||||||
|
BBOX = (46.35, 9.50, 49.05, 17.20) # south, west, north, east
|
||||||
|
LAT_STEP = 0.9
|
||||||
|
LON_STEP = 1.0
|
||||||
|
|
||||||
|
STOP_ROLES = {
|
||||||
|
"stop", "platform",
|
||||||
|
"stop_entry_only", "stop_exit_only",
|
||||||
|
"platform_entry_only", "platform_exit_only",
|
||||||
|
}
|
||||||
|
|
||||||
CSV_FIELDS = [
|
CSV_FIELDS = [
|
||||||
"osm_type",
|
"osm_type", "osm_id", "name", "stop_type",
|
||||||
"osm_id",
|
"municipality", "latitude", "longitude", "lines",
|
||||||
"name",
|
|
||||||
"stop_type",
|
|
||||||
"municipality",
|
|
||||||
"latitude",
|
|
||||||
"longitude",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
TYPE_RANK = {"subway": 5, "train": 4, "bus_station": 3, "tram": 2, "bus": 1, "other": 0}
|
||||||
|
|
||||||
|
# OSM keeps a separate node per direction for most bus stops (~20-60 m apart);
|
||||||
|
# nodes with the same name closer than this are treated as one stop.
|
||||||
|
MERGE_METERS = 250.0
|
||||||
|
|
||||||
|
|
||||||
def normalize_name(value: str) -> str:
|
def normalize_name(value: str) -> str:
|
||||||
"""Lowercase, fold accents and ß so search is diacritic-insensitive.
|
"""Lowercase, fold accents and ß so search is diacritic-insensitive.
|
||||||
@@ -64,6 +82,13 @@ def normalize_name(value: str) -> str:
|
|||||||
return "".join(ch for ch in decomposed if not unicodedata.combining(ch))
|
return "".join(ch for ch in decomposed if not unicodedata.combining(ch))
|
||||||
|
|
||||||
|
|
||||||
|
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
|
rlat1, rlon1, rlat2, rlon2 = map(math.radians, (lat1, lon1, lat2, lon2))
|
||||||
|
dlat, dlon = rlat2 - rlat1, rlon2 - rlon1
|
||||||
|
h = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
|
||||||
|
return 2 * 6_371_000 * math.asin(min(1.0, math.sqrt(h)))
|
||||||
|
|
||||||
|
|
||||||
def classify(tags: dict) -> str:
|
def classify(tags: dict) -> str:
|
||||||
if tags.get("station") == "subway" or tags.get("subway") == "yes":
|
if tags.get("station") == "subway" or tags.get("subway") == "yes":
|
||||||
return "subway"
|
return "subway"
|
||||||
@@ -75,8 +100,6 @@ def classify(tags: dict) -> str:
|
|||||||
return "bus_station"
|
return "bus_station"
|
||||||
if tags.get("highway") == "bus_stop" or tags.get("bus") == "yes":
|
if tags.get("highway") == "bus_stop" or tags.get("bus") == "yes":
|
||||||
return "bus"
|
return "bus"
|
||||||
if tags.get("public_transport") == "station":
|
|
||||||
return "other"
|
|
||||||
return "other"
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
@@ -88,56 +111,38 @@ def municipality_of(tags: dict) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def fetch_raw() -> dict:
|
def overpass(query: str, *, tries: int = 4) -> dict:
|
||||||
body = urllib.parse.urlencode({"data": OVERPASS_QUERY}).encode()
|
body = urllib.parse.urlencode({"data": query}).encode()
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
for endpoint in OVERPASS_ENDPOINTS:
|
for attempt in range(tries):
|
||||||
|
endpoint = OVERPASS_ENDPOINTS[attempt % len(OVERPASS_ENDPOINTS)]
|
||||||
try:
|
try:
|
||||||
print(f"Querying {endpoint} ...", file=sys.stderr)
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
endpoint, data=body, headers={"User-Agent": "pbt-stop-import/1.0"}
|
endpoint, data=body, headers={"User-Agent": "pbt-stop-import/1.0"}
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=600) as resp:
|
with urllib.request.urlopen(req, timeout=600) as resp:
|
||||||
return json.load(resp)
|
return json.load(resp)
|
||||||
except Exception as exc: # noqa: BLE001 - try the next mirror
|
except Exception as exc: # noqa: BLE001 - retry on another mirror
|
||||||
last_error = exc
|
last_error = exc
|
||||||
print(f" failed: {exc}", file=sys.stderr)
|
print(f" {endpoint} failed ({exc}); retrying", file=sys.stderr)
|
||||||
raise SystemExit(f"All Overpass endpoints failed; last error: {last_error}")
|
time.sleep(10 * (attempt + 1))
|
||||||
|
raise SystemExit(f"Overpass failed after {tries} tries; last error: {last_error}")
|
||||||
|
|
||||||
|
|
||||||
TYPE_RANK = {"subway": 5, "train": 4, "bus_station": 3, "tram": 2, "bus": 1, "other": 0}
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Stops
|
||||||
# Nodes with the same name closer than this are treated as one stop (OSM keeps a
|
# --------------------------------------------------------------------------- #
|
||||||
# separate node per direction for most bus stops, ~20-60 m apart).
|
def build_stop_rows(payload: dict) -> list[dict]:
|
||||||
MERGE_METERS = 250.0
|
|
||||||
|
|
||||||
|
|
||||||
def _dist_m(a: dict, b: dict) -> float:
|
|
||||||
import math
|
|
||||||
|
|
||||||
lat1, lon1, lat2, lon2 = map(
|
|
||||||
math.radians,
|
|
||||||
(a["latitude"], a["longitude"], b["latitude"], b["longitude"]),
|
|
||||||
)
|
|
||||||
dlat, dlon = lat2 - lat1, lon2 - lon1
|
|
||||||
h = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
|
||||||
return 2 * 6_371_000 * math.asin(min(1.0, math.sqrt(h)))
|
|
||||||
|
|
||||||
|
|
||||||
def build_rows(payload: dict) -> list[dict]:
|
|
||||||
# Collect candidate nodes grouped by normalized name.
|
|
||||||
by_name: dict[str, list[dict]] = {}
|
by_name: dict[str, list[dict]] = {}
|
||||||
for element in payload.get("elements", []):
|
for element in payload.get("elements", []):
|
||||||
if element.get("type") != "node":
|
if element.get("type") != "node":
|
||||||
continue
|
continue
|
||||||
tags = element.get("tags", {})
|
tags = element.get("tags", {})
|
||||||
name = (tags.get("name") or "").strip()
|
name = (tags.get("name") or "").strip()
|
||||||
lat = element.get("lat")
|
lat, lon = element.get("lat"), element.get("lon")
|
||||||
lon = element.get("lon")
|
|
||||||
if not name or lat is None or lon is None:
|
if not name or lat is None or lon is None:
|
||||||
continue
|
continue
|
||||||
node = {
|
node = {
|
||||||
"osm_type": "node",
|
|
||||||
"osm_id": int(element["id"]),
|
"osm_id": int(element["id"]),
|
||||||
"name": name,
|
"name": name,
|
||||||
"stop_type": classify(tags),
|
"stop_type": classify(tags),
|
||||||
@@ -148,12 +153,17 @@ def build_rows(payload: dict) -> list[dict]:
|
|||||||
by_name.setdefault(normalize_name(name), []).append(node)
|
by_name.setdefault(normalize_name(name), []).append(node)
|
||||||
|
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
for nodes in by_name.values():
|
for group in by_name.values():
|
||||||
# Greedy single-link clustering within the name group.
|
|
||||||
clusters: list[list[dict]] = []
|
clusters: list[list[dict]] = []
|
||||||
for node in sorted(nodes, key=lambda n: n["osm_id"]):
|
for node in sorted(group, key=lambda n: n["osm_id"]):
|
||||||
for cluster in clusters:
|
for cluster in clusters:
|
||||||
if any(_dist_m(node, member) <= MERGE_METERS for member in cluster):
|
if any(
|
||||||
|
haversine_m(
|
||||||
|
node["latitude"], node["longitude"],
|
||||||
|
other["latitude"], other["longitude"],
|
||||||
|
) <= MERGE_METERS
|
||||||
|
for other in cluster
|
||||||
|
):
|
||||||
cluster.append(node)
|
cluster.append(node)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -164,10 +174,8 @@ def build_rows(payload: dict) -> list[dict]:
|
|||||||
cluster,
|
cluster,
|
||||||
key=lambda n: (TYPE_RANK[n["stop_type"]], n["municipality"] != "", -n["osm_id"]),
|
key=lambda n: (TYPE_RANK[n["stop_type"]], n["municipality"] != "", -n["osm_id"]),
|
||||||
)
|
)
|
||||||
rows.append(
|
rows.append({
|
||||||
{
|
|
||||||
"osm_type": "node",
|
"osm_type": "node",
|
||||||
# lowest id in the cluster -> stable identity across refreshes
|
|
||||||
"osm_id": min(n["osm_id"] for n in cluster),
|
"osm_id": min(n["osm_id"] for n in cluster),
|
||||||
"name": lead["name"],
|
"name": lead["name"],
|
||||||
"stop_type": lead["stop_type"],
|
"stop_type": lead["stop_type"],
|
||||||
@@ -175,31 +183,149 @@ def build_rows(payload: dict) -> list[dict]:
|
|||||||
or next((n["municipality"] for n in cluster if n["municipality"]), ""),
|
or next((n["municipality"] for n in cluster if n["municipality"]), ""),
|
||||||
"latitude": round(sum(n["latitude"] for n in cluster) / len(cluster), 6),
|
"latitude": round(sum(n["latitude"] for n in cluster) / len(cluster), 6),
|
||||||
"longitude": round(sum(n["longitude"] for n in cluster) / len(cluster), 6),
|
"longitude": round(sum(n["longitude"] for n in cluster) / len(cluster), 6),
|
||||||
}
|
"lines": set(),
|
||||||
)
|
})
|
||||||
|
|
||||||
rows.sort(key=lambda r: (normalize_name(r["name"]), r["osm_id"]))
|
rows.sort(key=lambda r: (normalize_name(r["name"]), r["osm_id"]))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Lines (route relations -> stops)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def fetch_route_members() -> tuple[list[tuple[str, int]], dict[int, tuple[str, float, float]]]:
|
||||||
|
"""Return (ref, node_id) pairs plus node_id -> (name, lat, lon)."""
|
||||||
|
seen_rel: set[int] = set()
|
||||||
|
ref_members: list[tuple[str, int]] = []
|
||||||
|
node_pos: dict[int, tuple[str, float, float]] = {}
|
||||||
|
|
||||||
|
south, west, north, east = BBOX
|
||||||
|
lat = south
|
||||||
|
tiles: list[tuple[float, float, float, float]] = []
|
||||||
|
while lat < north:
|
||||||
|
lon = west
|
||||||
|
while lon < east:
|
||||||
|
tiles.append((lat, lon, min(lat + LAT_STEP, north), min(lon + LON_STEP, east)))
|
||||||
|
lon += LON_STEP
|
||||||
|
lat += LAT_STEP
|
||||||
|
|
||||||
|
for i, (s, w, n, e) in enumerate(tiles, 1):
|
||||||
|
print(f" route tile {i}/{len(tiles)} ({s:.1f},{w:.1f})", file=sys.stderr)
|
||||||
|
query = (
|
||||||
|
f"[out:json][timeout:400];"
|
||||||
|
f'relation["type"="route"]["route"~"^({ROUTE_MODES})$"]["ref"]'
|
||||||
|
f"({s},{w},{n},{e})->.r;"
|
||||||
|
f".r out body;"
|
||||||
|
f"node(r.r);out body;"
|
||||||
|
)
|
||||||
|
payload = overpass(query)
|
||||||
|
for el in payload.get("elements", []):
|
||||||
|
if el["type"] == "node":
|
||||||
|
tags = el.get("tags", {})
|
||||||
|
node_pos[el["id"]] = (tags.get("name", ""), el["lat"], el["lon"])
|
||||||
|
elif el["type"] == "relation":
|
||||||
|
if el["id"] in seen_rel:
|
||||||
|
continue
|
||||||
|
seen_rel.add(el["id"])
|
||||||
|
ref = (el.get("tags", {}).get("ref") or "").strip()
|
||||||
|
if not ref:
|
||||||
|
continue
|
||||||
|
for m in el.get("members", []):
|
||||||
|
if m["type"] == "node" and m["role"] in STOP_ROLES:
|
||||||
|
ref_members.append((ref, m["ref"]))
|
||||||
|
del payload
|
||||||
|
|
||||||
|
print(f" {len(seen_rel)} routes, {len(ref_members)} stop memberships", file=sys.stderr)
|
||||||
|
return ref_members, node_pos
|
||||||
|
|
||||||
|
|
||||||
|
def assign_lines(rows: list[dict], ref_members, node_pos) -> tuple[int, int]:
|
||||||
|
by_name: dict[str, list[int]] = {}
|
||||||
|
grid: dict[tuple[int, int], list[int]] = {}
|
||||||
|
for idx, row in enumerate(rows):
|
||||||
|
by_name.setdefault(normalize_name(row["name"]), []).append(idx)
|
||||||
|
cell = (round(row["latitude"] / 0.01), round(row["longitude"] / 0.01))
|
||||||
|
grid.setdefault(cell, []).append(idx)
|
||||||
|
|
||||||
|
def nearest(lat: float, lon: float, max_m: float) -> int | None:
|
||||||
|
best, best_d = None, max_m
|
||||||
|
base = (round(lat / 0.01), round(lon / 0.01))
|
||||||
|
for dr in (-1, 0, 1):
|
||||||
|
for dc in (-1, 0, 1):
|
||||||
|
for idx in grid.get((base[0] + dr, base[1] + dc), ()):
|
||||||
|
d = haversine_m(lat, lon, rows[idx]["latitude"], rows[idx]["longitude"])
|
||||||
|
if d < best_d:
|
||||||
|
best, best_d = idx, d
|
||||||
|
return best
|
||||||
|
|
||||||
|
matched = unmatched = 0
|
||||||
|
for ref, node_id in ref_members:
|
||||||
|
pos = node_pos.get(node_id)
|
||||||
|
if pos is None:
|
||||||
|
unmatched += 1
|
||||||
|
continue
|
||||||
|
name, lat, lon = pos
|
||||||
|
idx = None
|
||||||
|
key = normalize_name(name) if name else ""
|
||||||
|
if key and key in by_name:
|
||||||
|
idx = min(
|
||||||
|
by_name[key],
|
||||||
|
key=lambda i: haversine_m(lat, lon, rows[i]["latitude"], rows[i]["longitude"]),
|
||||||
|
)
|
||||||
|
if haversine_m(lat, lon, rows[idx]["latitude"], rows[idx]["longitude"]) > 600:
|
||||||
|
idx = None
|
||||||
|
if idx is None:
|
||||||
|
idx = nearest(lat, lon, 120)
|
||||||
|
if idx is None:
|
||||||
|
unmatched += 1
|
||||||
|
else:
|
||||||
|
matched += 1
|
||||||
|
rows[idx]["lines"].add(ref)
|
||||||
|
return matched, unmatched
|
||||||
|
|
||||||
|
|
||||||
|
def line_sort_key(ref: str):
|
||||||
|
head, i = "", 0
|
||||||
|
while i < len(ref) and not ref[i].isdigit():
|
||||||
|
head, i = head + ref[i], i + 1
|
||||||
|
digits = ""
|
||||||
|
while i < len(ref) and ref[i].isdigit():
|
||||||
|
digits, i = digits + ref[i], i + 1
|
||||||
|
return (head.lower(), int(digits) if digits else -1, ref[i:].lower())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
payload = fetch_raw()
|
print("Fetching stops ...", file=sys.stderr)
|
||||||
rows = build_rows(payload)
|
rows = build_stop_rows(overpass(STOPS_QUERY))
|
||||||
if len(rows) < 10_000:
|
if len(rows) < 10_000:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"Only {len(rows)} stops parsed - refusing to overwrite the snapshot. "
|
f"Only {len(rows)} stops parsed - refusing to overwrite the snapshot."
|
||||||
"Overpass probably returned a partial result; try again later."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
print("Fetching routes ...", file=sys.stderr)
|
||||||
|
ref_members, node_pos = fetch_route_members()
|
||||||
|
matched, unmatched = assign_lines(rows, ref_members, node_pos)
|
||||||
|
|
||||||
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with gzip.open(OUT_PATH, "wt", newline="", encoding="utf-8") as fh:
|
with gzip.open(OUT_PATH, "wt", newline="", encoding="utf-8") as fh:
|
||||||
writer = csv.DictWriter(fh, fieldnames=CSV_FIELDS)
|
writer = csv.DictWriter(fh, fieldnames=CSV_FIELDS)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
writer.writerows(rows)
|
for row in rows:
|
||||||
|
row["lines"] = ";".join(sorted(row["lines"], key=line_sort_key))
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
by_type: dict[str, int] = {}
|
by_type: dict[str, int] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
by_type[row["stop_type"]] = by_type.get(row["stop_type"], 0) + 1
|
by_type[row["stop_type"]] = by_type.get(row["stop_type"], 0) + 1
|
||||||
|
with_lines = sum(1 for r in rows if r["lines"])
|
||||||
print(f"Wrote {len(rows)} stops to {OUT_PATH}", file=sys.stderr)
|
print(f"Wrote {len(rows)} stops to {OUT_PATH}", file=sys.stderr)
|
||||||
print(f" by type: {by_type}", file=sys.stderr)
|
print(f" by type: {by_type}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
f" lines: {with_lines} stops have >=1 line "
|
||||||
|
f"(memberships matched {matched}, unmatched {unmatched})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+169
-83
@@ -1,17 +1,16 @@
|
|||||||
/* Autocomplete for the origin/destination fields, backed by /api/stops.
|
/* Autocomplete helpers for the "Neue Fahrt" form.
|
||||||
Progressive enhancement: without JS the inputs stay plain free-text fields.
|
- origin/destination: stop lookup from /api/stops, fills a hidden *_stop_id
|
||||||
A picked stop fills the sibling hidden <input> with its id; editing the text
|
when the text matches a known Austrian stop (free text still allowed).
|
||||||
afterwards clears it again, so unmatched entries save as free text. */
|
- line: suggestions from /api/lines, scoped to the chosen origin/destination
|
||||||
|
stops. Always free text; when exactly one line is possible and the field is
|
||||||
|
untouched it is pre-filled.
|
||||||
|
Progressive enhancement: without JS every field stays a plain input. */
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var TYPE_LABEL = {
|
var TYPE_LABEL = {
|
||||||
bus: "Bus",
|
bus: "Bus", tram: "Straßenbahn", subway: "U-Bahn", train: "Zug",
|
||||||
tram: "Straßenbahn",
|
bus_station: "Busbahnhof", other: "Haltestelle",
|
||||||
subway: "U-Bahn",
|
|
||||||
train: "Zug",
|
|
||||||
bus_station: "Busbahnhof",
|
|
||||||
other: "Haltestelle",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function debounce(fn, ms) {
|
function debounce(fn, ms) {
|
||||||
@@ -23,12 +22,11 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup(input) {
|
/* Shared dropdown: owns the list element, keyboard nav and open/close.
|
||||||
|
opts: { fetchItems(query) -> Promise<item[]>,
|
||||||
|
fillOption(item, el), onChoose(item), minChars, onInput() } */
|
||||||
|
function attachCombo(input, opts) {
|
||||||
var wrap = input.closest(".combo");
|
var wrap = input.closest(".combo");
|
||||||
var hidden = wrap.querySelector(input.dataset.stopTarget);
|
|
||||||
var hint = wrap.querySelector("[data-stop-hint]");
|
|
||||||
if (!hidden) return;
|
|
||||||
|
|
||||||
var list = document.createElement("div");
|
var list = document.createElement("div");
|
||||||
list.className = "combo-list";
|
list.className = "combo-list";
|
||||||
list.hidden = true;
|
list.hidden = true;
|
||||||
@@ -36,109 +34,197 @@
|
|||||||
|
|
||||||
var items = [];
|
var items = [];
|
||||||
var active = -1;
|
var active = -1;
|
||||||
var lastQuery = "";
|
var lastQuery = null;
|
||||||
|
var minChars = opts.minChars != null ? opts.minChars : 2;
|
||||||
function clearMatch() {
|
|
||||||
hidden.value = "";
|
|
||||||
if (hint) { hint.textContent = ""; hint.classList.remove("matched"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function markMatch(stop) {
|
|
||||||
hidden.value = String(stop.id);
|
|
||||||
if (hint) {
|
|
||||||
hint.textContent =
|
|
||||||
"✓ " + stop.name + (stop.municipality ? ", " + stop.municipality : "");
|
|
||||||
hint.classList.add("matched");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() { list.hidden = true; active = -1; }
|
function close() { list.hidden = true; active = -1; }
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
|
if (opts.emptyText) {
|
||||||
var empty = document.createElement("div");
|
var empty = document.createElement("div");
|
||||||
empty.className = "combo-empty";
|
empty.className = "combo-empty";
|
||||||
empty.textContent =
|
empty.textContent = opts.emptyText;
|
||||||
"Keine Haltestelle gefunden – Eingabe wird als Freitext gespeichert.";
|
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
list.hidden = false;
|
list.hidden = false;
|
||||||
|
} else {
|
||||||
|
close();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
items.forEach(function (stop, i) {
|
items.forEach(function (item, i) {
|
||||||
var opt = document.createElement("div");
|
var el = document.createElement("div");
|
||||||
opt.className = "combo-option" + (i === active ? " active" : "");
|
el.className = "combo-option" + (i === active ? " active" : "");
|
||||||
var name = document.createElement("span");
|
opts.fillOption(item, el);
|
||||||
name.className = "name";
|
el.addEventListener("mousedown", function (e) { e.preventDefault(); choose(i); });
|
||||||
name.textContent = stop.name;
|
list.appendChild(el);
|
||||||
opt.appendChild(name);
|
|
||||||
if (stop.municipality) {
|
|
||||||
var muni = document.createElement("span");
|
|
||||||
muni.className = "muni";
|
|
||||||
muni.textContent = stop.municipality;
|
|
||||||
opt.appendChild(muni);
|
|
||||||
}
|
|
||||||
var kind = document.createElement("span");
|
|
||||||
kind.className = "kind";
|
|
||||||
kind.textContent = TYPE_LABEL[stop.type] || "";
|
|
||||||
opt.appendChild(kind);
|
|
||||||
opt.addEventListener("mousedown", function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
choose(i);
|
|
||||||
});
|
|
||||||
list.appendChild(opt);
|
|
||||||
});
|
});
|
||||||
list.hidden = false;
|
list.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function choose(i) {
|
function choose(i) {
|
||||||
var stop = items[i];
|
if (!items[i]) return;
|
||||||
if (!stop) return;
|
opts.onChoose(items[i]);
|
||||||
input.value = stop.name;
|
|
||||||
markMatch(stop);
|
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
var search = debounce(function () {
|
var run = debounce(function () {
|
||||||
var q = input.value.trim();
|
var q = input.value.trim();
|
||||||
lastQuery = q;
|
lastQuery = q;
|
||||||
if (q.length < 2) { items = []; close(); return; }
|
if (q.length < minChars && minChars > 0) { items = []; close(); return; }
|
||||||
fetch("/api/stops?q=" + encodeURIComponent(q))
|
Promise.resolve(opts.fetchItems(q))
|
||||||
.then(function (r) { return r.ok ? r.json() : []; })
|
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (input.value.trim() !== lastQuery) return;
|
if (input.value.trim() !== lastQuery) return;
|
||||||
items = data;
|
items = data || [];
|
||||||
active = -1;
|
active = -1;
|
||||||
render();
|
render();
|
||||||
var exact = data.filter(function (s) {
|
if (opts.afterFetch) opts.afterFetch(items, lastQuery);
|
||||||
return s.name.toLowerCase() === lastQuery.toLowerCase();
|
|
||||||
})[0];
|
|
||||||
if (exact) { markMatch(exact); } else { clearMatch(); }
|
|
||||||
})
|
})
|
||||||
.catch(function () { items = []; close(); });
|
.catch(function () { items = []; close(); });
|
||||||
}, 180);
|
}, 160);
|
||||||
|
|
||||||
input.setAttribute("autocomplete", "off");
|
input.setAttribute("autocomplete", "off");
|
||||||
input.addEventListener("input", function () { clearMatch(); search(); });
|
input.addEventListener("input", function () {
|
||||||
input.addEventListener("focus", function () { if (items.length) render(); });
|
if (opts.onInput) opts.onInput();
|
||||||
|
run();
|
||||||
|
});
|
||||||
|
input.addEventListener("focus", function () {
|
||||||
|
if (items.length) render(); else run();
|
||||||
|
});
|
||||||
input.addEventListener("blur", function () { setTimeout(close, 120); });
|
input.addEventListener("blur", function () { setTimeout(close, 120); });
|
||||||
input.addEventListener("keydown", function (e) {
|
input.addEventListener("keydown", function (e) {
|
||||||
if (list.hidden) return;
|
if (list.hidden) return;
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") { e.preventDefault(); active = Math.min(active + 1, items.length - 1); render(); }
|
||||||
e.preventDefault();
|
else if (e.key === "ArrowUp") { e.preventDefault(); active = Math.max(active - 1, 0); render(); }
|
||||||
active = Math.min(active + 1, items.length - 1);
|
else if (e.key === "Enter" && active >= 0) { e.preventDefault(); choose(active); }
|
||||||
render();
|
else if (e.key === "Escape") { close(); }
|
||||||
} else if (e.key === "ArrowUp") {
|
});
|
||||||
e.preventDefault();
|
|
||||||
active = Math.max(active - 1, 0);
|
return { refresh: run, closeList: close };
|
||||||
render();
|
|
||||||
} else if (e.key === "Enter") {
|
|
||||||
if (active >= 0) { e.preventDefault(); choose(active); }
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupStopCombo(input, form) {
|
||||||
|
var wrap = input.closest(".combo");
|
||||||
|
var hidden = wrap.querySelector(input.dataset.stopTarget);
|
||||||
|
var hint = wrap.querySelector("[data-stop-hint]");
|
||||||
|
if (!hidden) return;
|
||||||
|
|
||||||
|
function clearMatch() {
|
||||||
|
if (!hidden.value) return;
|
||||||
|
hidden.value = "";
|
||||||
|
if (hint) { hint.textContent = ""; hint.classList.remove("matched"); }
|
||||||
|
form.dispatchEvent(new CustomEvent("stopchange"));
|
||||||
|
}
|
||||||
|
function markMatch(stop) {
|
||||||
|
hidden.value = String(stop.id);
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent = "✓ " + stop.name +
|
||||||
|
(stop.municipality ? ", " + stop.municipality : "");
|
||||||
|
hint.classList.add("matched");
|
||||||
|
}
|
||||||
|
form.dispatchEvent(new CustomEvent("stopchange"));
|
||||||
|
}
|
||||||
|
|
||||||
|
attachCombo(input, {
|
||||||
|
emptyText: "Keine Haltestelle gefunden – Eingabe wird als Freitext gespeichert.",
|
||||||
|
onInput: clearMatch,
|
||||||
|
fetchItems: function (q) {
|
||||||
|
return fetch("/api/stops?q=" + encodeURIComponent(q))
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; });
|
||||||
|
},
|
||||||
|
fillOption: function (stop, el) {
|
||||||
|
var name = document.createElement("span");
|
||||||
|
name.className = "name";
|
||||||
|
name.textContent = stop.name;
|
||||||
|
el.appendChild(name);
|
||||||
|
if (stop.municipality) {
|
||||||
|
var m = document.createElement("span");
|
||||||
|
m.className = "muni";
|
||||||
|
m.textContent = stop.municipality;
|
||||||
|
el.appendChild(m);
|
||||||
|
}
|
||||||
|
var kind = document.createElement("span");
|
||||||
|
kind.className = "kind";
|
||||||
|
kind.textContent = TYPE_LABEL[stop.type] || "";
|
||||||
|
el.appendChild(kind);
|
||||||
|
},
|
||||||
|
onChoose: function (stop) { input.value = stop.name; markMatch(stop); },
|
||||||
|
afterFetch: function (data, q) {
|
||||||
|
var exact = data.filter(function (s) {
|
||||||
|
return s.name.toLowerCase() === q.toLowerCase();
|
||||||
|
})[0];
|
||||||
|
if (exact) markMatch(exact); else clearMatch();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll("[data-stop-input]").forEach(setup);
|
function setupLineCombo(input, form) {
|
||||||
|
var touched = false;
|
||||||
|
var hint = input.closest(".combo").querySelector("[data-line-hint]");
|
||||||
|
input.addEventListener("input", function () { touched = true; });
|
||||||
|
|
||||||
|
function context() {
|
||||||
|
var o = form.querySelector("#origin_stop_id");
|
||||||
|
var d = form.querySelector("#destination_stop_id");
|
||||||
|
return {
|
||||||
|
origin_stop_id: (o && o.value) || "",
|
||||||
|
destination_stop_id: (d && d.value) || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var combo = attachCombo(input, {
|
||||||
|
minChars: 0,
|
||||||
|
onInput: function () { touched = true; },
|
||||||
|
fetchItems: function (q) {
|
||||||
|
var ctx = context();
|
||||||
|
if (!ctx.origin_stop_id && !ctx.destination_stop_id) return [];
|
||||||
|
var qs = "origin_stop_id=" + encodeURIComponent(ctx.origin_stop_id) +
|
||||||
|
"&destination_stop_id=" + encodeURIComponent(ctx.destination_stop_id) +
|
||||||
|
"&q=" + encodeURIComponent(q);
|
||||||
|
return fetch("/api/lines?" + qs).then(function (r) { return r.ok ? r.json() : []; });
|
||||||
|
},
|
||||||
|
fillOption: function (line, el) {
|
||||||
|
var ref = document.createElement("span");
|
||||||
|
ref.className = "name";
|
||||||
|
ref.textContent = line.ref;
|
||||||
|
el.appendChild(ref);
|
||||||
|
if (line.both) {
|
||||||
|
var tag = document.createElement("span");
|
||||||
|
tag.className = "kind";
|
||||||
|
tag.textContent = "beide Halte";
|
||||||
|
el.appendChild(tag);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChoose: function (line) { input.value = line.ref; touched = true; },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-scope suggestions when a stop is picked/cleared; pre-fill an
|
||||||
|
// unambiguous line into an untouched field.
|
||||||
|
form.addEventListener("stopchange", function () {
|
||||||
|
var ctx = context();
|
||||||
|
if (!ctx.origin_stop_id && !ctx.destination_stop_id) return;
|
||||||
|
var qs = "origin_stop_id=" + encodeURIComponent(ctx.origin_stop_id) +
|
||||||
|
"&destination_stop_id=" + encodeURIComponent(ctx.destination_stop_id);
|
||||||
|
fetch("/api/lines?" + qs)
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; })
|
||||||
|
.then(function (lines) {
|
||||||
|
if (!touched && input.value.trim() === "" && lines.length === 1) {
|
||||||
|
input.value = lines[0].ref;
|
||||||
|
}
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent = lines.length
|
||||||
|
? lines.length + " Linie(n) an den gewählten Halten"
|
||||||
|
: "Keine Linien bekannt – Freitext";
|
||||||
|
}
|
||||||
|
if (document.activeElement === input) combo.refresh();
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("form").forEach(function (form) {
|
||||||
|
form.querySelectorAll("[data-stop-input]").forEach(function (i) { setupStopCombo(i, form); });
|
||||||
|
form.querySelectorAll("[data-line-input]").forEach(function (i) { setupLineCombo(i, form); });
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ def import_stops(path: Path | str = SNAPSHOT_PATH) -> dict[str, int]:
|
|||||||
municipality=(row.get("municipality") or "").strip() or None,
|
municipality=(row.get("municipality") or "").strip() or None,
|
||||||
latitude=float(row["latitude"]),
|
latitude=float(row["latitude"]),
|
||||||
longitude=float(row["longitude"]),
|
longitude=float(row["longitude"]),
|
||||||
|
lines=(row.get("lines") or "").strip(),
|
||||||
)
|
)
|
||||||
stop = existing.get(key)
|
stop = existing.get(key)
|
||||||
if stop is None:
|
if stop is None:
|
||||||
|
|||||||
@@ -13,11 +13,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="line">Linie</label>
|
|
||||||
<input type="text" id="line" name="line" placeholder="z.B. S3, Bus 10">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field combo">
|
<div class="field combo">
|
||||||
<label for="origin">Von<span class="required">*</span></label>
|
<label for="origin">Von<span class="required">*</span></label>
|
||||||
<input type="text" id="origin" name="origin" required
|
<input type="text" id="origin" name="origin" required
|
||||||
@@ -36,6 +31,13 @@
|
|||||||
<p class="field-hint" data-stop-hint></p>
|
<p class="field-hint" data-stop-hint></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field combo">
|
||||||
|
<label for="line">Linie</label>
|
||||||
|
<input type="text" id="line" name="line" placeholder="z.B. S3, 693"
|
||||||
|
data-line-input>
|
||||||
|
<p class="field-hint" data-line-hint>Halte oben wählen für Linienvorschläge.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="trip_date">Datum<span class="required">*</span></label>
|
<label for="trip_date">Datum<span class="required">*</span></label>
|
||||||
<input type="date" id="trip_date" name="trip_date" value="{{ heute }}" required>
|
<input type="date" id="trip_date" name="trip_date" value="{{ heute }}" required>
|
||||||
|
|||||||
Reference in New Issue
Block a user