Files
Trainpulse/web/app.js
T

468 lines
23 KiB
JavaScript
Raw 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.
"use strict";
const EMPTY = { type: "FeatureCollection", features: [] };
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
// Drei Grundkarten-Sätze im selben Style, umgeschaltet per Sichtbarkeit:
// hell, dunkel, und Beschriftung für Satellit (weisse Schrift mit dunklem Rand, wie bei Google Maps)
// Orte: ohne weissen Punkt, Name direkt auf der Ortslage statt daneben
function centerPlace(l) {
if (l.id !== "places_locality") return l.layout;
const { "icon-image": _i, "text-variable-anchor": _a, "text-radial-offset": _o, ...rest } = l.layout;
return { ...rest, "text-anchor": "center" };
}
const prefixed = (layers, pre, extra = {}) => layers.filter((l) => !["roads_shields", "pois"].includes(l.id)).map((l) => ({ ...l, id: pre + l.id,
layout: { ...centerPlace(l), visibility: "none" }, paint: { ...l.paint, ...(l.type === "symbol" ? extra : {}) } }));
const LAYERS = {
light: prefixed(basemaps.layers("osm", basemaps.namedFlavor("light"), { lang: "de" }), "l-"),
dark: prefixed(basemaps.layers("osm", basemaps.namedFlavor("dark"), { lang: "de" }), "d-"),
sat: prefixed(basemaps.layers("osm", basemaps.namedFlavor("dark"), { labelsOnly: true, lang: "de" }), "s-",
{ "text-color": "#ffffff", "text-halo-color": "rgba(0,0,0,0.85)", "text-halo-width": 1.6, "text-halo-blur": 0.5 }),
};
// Verspätung in Sekunden -> Farbe (SBB zählt < 3 min als pünktlich)
const delayColor = ["case",
["==", ["get", "delay"], null], "#94a3b8",
["<", ["get", "delay"], 180], "#16a34a",
["<", ["get", "delay"], 360], "#eab308",
["<", ["get", "delay"], 900], "#f97316",
"#dc2626"];
const map = new maplibregl.Map({
container: "map",
center: [6.5, 49.0], // CH, DE, FR, BE, NL
zoom: 5.2,
hash: true,
style: {
version: 8,
glyphs: "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf",
sprite: "https://protomaps.github.io/basemaps-assets/sprites/v4/light",
sources: {
osm: {
type: "vector", url: `pmtiles://${location.origin}/tiles/region.pmtiles`,
attribution: '<a href="https://protomaps.com">Protomaps</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap-Mitwirkende</a>',
},
s2: {
type: "raster", tileSize: 256, maxzoom: 14,
tiles: ["https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2024_3857/default/g/{z}/{y}/{x}.jpg"],
attribution: '<a href="https://s2maps.eu">Sentinel-2 cloudless</a> by EOX IT Services GmbH (Contains modified Copernicus Sentinel data 2024)',
},
swissimage: {
type: "raster", tileSize: 256, minzoom: 8, maxzoom: 19, bounds: [5.96, 45.82, 10.49, 47.81],
tiles: ["https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg"],
attribution: '© <a href="https://www.swisstopo.admin.ch">swisstopo</a>',
},
route: { type: "geojson", data: EMPTY },
"route-stops": { type: "geojson", data: EMPTY },
trains: {
type: "geojson", data: EMPTY,
attribution: 'Fahrplan &amp; Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>, '
+ '<a href="https://gtfs.de">gtfs.de</a> (<a href="https://www.delfi.de">DELFI e.V.</a>, CC BY-SA 4.0), '
+ '<a href="https://transport.data.gouv.fr/datasets/horaires-sncf">SNCF</a> (ODbL), '
+ '<a href="https://gtfs.ovapi.nl">OVapi</a>, <a href="https://www.belgiantrain.be/en/3rd-party-services/mobility-service-providers/public-data">NMBS/SNCB</a>',
},
},
layers: [
{ id: "s2", type: "raster", source: "s2", layout: { visibility: "none" } },
{ id: "swissimage", type: "raster", source: "swissimage", layout: { visibility: "none" } },
...LAYERS.light, ...LAYERS.dark, ...LAYERS.sat,
{ id: "route", type: "line", source: "route", layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": "#2563eb", "line-width": 4, "line-opacity": 0.85 } },
{ id: "route-stops", type: "circle", source: "route-stops", // Halte: Linie dort etwas dicker
paint: { "circle-color": "#2563eb", "circle-radius": ["interpolate", ["linear"], ["zoom"], 5, 4.5, 12, 7] } },
{ id: "trains", type: "symbol", source: "trains",
layout: {
// Tropfen mit Spitze in Fahrtrichtung; Züge ohne bekannte Richtung als Punkt
"icon-image": ["case", ["has", "bearing"], "tp-arrow", "tp-dot"],
"icon-rotate": ["coalesce", ["get", "bearing"], 0],
"icon-rotation-alignment": "map",
// Trams/U-Bahnen etwas kleiner als Züge
// (zoom darf nur zuoberst in interpolate stehen, darum der Faktor in jedem Stützwert)
"icon-size": ["interpolate", ["linear"], ["zoom"],
...[[6, 0.3], [9, 0.5], [12, 0.9], [14, 1.3]].flatMap(([z, v]) => [z, ["*", v, ["case", ["==", ["get", "mode"], "rail"], 1, 0.75]]])],
"icon-allow-overlap": true, "icon-ignore-placement": true,
},
paint: {
"icon-color": delayColor, "icon-halo-color": "#fff",
// Rand muss mit der Symbolgrösse skalieren, sonst füllt er bei kleinem Zoom das ganze Quadrat
"icon-halo-width": ["interpolate", ["linear"], ["zoom"], 6, 0.8, 9, 1.4, 12, 2.4, 14, 3.4],
} },
{ id: "train-labels", type: "symbol", source: "trains", minzoom: 10,
layout: {
"text-field": ["get", "line"],
"text-font": ["Noto Sans Medium"], "text-size": 11,
"text-offset": [0, 1.1], "text-anchor": "top",
// nicht bei jedem Animationsschritt neu auf Überschneidung prüfen, sonst flackert die Linie
"text-allow-overlap": true, "text-ignore-placement": true,
},
paint: { "text-color": "#111827", "text-halo-color": "#fff", "text-halo-width": 1.5 } },
],
},
});
map.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), "bottom-right");
map.addControl(new maplibregl.ScaleControl(), "bottom-left");
// ---------- Karte / Dunkel / Satellit ----------
const MODES = ["light", "dark", "sat"];
function setMode(mode) {
if (!MODES.includes(mode)) mode = "light";
const show = (layers, on) => layers.forEach((l) => map.setLayoutProperty(l.id, "visibility", on ? "visible" : "none"));
show(LAYERS.light, mode === "light");
show(LAYERS.dark, mode === "dark");
show(LAYERS.sat, mode === "sat");
for (const id of ["s2", "swissimage"]) map.setLayoutProperty(id, "visibility", mode === "sat" ? "visible" : "none");
const light = mode === "light";
map.setPaintProperty("train-labels", "text-color", light ? "#111827" : "#ffffff");
map.setPaintProperty("train-labels", "text-halo-color", light ? "#ffffff" : "rgba(0,0,0,0.85)");
map.setPaintProperty("trains", "icon-halo-color", light ? "#ffffff" : "#111827");
for (const m of MODES) document.getElementById("btn-" + m).setAttribute("aria-pressed", String(m === mode));
try { localStorage.setItem("mode", mode); } catch (e) { /* privater Modus */ }
}
for (const m of MODES) document.getElementById("btn-" + m).onclick = () => setMode(m);
// ---------- Filter: alle / Nahverkehr / Fernverkehr / Tram & U-Bahn ----------
const RAIL = ["==", ["get", "mode"], "rail"];
const FILTERS = { all: null, local: ["all", RAIL, ["!", ["get", "fv"]]], long: ["get", "fv"], city: ["!", RAIL] };
function setFilter(name) {
if (!(name in FILTERS)) name = "all";
for (const id of ["trains", "train-labels"]) map.setFilter(id, FILTERS[name]);
for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).setAttribute("aria-pressed", String(n === name));
updateHint();
try { localStorage.setItem("filter", name); } catch (e) { /* privater Modus */ }
}
for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).onclick = () => setFilter(n);
// ---------- Züge ----------
// Server liefert je Zug die Gleispunkte der nächsten ~10 s, jeweils mit der Zeit, zu der der Zug dort ist;
// der Browser fährt sie ab, die Spitze dreht sich weich in die Richtung des aktuellen Gleisstücks.
let trains = { type: "FeatureCollection", features: [] };
let clockOffset = 0;
const URBAN_Z = 11; // Trams/U-Bahnen erst ab hier (vorher nur Gewimmel, und sonst zu viele Daten)
let ready = false; // erst Züge zeigen, wenn der Server fertige Daten (inkl. Echtzeit) hat
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(r.status);
const now = Number(r.headers.get("X-Now"));
if (now) clockOffset = Date.now() / 1000 - now;
const d = await r.json();
for (const f of d.features) f.t0 = d.ts; // Gleispunkt-Zeiten zählen ab der Berechnung auf dem Server
return d.features;
}
function updateHint() {
const city = document.getElementById("flt-city").getAttribute("aria-pressed") === "true";
document.getElementById("hint").hidden = !(city && map.getZoom() < URBAN_Z);
}
map.on("zoomend", updateHint);
// beim Verschieben/Zoomen Trams nachladen, wenn der Ausschnitt den zuletzt geladenen verlässt
map.on("moveend", () => {
if (!ready) return;
const b = map.getBounds(), want = map.getZoom() >= URBAN_Z;
const inside = urbanBox && b.getWest() >= urbanBox[0] && b.getSouth() >= urbanBox[1]
&& b.getEast() <= urbanBox[2] && b.getNorth() <= urbanBox[3];
if (want ? !inside : urbanBox) refreshTrains();
});
let urbanBox = null; // zuletzt abgefragter Ausschnitt (mit Rand), um beim Verschieben nachzuladen
async function refreshTrains() {
if (document.hidden || !ready) return;
try {
let urban = [];
if (map.getZoom() >= URBAN_Z) {
const b = map.getBounds(), dx = (b.getEast() - b.getWest()) * 0.3, dy = (b.getNorth() - b.getSouth()) * 0.3;
urbanBox = [b.getWest() - dx, b.getSouth() - dy, b.getEast() + dx, b.getNorth() + dy];
urban = await getJSON("api/urban?bbox=" + urbanBox.map((v) => v.toFixed(4)).join(","));
} else urbanBox = null;
const features = [...(await getJSON("api/vehicles")), ...urban];
const old = new Map(trains.features.map((f) => [f.properties.id, f]));
for (const f of features) {
f.pts = f.path && f.path.length ? f.path : [[...f.geometry.coordinates, 0]]; // [lon, lat, s ab ts, m ab Fahrtbeginn]
const o = old.get(f.properties.id);
if (o) {
f.properties.bearing = o.properties.bearing;
f.geometry.coordinates = o.geometry.coordinates; // für "nicht rückwärts", siehe render()
f.shownD = o.shownD;
}
}
trains = { type: "FeatureCollection", features };
render(); // sofort auf die aktuelle Position, nicht erst beim nächsten Bild
} catch (e) { /* nächster Versuch in ein paar Sekunden */ }
}
// kürzester Drehwinkel von a nach b in Grad (-180..180)
const turn = (a, b) => ((((b - a) % 360) + 540) % 360) - 180;
// Richtung von a nach b in Grad (0 = Norden), in Mercator wie die Karte; null wenn (fast) gleicher Punkt
function bearing(a, b) {
const y = (lat) => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360));
const dx = ((b[0] - a[0]) * Math.PI) / 180, dy = y(b[1]) - y(a[1]);
return Math.hypot(dx, dy) < 1e-7 ? null : (Math.atan2(dx, dy) * 180) / Math.PI;
}
// SDF-Icons, damit MapLibre sie nach Verspätung einfärben und mit Rand zeichnen kann
const S = 64, R = 8;
function sdfIcon(mask) {
const img = new Uint8Array(S * S * 4);
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
const self = mask[y * S + x];
let d = R; // Abstand zur nächsten Kante, gesucht im Umkreis R
for (let j = -R; j <= R; j++) for (let i = -R; i <= R; i++) {
const xx = x + i, yy = y + j;
const other = xx >= 0 && yy >= 0 && xx < S && yy < S ? mask[yy * S + xx] : false;
if (other !== self) d = Math.min(d, Math.hypot(i, j) - 0.5);
}
const sd = self ? -d : d;
img[(y * S + x) * 4 + 3] = Math.max(0, Math.min(255, Math.round(255 * (1 - (sd / R + 0.25)))));
}
return { width: S, height: S, data: img };
}
// Pfeil-Form (Navigations-Cursor), um 45° gedreht, damit die Spitze nach oben (Norden) zeigt
const ARROW = new Path2D("M.008 183.043a38.09 38.09 0 0 0 26.772 36.18l196.737 61.813a10.72 10.72 0 0 1 7.44 7.453l61.82 196.731A38.11 38.11 0 0 0 328.953 512h.3a38.11 38.11 0 0 0 36.282-26.2L510.018 50.311A38.233 38.233 0 0 0 461.7 1.975L26.2 146.458A38.115 38.115 0 0 0 .008 183.043");
function shapeMask(draw, fit = 44) {
const c = document.createElement("canvas");
c.width = c.height = S;
const ctx = c.getContext("2d", { willReadFrequently: true });
const raster = (k, dx, dy) => {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, S, S);
ctx.translate(S / 2 + dx, S / 2 + dy);
ctx.scale(k, k);
draw(ctx);
const a = ctx.getImageData(0, 0, S, S).data, m = [];
let x0 = S, x1 = 0, y0 = S, y1 = 0;
for (let i = 0; i < S * S; i++) {
m.push(a[i * 4 + 3] > 127);
if (m[i]) { const x = i % S, y = (i / S) | 0; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
}
return { m, x0, x1, y0, y1 };
};
let b = raster(0.04, 0, 0); // erst messen, dann skalieren und zentrieren
const k = 0.04 * fit / Math.max(b.x1 - b.x0 + 1, b.y1 - b.y0 + 1);
b = raster(k, 0, 0);
return raster(k, S / 2 - (b.x0 + b.x1 + 1) / 2, S / 2 - (b.y0 + b.y1 + 1) / 2).m;
}
map.addImage("tp-arrow", sdfIcon(shapeMask((ctx) => {
ctx.rotate(-Math.PI / 4);
ctx.translate(-256, -256);
ctx.fill(ARROW);
})), { sdf: true, pixelRatio: 2 });
map.addImage("tp-dot", sdfIcon(shapeMask((ctx) => {
ctx.beginPath();
ctx.arc(0, 0, 100, 0, 2 * Math.PI);
ctx.fill();
}, 26)), { sdf: true, pixelRatio: 2 });
let lastRender = 0;
const MAX_HOLD_M = 5000; // ~1 min eines schnellen Zuges
function render() {
const now = Date.now() / 1000 - clockOffset;
const dtFrame = Math.min(0.2, (performance.now() - lastRender) / 1000); // nach Ruckler nicht schlagartig drehen
lastRender = performance.now();
const ease = 1 - Math.exp(-dtFrame * 6); // Richtung folgt in ~0,3 s
for (const f of trains.features) {
const p = f.pts, t = now - f.t0; // s seit Berechnung auf dem Server
if (!p) continue;
// Gleispunkte mit Zeitstempel: Abschnitt suchen, in dem der Zug gerade ist, und darauf fahren
let i = 0;
while (i + 1 < p.length && p[i + 1][2] <= t) i++;
const a = p[i], b = p[Math.min(i + 1, p.length - 1)];
const k = b[2] > a[2] ? Math.max(0, Math.min(1, (t - a[2]) / (b[2] - a[2]))) : 0;
// Steigt die Verspätung (Echtzeit kommt in Minutenschritten), läge der Zug plötzlich hinter der
// gezeigten Stelle: dann warten statt zurückspringen, bis er wieder dort ist (grosse Korrekturen ausgenommen)
const d = a[3] === undefined ? undefined : a[3] + (b[3] - a[3]) * k;
if (d !== undefined && f.shownD !== undefined && d < f.shownD && f.shownD - d < MAX_HOLD_M) continue;
f.shownD = d;
f.geometry.coordinates = [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k];
// Richtung aus dem aktuellen Gleisstück; steht der Zug, die vom Server (Gleis am Halt)
const target = bearing(a, b) ?? f.properties.heading;
if (target === null || target === undefined) continue;
const cur = f.properties.bearing;
f.properties.bearing = cur === undefined ? target : cur + turn(cur, target) * ease;
}
map.getSource("trains").setData(trains);
if (hoverId) { // Hover-Kärtchen fährt mit dem Zug mit
const f = trains.features.find((x) => x.properties.id === hoverId);
if (f) hover.setLngLat(f.geometry.coordinates); else { hover.remove(); hoverId = null; }
}
}
let lastFrame = 0;
function animate(ms) {
if (!document.hidden && ms - lastFrame > 66) { // ~15 Bilder/s reichen und schonen Akku
lastFrame = ms;
render();
}
requestAnimationFrame(animate);
}
async function refreshStatus() {
const el = document.getElementById("status"), loading = document.getElementById("loading");
try {
const s = await (await fetch("api/status")).json();
if (!s.ready && !ready) { // nur beim Start; danach meldet der Server nach jedem Echtzeit-Abruf kurz "nicht bereit"
loading.textContent = s.loaded ? "Züge werden geladen …"
: "Fahrplan wird vorbereitet … beim ersten Start dauert das 1–1,5 Stunden.";
loading.hidden = false;
return;
}
if (!ready) {
ready = true;
await refreshTrains();
}
loading.hidden = true; // auch nach "Keine Verbindung" wieder weg
const rtOk = s.rt_ok;
el.textContent = `${s.vehicles} Züge` + (s.urban ? `, ${s.urban} Trams/U-Bahnen` : "") + " unterwegs" + (rtOk ? "" : s.rt_some
? " · Verspätungen teilweise nicht verfügbar" : " · Verspätungen zurzeit nicht verfügbar");
el.className = rtOk ? "" : "warn";
} catch (e) {
loading.textContent = "Keine Verbindung zum Server …";
loading.hidden = false;
}
}
async function statusLoop() { // bis alles bereit ist alle 2 s nachfragen, danach alle 30 s
await refreshStatus();
setTimeout(statusLoop, ready ? 30000 : 2000);
}
// ---------- Zugdetails ----------
let openId = null;
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
function delayBadge(sec) {
if (sec === null || sec === undefined) return '<span class="badge" style="background:var(--na)">ohne Echtzeit</span>';
const min = Math.round(sec / 60);
const c = sec < 180 ? "--ok" : sec < 360 ? "--d1" : sec < 900 ? "--d2" : "--d3";
return `<span class="badge" style="background:var(${c})">${min <= 0 ? "pünktlich" : "+" + min + "′"}</span>`;
}
function delayText(sec) {
if (sec === null || sec === undefined || Math.round(sec / 60) === 0) return "";
const min = Math.round(sec / 60);
const c = sec < 180 ? "--ok" : sec < 360 ? "--d1" : sec < 900 ? "--d2" : "--d3";
return `<span class="d" style="color:var(${c})">${min > 0 ? "+" : ""}${min}′</span>`;
}
async function showTrip(id) {
openId = id;
const r = await fetch(`api/trip/${id}`);
if (!r.ok || openId !== id) return;
const t = await r.json();
const next = t.stops.find((s) => !s.passed);
const curDelay = t.realtime ? (next ? (next.arr_delay ?? next.dep_delay) : t.stops.at(-1).arr_delay) : null;
document.getElementById("detail").innerHTML = `
<h2>${esc(t.line)} ${esc(t.nr)} ${t.canceled ? '<span class="badge" style="background:var(--d3)">fällt aus</span>' : delayBadge(curDelay)}</h2>
<p class="sub">nach ${esc(t.to)} · ${esc(t.agency)}</p>
<ul class="stops">${t.stops.map((s) => `
<li class="${s.passed ? "passed" : ""} ${s.skipped ? "skipped" : ""}">
<span class="t">${esc(s.arr ?? s.dep)}</span>
<span>${esc(s.name)}${s.platform ? ` <span class="pl">Gl. ${esc(s.platform)}</span>` : ""}</span>
${s.skipped ? '<span class="d" style="color:var(--d3)">fällt aus</span>' : delayText(s.arr !== null ? s.arr_delay : s.dep_delay)}
</li>`).join("")}
</ul>`;
document.getElementById("panel").hidden = false;
map.getSource("route").setData({ type: "Feature", geometry: { type: "LineString", coordinates: t.shape } });
map.getSource("route-stops").setData({ type: "FeatureCollection", features: t.stops.map((x) => (
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [x.lon, x.lat] } })) });
}
// ---------- Suche ----------
// Gleiche Linie fährt oft mehrfach gleichzeitig (S5 in Zürich, Bern, München …): darum je Treffer
// Start → Ziel und nächster Halt, die nächstgelegenen zuerst
const norm = (s) => String(s ?? "").toLowerCase().replace(/\s+/g, "");
let hits = [], sel = -1;
function search() {
const q = norm(document.getElementById("q").value), list = document.getElementById("results");
if (!q) { list.hidden = true; hits = []; return; }
const c = map.getCenter(), kx = Math.cos((c.lat * Math.PI) / 180);
const dist = (f) => Math.hypot((f.geometry.coordinates[0] - c.lng) * kx, f.geometry.coordinates[1] - c.lat);
hits = trains.features.map((f) => {
const p = f.properties, line = norm(p.line);
const rank = line === q || norm(p.nr) === q ? 0 : line.startsWith(q) ? 1
: norm(p.to).includes(q) || norm(p.from).includes(q) ? 2 : -1;
return [rank, dist(f), f];
}).filter((h) => h[0] >= 0).sort((a, b) => a[0] - b[0] || a[1] - b[1]).slice(0, 30).map((h) => h[2]);
sel = -1;
list.innerHTML = hits.length ? hits.map((f, i) => {
const p = f.properties;
return `<li role="option" data-i="${i}"><div class="r1"><b>${esc(p.line)}${p.nr ? " " + esc(p.nr) : ""}</b>${delayBadge(p.delay)}</div>
<div class="r2">${esc(p.from)} → ${esc(p.to)}</div><div class="r2">nächster Halt: ${esc(p.next)}</div></li>`;
}).join("") : '<li class="r2">kein fahrender Zug gefunden</li>';
list.hidden = false;
}
function pick(i) {
const f = hits[i];
if (!f) return;
const now = trains.features.find((x) => x.properties.id === f.properties.id) || f; // aktuelle Position
const pressed = Object.keys(FILTERS).find((n) => document.getElementById("flt-" + n).getAttribute("aria-pressed") === "true");
if (pressed === "local" && f.properties.fv || pressed === "long" && !f.properties.fv) setFilter("all"); // sonst unsichtbar
map.flyTo({ center: now.geometry.coordinates, zoom: Math.max(map.getZoom(), 12) });
showTrip(f.properties.id);
document.getElementById("results").hidden = true;
}
const qEl = document.getElementById("q");
qEl.addEventListener("input", search);
qEl.addEventListener("focus", search);
qEl.addEventListener("keydown", (e) => {
const items = document.querySelectorAll("#results li[data-i]");
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (!items.length) return;
sel = (sel + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
items.forEach((li, i) => li.classList.toggle("sel", i === sel));
items[sel].scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter") pick(sel >= 0 ? sel : 0);
else if (e.key === "Escape") { document.getElementById("results").hidden = true; qEl.blur(); }
});
document.getElementById("results").addEventListener("click", (e) => {
const li = e.target.closest("li[data-i]");
if (li) pick(Number(li.dataset.i));
});
map.on("click", () => { document.getElementById("results").hidden = true; });
function closeTrip() {
openId = null;
document.getElementById("panel").hidden = true;
map.getSource("route").setData(EMPTY);
map.getSource("route-stops").setData(EMPTY);
}
document.getElementById("close").onclick = closeTrip;
map.on("click", "trains", (e) => showTrip(e.features[0].properties.id));
const hover = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 12, className: "hover" });
let hoverId = null;
function hoverHtml(p) {
const delay = p.delay === undefined || p.delay === null ? "" : delayText(Number(p.delay)) || '<span class="d" style="color:var(--ok)">pünktlich</span>';
return `<b>${esc(p.line)} ${esc(p.nr)}</b> ${delay}<br>nach ${esc(p.to)}`;
}
map.on("mousemove", "trains", (e) => {
map.getCanvas().style.cursor = "pointer";
const p = e.features[0].properties;
if (p.id !== hoverId) hover.setHTML(hoverHtml(p));
hoverId = p.id;
hover.setLngLat(e.features[0].geometry.coordinates).addTo(map);
});
map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; hover.remove(); hoverId = null; });
map.on("load", () => {
let mode = "light";
try { mode = localStorage.getItem("mode") || (localStorage.getItem("sat") === "1" ? "sat" : "light"); } catch (e) { /* egal */ }
setMode(mode);
let filter = "all";
try { filter = localStorage.getItem("filter") || "all"; } catch (e) { /* egal */ }
setFilter(filter);
statusLoop();
setInterval(refreshTrains, 5000);
requestAnimationFrame(animate);
setInterval(() => openId && showTrip(openId), 30000);
});