"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) // Verspätung (s) -> Farbe, fliessend: bis +2′ grün (wie SBB "pünktlich"), dann über gelb (10′), // orange (20′), rot (30′) zu lila (40′); ab 50′ blinkt das Symbol zusätzlich (BLINK_S) const DELAY_STOPS = [[150, "#16a34a"], [600, "#eab308"], [1200, "#f97316"], [1800, "#dc2626"], [2400, "#9333ea"]]; const NO_RT = "#94a3b8", BLINK_S = 3000; const delayColor = ["case", ["==", ["get", "delay"], null], NO_RT, ["interpolate", ["linear"], ["get", "delay"], ...DELAY_STOPS.flat()]]; function delayRGB(sec) { // dasselbe für die Halteliste (Badges, +x′) const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); if (sec <= DELAY_STOPS[0][0]) return DELAY_STOPS[0][1]; for (let i = 1; i < DELAY_STOPS.length; i++) { const [s0, c0] = DELAY_STOPS[i - 1], [s1, c1] = DELAY_STOPS[i]; if (sec <= s1) { const k = (sec - s0) / (s1 - s0), a = hex(c0), b = hex(c1); return `rgb(${a.map((v, j) => Math.round(v + (b[j] - v) * k)).join(",")})`; } } return DELAY_STOPS.at(-1)[1]; } const map = new maplibregl.Map({ container: "map", center: [9.0, 50.5], // Mitteleuropa; Skandinavien und Spanien per Verschieben zoom: 4.6, 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: 'Protomaps © OpenStreetMap-Mitwirkende', }, 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: 'Sentinel-2 cloudless 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: '© swisstopo', }, route: { type: "geojson", data: EMPTY }, "route-stops": { type: "geojson", data: EMPTY }, trains: { type: "geojson", data: EMPTY, attribution: 'Fahrplan & Echtzeit: opentransportdata.swiss, ' + 'gtfs.de (DELFI e.V., CC BY-SA 4.0), ' + 'SNCF (ODbL), ' + 'OVapi, NMBS/SNCB, ' + 'Renfe, mkuran.pl (PKP PLK), ' + 'Entur (NLOD), Rejseplanen, ' + 'ÖBB, Wiener Linien (CC BY 4.0), ' + 'Trafiklab', }, }, 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); // ---------- Ebenen: Fernverkehr / Regionalzug / S-Bahn / Tram / U-Bahn, einzeln oder alle ---------- const LAYER_FILTERS = { long: ["get", "fv"], regio: ["all", ["==", ["get", "mode"], "rail"], ["!", ["get", "fv"]], ["!", ["get", "sb"]]], sbahn: ["get", "sb"], tram: ["==", ["get", "mode"], "tram"], subway: ["==", ["get", "mode"], "subway"], }; const ALL_LAYERS = Object.keys(LAYER_FILTERS); const layerOf = (p) => (p.mode === "rail" ? (p.fv ? "long" : p.sb ? "sbahn" : "regio") : p.mode); let shown = new Set(ALL_LAYERS); function setLayers(list) { shown = new Set(list.filter((k) => k in LAYER_FILTERS)); if (!shown.size) shown = new Set(ALL_LAYERS); // nichts gewählt = alles const all = shown.size === ALL_LAYERS.length; const filter = all ? null : ["any", ...[...shown].map((k) => LAYER_FILTERS[k])]; for (const id of ["trains", "train-labels"]) map.setFilter(id, filter); document.getElementById("flt-all").setAttribute("aria-pressed", String(all)); for (const k of ALL_LAYERS) document.getElementById("flt-" + k).setAttribute("aria-pressed", String(!all && shown.has(k))); updateHint(); try { localStorage.setItem("layers", JSON.stringify([...shown])); } catch (e) { /* privater Modus */ } } document.getElementById("flt-all").onclick = () => setLayers(ALL_LAYERS); for (const k of ALL_LAYERS) document.getElementById("flt-" + k).onclick = () => { // aus "Alle" heraus: nur diese Ebene; sonst Ebene dazu oder weg if (shown.size === ALL_LAYERS.length) return setLayers([k]); const next = new Set(shown); next.has(k) ? next.delete(k) : next.add(k); setLayers([...next]); }; // ---------- 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() { // nur Trams/U-Bahnen gewählt, aber zu weit weg: sonst wäre die Karte einfach leer const onlyCity = !["long", "regio", "sbahn"].some((k) => shown.has(k)); document.getElementById("hint").hidden = !(onlyCity && 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); // ab 50′ Verspätung sanft ein- und ausblenden (ein Zyklus ~1,6 s) const pulse = 0.55 + 0.45 * Math.cos((performance.now() / 1600) * 2 * Math.PI); map.setPaintProperty("trains", "icon-opacity", ["case", [">=", ["coalesce", ["get", "delay"], 0], BLINK_S], pulse, 1]); 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(); // beim Start oder nach Server-Neustart (noch keine Züge); sonst meldet der Server nach jedem // Echtzeit-Abruf nur kurz "nicht bereit" if (!s.ready && (!ready || !s.vehicles)) { 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 && document.getElementById("loading").hidden ? 30000 : 2000); } // ---------- Zugdetails ---------- let openId = null; const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); function delayBadge(sec) { if (sec === null || sec === undefined) return 'ohne Echtzeit'; const min = Math.round(sec / 60); return `${min <= 0 ? "pünktlich" : "+" + min + "′"}`; } function delayText(sec) { if (sec === null || sec === undefined || Math.round(sec / 60) === 0) return ""; const min = Math.round(sec / 60); return `${min > 0 ? "+" : ""}${min}′`; } 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 = `

${esc(t.line)} ${esc(t.nr)} ${t.canceled ? 'fällt aus' : delayBadge(curDelay)}

nach ${esc(t.to)} · ${esc(t.agency)}

`; 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: { name: x.name }, 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 `
  • ${esc(p.line)}${p.nr ? " " + esc(p.nr) : ""}${delayBadge(p.delay)}
    ${esc(p.from)} → ${esc(p.to)}
    nächster Halt: ${esc(p.next)}
  • `; }).join("") : '
  • kein fahrender Zug gefunden
  • '; 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 if (!shown.has(layerOf(f.properties))) setLayers([...shown, layerOf(f.properties)]); // 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)) || 'pünktlich'; return `${esc(p.line)} ${esc(p.nr)} ${delay}
    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; }); // Halte der geöffneten Fahrt: Name beim Darüberfahren const stopHover = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 8, className: "hover" }); map.on("mousemove", "route-stops", (e) => { if (hoverId) return; // Zug-Kärtchen hat Vorrang stopHover.setText(e.features[0].properties.name).setLngLat(e.features[0].geometry.coordinates).addTo(map); }); map.on("mouseleave", "route-stops", () => stopHover.remove()); map.on("load", () => { let mode = "light"; try { mode = localStorage.getItem("mode") || (localStorage.getItem("sat") === "1" ? "sat" : "light"); } catch (e) { /* egal */ } setMode(mode); let layers = ALL_LAYERS; try { layers = JSON.parse(localStorage.getItem("layers")) || ALL_LAYERS; } catch (e) { /* egal */ } setLayers(Array.isArray(layers) ? layers : ALL_LAYERS); statusLoop(); setInterval(refreshTrains, 5000); requestAnimationFrame(animate); setInterval(() => openId && showTrip(openId), 30000); });