Files
Trainpulse/web/app.js
T
GuidoandClaude Opus 5.5 d92dac4a00 TrainPulse: Live-Zugkarte Schweiz (Grundgerüst)
Fahrplan-Aufbereitung (Bahnfilter + pfaedle), GTFS-RT-Abruf,
Positionsberechnung entlang der Strecke, Weboberfläche mit
Karte/Satellit, Proxmox-LXC-Installation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 14:59:08 +02:00

181 lines
8.0 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);
const flavor = basemaps.namedFlavor("light");
const baseLayers = basemaps.layers("osm", flavor, { lang: "de" });
const labelIds = new Set(basemaps.layers("osm", flavor, { labelsOnly: true, lang: "de" }).map((l) => l.id));
// 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: [8.23, 46.82],
zoom: 7.4,
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 },
trains: {
type: "geojson", data: EMPTY,
attribution: 'Fahrplan &amp; Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>',
},
},
layers: [
{ id: "s2", type: "raster", source: "s2", layout: { visibility: "none" } },
{ id: "swissimage", type: "raster", source: "swissimage", layout: { visibility: "none" } },
...baseLayers,
{ 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: "trains", type: "circle", source: "trains",
paint: {
"circle-color": delayColor,
"circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 3, 10, 5.5, 14, 9],
"circle-stroke-color": "#fff",
"circle-stroke-width": ["interpolate", ["linear"], ["zoom"], 6, 0.6, 12, 1.8],
} },
{ id: "train-labels", type: "symbol", source: "trains", minzoom: 10,
layout: {
"text-field": ["concat", ["get", "line"], " ", ["get", "nr"]],
"text-font": ["Noto Sans Medium"], "text-size": 11,
"text-offset": [0, 1.1], "text-anchor": "top", "text-optional": 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 / Satellit ----------
function setBackground(sat) {
for (const id of ["s2", "swissimage"]) map.setLayoutProperty(id, "visibility", sat ? "visible" : "none");
for (const l of baseLayers) {
if (!labelIds.has(l.id)) map.setLayoutProperty(l.id, "visibility", sat ? "none" : "visible");
}
document.getElementById("btn-map").setAttribute("aria-pressed", String(!sat));
document.getElementById("btn-sat").setAttribute("aria-pressed", String(sat));
try { localStorage.setItem("sat", sat ? "1" : ""); } catch (e) { /* privater Modus */ }
}
document.getElementById("btn-map").onclick = () => setBackground(false);
document.getElementById("btn-sat").onclick = () => setBackground(true);
// ---------- Züge ----------
async function refreshTrains() {
if (document.hidden) return;
try {
const r = await fetch("api/vehicles");
if (r.ok) map.getSource("trains").setData(await r.json());
} catch (e) { /* nächster Versuch in ein paar Sekunden */ }
}
async function refreshStatus() {
const el = document.getElementById("status");
try {
const s = await (await fetch("api/status")).json();
if (!s.feed_version) { el.textContent = "Noch kein Fahrplan geladen …"; el.className = "warn"; return; }
const v = s.feed_version;
const parts = [`Fahrplan ${v.slice(6, 8)}.${v.slice(4, 6)}.${v.slice(0, 4)}`, `${s.vehicles} Züge`];
let warn = !s.shapes;
if (!s.realtime) { parts.push("keine Echtzeit (API-Key fehlt)"); warn = true; }
else if (s.rt_error || s.rt_age === null || s.rt_age > 180) { parts.push("Echtzeit gestört"); warn = true; }
else parts.push(`Echtzeit vor ${s.rt_age} s`);
if (!s.shapes) parts.push("Strecken: Luftlinie");
el.textContent = parts.join(" · ");
el.className = warn ? "warn" : "";
} catch (e) {
el.textContent = "Server nicht erreichbar";
el.className = "warn";
}
}
// ---------- 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" : ""}">
<span class="t">${esc(s.arr ?? s.dep)}</span>
<span>${esc(s.name)}${s.platform ? ` <span class="pl">Gl. ${esc(s.platform)}</span>` : ""}</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 } });
}
function closeTrip() {
openId = null;
document.getElementById("panel").hidden = true;
map.getSource("route").setData(EMPTY);
}
document.getElementById("close").onclick = closeTrip;
map.on("click", "trains", (e) => showTrip(e.features[0].properties.id));
map.on("mouseenter", "trains", () => { map.getCanvas().style.cursor = "pointer"; });
map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; });
map.on("load", () => {
let sat = false;
try { sat = localStorage.getItem("sat") === "1"; } catch (e) { /* egal */ }
setBackground(sat);
refreshTrains();
refreshStatus();
// shortcut: Züge springen alle 5 s; für flüssige Bewegung clientseitig interpolieren
setInterval(refreshTrains, 5000);
setInterval(refreshStatus, 30000);
setInterval(() => openId && showTrip(openId), 30000);
});