Add valid lines to stops
Deploy PBT / deploy (push) Failing after 25s

This commit is contained in:
2026-09-10 22:45:12 +02:00
parent 158311a329
commit 3535ebcba0
9 changed files with 424 additions and 160 deletions
+7 -1
View File
@@ -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 12 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
+37
View File
@@ -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.
+6
View File
@@ -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
View File
@@ -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]
+192 -66
View File
@@ -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,42 +174,158 @@ 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", "osm_id": min(n["osm_id"] for n in cluster),
# lowest id in the cluster -> stable identity across refreshes "name": lead["name"],
"osm_id": min(n["osm_id"] for n in cluster), "stop_type": lead["stop_type"],
"name": lead["name"], "municipality": lead["municipality"]
"stop_type": lead["stop_type"], or next((n["municipality"] for n in cluster if n["municipality"]), ""),
"municipality": lead["municipality"] "latitude": round(sum(n["latitude"] for n in cluster) / len(cluster), 6),
or next((n["municipality"] for n in cluster if n["municipality"]), ""), "longitude": round(sum(n["longitude"] for n in cluster) / len(cluster), 6),
"latitude": round(sum(n["latitude"] for n in cluster) / len(cluster), 6), "lines": set(),
"longitude": round(sum(n["longitude"] for n in cluster) / len(cluster), 6), })
}
)
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__":
+173 -87
View File
@@ -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) {
var empty = document.createElement("div"); if (opts.emptyText) {
empty.className = "combo-empty"; var empty = document.createElement("div");
empty.textContent = empty.className = "combo-empty";
"Keine Haltestelle gefunden Eingabe wird als Freitext gespeichert."; empty.textContent = opts.emptyText;
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); } function setupStopCombo(input, form) {
} else if (e.key === "Escape") { var wrap = input.closest(".combo");
close(); 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); });
});
})(); })();
+1
View File
@@ -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:
+7 -5
View File
@@ -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>