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
This commit is contained in:
+180
@@ -0,0 +1,180 @@
|
||||
"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 & 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>TrainPulse</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
|
||||
<script src="https://unpkg.com/pmtiles@4.5.0/dist/pmtiles.js"></script>
|
||||
<script src="https://unpkg.com/@protomaps/basemaps@5.7.2/dist/basemaps.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
|
||||
<header class="card" id="bar">
|
||||
<h1>TrainPulse</h1>
|
||||
<div class="seg" role="group" aria-label="Hintergrund">
|
||||
<button id="btn-map" aria-pressed="true">Karte</button>
|
||||
<button id="btn-sat" aria-pressed="false">Satellit</button>
|
||||
</div>
|
||||
<ul class="legend">
|
||||
<li><i style="--c:var(--ok)"></i>pünktlich</li>
|
||||
<li><i style="--c:var(--d1)"></i>3–6′</li>
|
||||
<li><i style="--c:var(--d2)"></i>6–15′</li>
|
||||
<li><i style="--c:var(--d3)"></i>>15′</li>
|
||||
<li><i style="--c:var(--na)"></i>ohne Echtzeit</li>
|
||||
</ul>
|
||||
<p id="status">Lade …</p>
|
||||
</header>
|
||||
|
||||
<aside class="card" id="panel" hidden>
|
||||
<button id="close" aria-label="Schliessen">×</button>
|
||||
<div id="detail"></div>
|
||||
</aside>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
:root {
|
||||
--bg: #fff; --fg: #1f2937; --muted: #6b7280; --line: #e5e7eb; --accent: #b91c1c;
|
||||
--ok: #16a34a; --d1: #eab308; --d2: #f97316; --d3: #dc2626; --na: #94a3b8;
|
||||
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
color: var(--fg);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --bg: #111827; --fg: #e5e7eb; --muted: #9ca3af; --line: #374151; }
|
||||
}
|
||||
html, body { margin: 0; height: 100%; }
|
||||
#map { position: absolute; inset: 0; }
|
||||
|
||||
.card {
|
||||
position: absolute; z-index: 2; background: var(--bg); border-radius: 10px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,.18);
|
||||
}
|
||||
#bar { top: 12px; left: 12px; padding: 10px 12px; max-width: min(340px, calc(100vw - 24px)); box-sizing: border-box; }
|
||||
h1 { margin: 0 0 8px; font-size: 16px; letter-spacing: .02em; }
|
||||
h1::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); margin-right: 7px; }
|
||||
|
||||
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
|
||||
.seg button { border: 0; background: transparent; color: var(--fg); padding: 6px 14px; font: inherit; cursor: pointer; }
|
||||
.seg button[aria-pressed="true"] { background: var(--fg); color: var(--bg); }
|
||||
|
||||
.legend { list-style: none; padding: 0; margin: 8px 0 0; display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 12px; color: var(--muted); }
|
||||
.legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--c); margin-right: 4px; }
|
||||
#status { margin: 6px 0 0; font-size: 12px; color: var(--muted); }
|
||||
#status.warn { color: var(--d2); }
|
||||
|
||||
#panel { top: 12px; right: 12px; width: 340px; max-height: calc(100% - 24px); overflow: auto; padding: 14px 16px; box-sizing: border-box; }
|
||||
#close { position: sticky; top: 0; float: right; border: 0; background: none; color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; }
|
||||
#detail h2 { margin: 0; font-size: 18px; }
|
||||
#detail .sub { color: var(--muted); margin: 2px 0 10px; }
|
||||
.badge { display: inline-block; padding: 1px 8px; border-radius: 99px; color: #fff; font-size: 12px; font-weight: 600; vertical-align: 2px; }
|
||||
.stops { list-style: none; margin: 0; padding: 0; }
|
||||
.stops li { display: grid; grid-template-columns: 46px 1fr auto; gap: 0 8px; padding: 5px 0; border-top: 1px solid var(--line); }
|
||||
.stops li.passed { opacity: .45; }
|
||||
.stops .t { font-variant-numeric: tabular-nums; }
|
||||
.stops .d { font-size: 12px; font-weight: 600; }
|
||||
.stops .pl { color: var(--muted); font-size: 12px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
#panel { top: auto; bottom: 0; right: 0; left: 0; width: auto; max-height: 55%; border-radius: 12px 12px 0 0; }
|
||||
.legend { display: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user