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
(`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 12 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
+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):
raw_value = (raw_value or "").strip()
try:
Binary file not shown.
+6
View File
@@ -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"),
+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.
# 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]
+183 -57
View File
@@ -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,10 +174,8 @@ def build_rows(payload: dict) -> list[dict]:
cluster,
key=lambda n: (TYPE_RANK[n["stop_type"]], n["municipality"] != "", -n["osm_id"]),
)
rows.append(
{
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"],
@@ -175,31 +183,149 @@ def build_rows(payload: dict) -> list[dict]:
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."
)
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__":
+169 -83
View File
@@ -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 <input> 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<item[]>,
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) {
if (opts.emptyText) {
var empty = document.createElement("div");
empty.className = "combo-empty";
empty.textContent =
"Keine Haltestelle gefunden Eingabe wird als Freitext gespeichert.";
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); });
});
})();
+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,
latitude=float(row["latitude"]),
longitude=float(row["longitude"]),
lines=(row.get("lines") or "").strip(),
)
stop = existing.get(key)
if stop is None:
+7 -5
View File
@@ -13,11 +13,6 @@
</select>
</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">
<label for="origin">Von<span class="required">*</span></label>
<input type="text" id="origin" name="origin" required
@@ -36,6 +31,13 @@
<p class="field-hint" data-stop-hint></p>
</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">
<label for="trip_date">Datum<span class="required">*</span></label>
<input type="date" id="trip_date" name="trip_date" value="{{ heute }}" required>