Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
372 lines
18 KiB
JavaScript
372 lines
18 KiB
JavaScript
"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) => l.id !== "roads_shields").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 & 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://gtfs.be">GTFS.be</a> (NMBS/SNCB)',
|
||
},
|
||
},
|
||
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",
|
||
"icon-size": ["interpolate", ["linear"], ["zoom"], 6, 0.3, 9, 0.5, 12, 0.9, 14, 1.3],
|
||
"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 ----------
|
||
|
||
const FILTERS = { all: null, local: ["!", ["get", "fv"]], long: ["get", "fv"] };
|
||
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));
|
||
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 ts = 0, clockOffset = 0;
|
||
|
||
let ready = false; // erst Züge zeigen, wenn der Server fertige Daten (inkl. Echtzeit) hat
|
||
|
||
async function refreshTrains() {
|
||
if (document.hidden || !ready) return;
|
||
try {
|
||
const r = await fetch("api/vehicles");
|
||
if (!r.ok) return;
|
||
const now = Number(r.headers.get("X-Now"));
|
||
const d = await r.json();
|
||
if (now) clockOffset = Date.now() / 1000 - now;
|
||
const old = new Map(trains.features.map((f) => [f.properties.id, f.properties.bearing]));
|
||
for (const f of d.features) {
|
||
f.pts = f.path && f.path.length ? f.path : [[...f.geometry.coordinates, 0]]; // [lon, lat, s ab ts]
|
||
const b = old.get(f.properties.id);
|
||
if (b !== undefined) f.properties.bearing = b;
|
||
}
|
||
trains = d;
|
||
ts = d.ts;
|
||
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;
|
||
function render() {
|
||
const t = Date.now() / 1000 - clockOffset - ts; // s seit Berechnung auf dem Server
|
||
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;
|
||
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;
|
||
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) {
|
||
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;
|
||
}
|
||
const rtOk = s.rt_ok;
|
||
el.textContent = `${s.vehicles} Züge 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 } });
|
||
map.getSource("route-stops").setData({ type: "FeatureCollection", features: t.stops.map((x) => (
|
||
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [x.lon, x.lat] } })) });
|
||
}
|
||
|
||
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);
|
||
});
|