/* 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, 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] || ""; 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); }); })();