Fetch stops from OpenStreetMap
Deploy PBT / deploy (push) Successful in 16s

This commit is contained in:
2026-09-10 21:44:45 +02:00
parent 0ebf13aacf
commit 2a6e9c690b
14 changed files with 666 additions and 9 deletions
+144
View File
@@ -0,0 +1,144 @@
/* 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. */
(function () {
"use strict";
var TYPE_LABEL = {
bus: "Bus",
tram: "Straßenbahn",
subway: "U-Bahn",
train: "Zug",
bus_station: "Busbahnhof",
other: "Haltestelle",
};
function debounce(fn, ms) {
var t;
return function () {
var args = arguments, self = this;
clearTimeout(t);
t = setTimeout(function () { fn.apply(self, args); }, ms);
};
}
function setup(input) {
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;
wrap.appendChild(list);
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");
}
}
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;
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);
});
list.hidden = false;
}
function choose(i) {
var stop = items[i];
if (!stop) return;
input.value = stop.name;
markMatch(stop);
close();
}
var search = 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() : []; })
.then(function (data) {
if (input.value.trim() !== lastQuery) return;
items = data;
active = -1;
render();
var exact = data.filter(function (s) {
return s.name.toLowerCase() === lastQuery.toLowerCase();
})[0];
if (exact) { markMatch(exact); } else { clearMatch(); }
})
.catch(function () { items = []; close(); });
}, 180);
input.setAttribute("autocomplete", "off");
input.addEventListener("input", function () { clearMatch(); search(); });
input.addEventListener("focus", function () { if (items.length) render(); });
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();
}
});
}
document.querySelectorAll("[data-stop-input]").forEach(setup);
})();