diff --git a/README.md b/README.md index 14fc15a..793320b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,11 @@ Die Felder „Von" und „Nach" haben eine Autovervollständigung aus allen (© OpenStreetMap-Mitwirkende, ODbL) und liegen als Snapshot im Repo (`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: ```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 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 python scripts/fetch_stops.py # überschreibt data/stops_at.csv.gz diff --git a/app.py b/app.py index 7a47b27..23c58bd 100644 --- a/app.py +++ b/app.py @@ -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): raw_value = (raw_value or "").strip() try: diff --git a/data/stops_at.csv.gz b/data/stops_at.csv.gz index 52578f0..1fc2bb2 100644 Binary files a/data/stops_at.csv.gz and b/data/stops_at.csv.gz differ diff --git a/models.py b/models.py index cffabf6..caebe87 100644 --- a/models.py +++ b/models.py @@ -55,6 +55,12 @@ class Stop(db.Model): municipality = db.Column(db.String(120)) latitude = 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__ = ( db.UniqueConstraint("osm_type", "osm_id", name="uq_stops_osm"), diff --git a/pbt.service b/pbt.service index af3c70a..830f144 100644 --- a/pbt.service +++ b/pbt.service @@ -8,7 +8,7 @@ WorkingDirectory=/opt/pbt # 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. 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 [Install] diff --git a/scripts/fetch_stops.py b/scripts/fetch_stops.py index ce3b921..9e59a0d 100644 --- a/scripts/fetch_stops.py +++ b/scripts/fetch_stops.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 -"""Fetch all Austrian public-transport stops from OpenStreetMap (Overpass API) -and write a deduplicated snapshot to data/stops_at.csv.gz. +"""Fetch all Austrian public-transport stops (and the lines serving them) from +OpenStreetMap via the Overpass API and write a deduplicated snapshot to +data/stops_at.csv.gz. Data © OpenStreetMap contributors, licensed under the ODbL. 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 """ @@ -14,7 +16,9 @@ from __future__ import annotations import csv import gzip import json +import math import sys +import time import unicodedata import urllib.parse import urllib.request @@ -29,7 +33,7 @@ OVERPASS_ENDPOINTS = [ ] # Named nodes in Austria that represent a boardable stop ("Haltestelle"). -OVERPASS_QUERY = """ +STOPS_QUERY = """ [out:json][timeout:600]; area["ISO3166-1"="AT"][admin_level=2]->.at; ( @@ -43,16 +47,30 @@ area["ISO3166-1"="AT"][admin_level=2]->.at; 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 = [ - "osm_type", - "osm_id", - "name", - "stop_type", - "municipality", - "latitude", - "longitude", + "osm_type", "osm_id", "name", "stop_type", + "municipality", "latitude", "longitude", "lines", ] +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: """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)) +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: if tags.get("station") == "subway" or tags.get("subway") == "yes": return "subway" @@ -75,8 +100,6 @@ def classify(tags: dict) -> str: return "bus_station" if tags.get("highway") == "bus_stop" or tags.get("bus") == "yes": return "bus" - if tags.get("public_transport") == "station": - return "other" return "other" @@ -88,56 +111,38 @@ def municipality_of(tags: dict) -> str: return "" -def fetch_raw() -> dict: - body = urllib.parse.urlencode({"data": OVERPASS_QUERY}).encode() +def overpass(query: str, *, tries: int = 4) -> dict: + body = urllib.parse.urlencode({"data": query}).encode() last_error: Exception | None = None - for endpoint in OVERPASS_ENDPOINTS: + for attempt in range(tries): + endpoint = OVERPASS_ENDPOINTS[attempt % len(OVERPASS_ENDPOINTS)] try: - print(f"Querying {endpoint} ...", file=sys.stderr) req = urllib.request.Request( endpoint, data=body, headers={"User-Agent": "pbt-stop-import/1.0"} ) with urllib.request.urlopen(req, timeout=600) as 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 - print(f" failed: {exc}", file=sys.stderr) - raise SystemExit(f"All Overpass endpoints failed; last error: {last_error}") + print(f" {endpoint} failed ({exc}); retrying", file=sys.stderr) + 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} - -# 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). -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. +# --------------------------------------------------------------------------- # +# Stops +# --------------------------------------------------------------------------- # +def build_stop_rows(payload: dict) -> list[dict]: by_name: dict[str, list[dict]] = {} for element in payload.get("elements", []): if element.get("type") != "node": continue tags = element.get("tags", {}) name = (tags.get("name") or "").strip() - lat = element.get("lat") - lon = element.get("lon") + lat, lon = element.get("lat"), element.get("lon") if not name or lat is None or lon is None: continue node = { - "osm_type": "node", "osm_id": int(element["id"]), "name": name, "stop_type": classify(tags), @@ -148,12 +153,17 @@ def build_rows(payload: dict) -> list[dict]: by_name.setdefault(normalize_name(name), []).append(node) rows: list[dict] = [] - for nodes in by_name.values(): - # Greedy single-link clustering within the name group. + for group in by_name.values(): 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: - 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) break else: @@ -164,42 +174,158 @@ def build_rows(payload: dict) -> list[dict]: cluster, key=lambda n: (TYPE_RANK[n["stop_type"]], n["municipality"] != "", -n["osm_id"]), ) - rows.append( - { - "osm_type": "node", - # lowest id in the cluster -> stable identity across refreshes - "osm_id": min(n["osm_id"] for n in cluster), - "name": lead["name"], - "stop_type": lead["stop_type"], - "municipality": lead["municipality"] - or next((n["municipality"] for n in cluster if n["municipality"]), ""), - "latitude": round(sum(n["latitude"] for n in cluster) / len(cluster), 6), - "longitude": round(sum(n["longitude"] for n in cluster) / len(cluster), 6), - } - ) + rows.append({ + "osm_type": "node", + "osm_id": min(n["osm_id"] for n in cluster), + "name": lead["name"], + "stop_type": lead["stop_type"], + "municipality": lead["municipality"] + or next((n["municipality"] for n in cluster if n["municipality"]), ""), + "latitude": round(sum(n["latitude"] 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"])) 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: - payload = fetch_raw() - rows = build_rows(payload) + print("Fetching stops ...", file=sys.stderr) + rows = build_stop_rows(overpass(STOPS_QUERY)) if len(rows) < 10_000: raise SystemExit( - f"Only {len(rows)} stops parsed - refusing to overwrite the snapshot. " - "Overpass probably returned a partial result; try again later." + f"Only {len(rows)} stops parsed - refusing to overwrite the snapshot." ) + + 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) with gzip.open(OUT_PATH, "wt", newline="", encoding="utf-8") as fh: writer = csv.DictWriter(fh, fieldnames=CSV_FIELDS) 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] = {} for row in rows: 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" 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__": diff --git a/static/stops.js b/static/stops.js index 8f9c1df..2ff57c6 100644 --- a/static/stops.js +++ b/static/stops.js @@ -1,17 +1,16 @@ -/* Autocomplete for the origin/destination fields, backed by /api/stops. - Progressive enhancement: without JS the inputs stay plain free-text fields. - A picked stop fills the sibling hidden with its id; editing the text - afterwards clears it again, so unmatched entries save as free text. */ +/* Autocomplete helpers for the "Neue Fahrt" form. + - origin/destination: stop lookup from /api/stops, fills a hidden *_stop_id + when the text matches a known Austrian stop (free text still allowed). + - 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 () { "use strict"; var TYPE_LABEL = { - bus: "Bus", - tram: "Straßenbahn", - subway: "U-Bahn", - train: "Zug", - bus_station: "Busbahnhof", - other: "Haltestelle", + bus: "Bus", tram: "Straßenbahn", subway: "U-Bahn", train: "Zug", + bus_station: "Busbahnhof", other: "Haltestelle", }; 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, + fillOption(item, el), onChoose(item), minChars, onInput() } */ + function attachCombo(input, opts) { 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"); list.className = "combo-list"; list.hidden = true; @@ -36,109 +34,197 @@ var items = []; var active = -1; - var lastQuery = ""; - - 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"); - } - } + var lastQuery = null; + var minChars = opts.minChars != null ? opts.minChars : 2; function close() { list.hidden = true; active = -1; } function render() { list.innerHTML = ""; if (!items.length) { - var empty = document.createElement("div"); - empty.className = "combo-empty"; - empty.textContent = - "Keine Haltestelle gefunden – Eingabe wird als Freitext gespeichert."; - list.appendChild(empty); - list.hidden = false; + if (opts.emptyText) { + var empty = document.createElement("div"); + empty.className = "combo-empty"; + empty.textContent = opts.emptyText; + list.appendChild(empty); + list.hidden = false; + } else { + close(); + } return; } - items.forEach(function (stop, i) { - var opt = document.createElement("div"); - opt.className = "combo-option" + (i === active ? " active" : ""); - var name = document.createElement("span"); - name.className = "name"; - name.textContent = stop.name; - 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); + items.forEach(function (item, i) { + var el = document.createElement("div"); + el.className = "combo-option" + (i === active ? " active" : ""); + opts.fillOption(item, el); + el.addEventListener("mousedown", function (e) { e.preventDefault(); choose(i); }); + list.appendChild(el); }); list.hidden = false; } function choose(i) { - var stop = items[i]; - if (!stop) return; - input.value = stop.name; - markMatch(stop); + if (!items[i]) return; + opts.onChoose(items[i]); close(); } - var search = debounce(function () { + var run = debounce(function () { var q = input.value.trim(); lastQuery = q; - if (q.length < 2) { items = []; close(); return; } - fetch("/api/stops?q=" + encodeURIComponent(q)) - .then(function (r) { return r.ok ? r.json() : []; }) + if (q.length < minChars && minChars > 0) { items = []; close(); return; } + Promise.resolve(opts.fetchItems(q)) .then(function (data) { if (input.value.trim() !== lastQuery) return; - items = data; + items = data || []; active = -1; render(); - var exact = data.filter(function (s) { - return s.name.toLowerCase() === lastQuery.toLowerCase(); - })[0]; - if (exact) { markMatch(exact); } else { clearMatch(); } + if (opts.afterFetch) opts.afterFetch(items, lastQuery); }) .catch(function () { items = []; close(); }); - }, 180); + }, 160); input.setAttribute("autocomplete", "off"); - input.addEventListener("input", function () { clearMatch(); search(); }); - input.addEventListener("focus", function () { if (items.length) render(); }); + input.addEventListener("input", function () { + 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("keydown", function (e) { if (list.hidden) return; - if (e.key === "ArrowDown") { - e.preventDefault(); - active = Math.min(active + 1, items.length - 1); - render(); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - active = Math.max(active - 1, 0); - render(); - } else if (e.key === "Enter") { - if (active >= 0) { e.preventDefault(); choose(active); } - } else if (e.key === "Escape") { - close(); + if (e.key === "ArrowDown") { e.preventDefault(); active = Math.min(active + 1, items.length - 1); render(); } + else if (e.key === "ArrowUp") { e.preventDefault(); active = Math.max(active - 1, 0); render(); } + else if (e.key === "Enter" && active >= 0) { e.preventDefault(); choose(active); } + else if (e.key === "Escape") { close(); } + }); + + return { refresh: run, closeList: 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); }); + }); })(); diff --git a/stops_import.py b/stops_import.py index d55ea8a..36036ea 100644 --- a/stops_import.py +++ b/stops_import.py @@ -72,6 +72,7 @@ def import_stops(path: Path | str = SNAPSHOT_PATH) -> dict[str, int]: municipality=(row.get("municipality") or "").strip() or None, latitude=float(row["latitude"]), longitude=float(row["longitude"]), + lines=(row.get("lines") or "").strip(), ) stop = existing.get(key) if stop is None: diff --git a/templates/add_entry.html b/templates/add_entry.html index 5408843..25b5c8e 100644 --- a/templates/add_entry.html +++ b/templates/add_entry.html @@ -13,11 +13,6 @@ - - Linie - - - Von* + + Linie + + Halte oben wählen für Linienvorschläge. + + Datum*
Halte oben wählen für Linienvorschläge.