Files
zisco a52e233c31
Deploy PBT / deploy (push) Failing after 2m53s
Extend stops to DACH-region
2026-09-12 07:06:29 +02:00

317 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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",
};
function debounce(fn, ms) {
var t;
return function () {
var args = arguments, self = this;
clearTimeout(t);
t = setTimeout(function () { fn.apply(self, args); }, ms);
};
}
/* 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 list = document.createElement("div");
list.className = "combo-list";
list.hidden = true;
wrap.appendChild(list);
var items = [];
var active = -1;
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 = opts.emptyText;
list.appendChild(empty);
list.hidden = false;
} else {
close();
}
return;
}
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) {
if (!items[i]) return;
opts.onChoose(items[i]);
close();
}
var run = debounce(function () {
var q = input.value.trim();
lastQuery = q;
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 || [];
active = -1;
render();
if (opts.afterFetch) opts.afterFetch(items, lastQuery);
})
.catch(function () { items = []; close(); });
}, 160);
input.setAttribute("autocomplete", "off");
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" && 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() {
delete hidden.dataset.stopType;
delete hidden.dataset.stopLat;
delete hidden.dataset.stopLon;
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);
hidden.dataset.stopType = stop.type;
hidden.dataset.stopLat = stop.lat;
hidden.dataset.stopLon = stop.lon;
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] || "") + " · " + stop.country;
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();
},
});
}
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 () {});
});
}
// Which "Verkehrsmittel" options make sense for a given origin stop type.
// Rail stops aren't narrowed further (OSM doesn't tell S-Bahn from Fernzug).
var MODES_FOR_STOP_TYPE = {
bus: ["Bus"],
bus_station: ["Bus"],
tram: ["Straßenbahn"],
subway: ["U-Bahn"],
train: ["S-Bahn", "Regionalzug", "Fernzug (ÖBB/Railjet/WESTbahn)"],
};
function setupModeFilter(form) {
var select = form.querySelector("#transport_mode");
var originHidden = form.querySelector("#origin_stop_id");
if (!select || !originHidden) return;
var hint = form.querySelector("[data-mode-hint]");
function apply() {
var allowed = MODES_FOR_STOP_TYPE[originHidden.dataset.stopType || ""] || null;
var firstVisible = null;
Array.prototype.forEach.call(select.options, function (opt) {
var ok = !allowed || allowed.indexOf(opt.value) !== -1;
opt.hidden = !ok;
opt.disabled = !ok;
if (ok && !firstVisible) firstVisible = opt;
});
if (allowed && firstVisible && allowed.indexOf(select.value) === -1) {
select.value = firstVisible.value;
}
if (hint) {
hint.textContent = allowed
? "Eingeschränkt auf das Verkehrsmittel der gewählten Haltestelle."
: "";
}
}
form.addEventListener("stopchange", apply);
apply(); // e.g. an already-matched origin when editing a trip
}
// "Fahrplan öffnen": opens Google Maps transit directions for the chosen
// stops (exact coordinates when matched, else the typed name) so the user
// can look up the real departure and copy it into the "Kurs" field.
function setupTimetableLink(form) {
var link = form.querySelector("[data-timetable-link]");
var originText = form.querySelector("#origin");
var destText = form.querySelector("#destination");
var originHidden = form.querySelector("#origin_stop_id");
var destHidden = form.querySelector("#destination_stop_id");
if (!link || !originText || !destText || !originHidden || !destHidden) return;
function locationParam(textInput, hiddenInput) {
var lat = hiddenInput.dataset.stopLat, lon = hiddenInput.dataset.stopLon;
if (lat && lon) return lat + "," + lon;
var name = textInput.value.trim();
return name ? name + ", Österreich" : "";
}
function apply() {
var origin = locationParam(originText, originHidden);
var destination = locationParam(destText, destHidden);
if (!origin || !destination) {
link.removeAttribute("href");
link.setAttribute("aria-disabled", "true");
return;
}
link.href = "https://www.google.com/maps/dir/?api=1" +
"&origin=" + encodeURIComponent(origin) +
"&destination=" + encodeURIComponent(destination) +
"&travelmode=transit";
link.removeAttribute("aria-disabled");
}
form.addEventListener("stopchange", apply);
originText.addEventListener("input", apply);
destText.addEventListener("input", apply);
apply();
}
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); });
setupModeFilter(form);
setupTimetableLink(form);
});
})();