317 lines
12 KiB
JavaScript
317 lines
12 KiB
JavaScript
/* 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);
|
||
});
|
||
})();
|