Züge als Tropfen in Fahrtrichtung, nur Linie als Label, Hover mit Zugnummer, Favicon

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
Claude
2026-10-21 20:16:15 +02:00
parent 30d3127b21
commit 4447c83ef4
4 changed files with 69 additions and 9 deletions
+64 -9
View File
@@ -52,16 +52,22 @@ const map = new maplibregl.Map({
...baseLayers, ...baseLayers,
{ id: "route", type: "line", source: "route", layout: { "line-cap": "round", "line-join": "round" }, { id: "route", type: "line", source: "route", layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": "#2563eb", "line-width": 4, "line-opacity": 0.85 } }, paint: { "line-color": "#2563eb", "line-width": 4, "line-opacity": 0.85 } },
{ id: "trains", type: "circle", source: "trains", { id: "trains", type: "symbol", source: "trains",
layout: {
// Tropfen mit Spitze in Fahrtrichtung; Züge ohne bekannte Richtung als Punkt
"icon-image": ["case", ["has", "bearing"], "drop", "dot"],
"icon-rotate": ["coalesce", ["get", "bearing"], 0],
"icon-rotation-alignment": "map",
"icon-size": ["interpolate", ["linear"], ["zoom"], 6, 0.7, 10, 1.1, 14, 1.6],
"icon-allow-overlap": true, "icon-ignore-placement": true,
},
paint: { paint: {
"circle-color": delayColor, "icon-color": delayColor, "icon-halo-color": "#fff",
"circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 3, 10, 5.5, 14, 9], "icon-halo-width": 4,
"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, { id: "train-labels", type: "symbol", source: "trains", minzoom: 10,
layout: { layout: {
"text-field": ["concat", ["get", "line"], " ", ["get", "nr"]], "text-field": ["get", "line"],
"text-font": ["Noto Sans Medium"], "text-size": 11, "text-font": ["Noto Sans Medium"], "text-size": 11,
"text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true, "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true,
}, },
@@ -100,13 +106,54 @@ async function refreshTrains() {
const now = Number(r.headers.get("X-Now")); const now = Number(r.headers.get("X-Now"));
const d = await r.json(); const d = await r.json();
if (now) clockOffset = Date.now() / 1000 - now; if (now) clockOffset = Date.now() / 1000 - now;
for (const f of d.features) f.start = f.geometry.coordinates; const old = new Map(trains.features.map((f) => [f.properties.id, f.properties.bearing]));
for (const f of d.features) {
f.start = f.geometry.coordinates;
const b = f.next && bearing(f.start, f.next);
if (b !== null && b !== undefined) f.properties.bearing = b;
else if (old.get(f.properties.id) !== undefined) f.properties.bearing = old.get(f.properties.id); // steht: alte Richtung behalten
}
trains = d; trains = d;
ts = d.ts; ts = d.ts;
ahead = d.ahead || 10; ahead = d.ahead || 10;
} catch (e) { /* nächster Versuch in ein paar Sekunden */ } } catch (e) { /* nächster Versuch in ein paar Sekunden */ }
} }
// Fahrtrichtung in Grad (0 = Norden, im Uhrzeigersinn), in Mercator gerechnet wie die Karte
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]);
if (Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9) return null;
return (Math.atan2(dx, dy) * 180) / Math.PI;
}
// SDF-Icons, damit MapLibre sie nach Verspätung einfärben und mit weissem Rand zeichnen kann
function sdfIcon(inside) {
const S = 64, R = 8, img = new Uint8Array(S * S * 4), mask = [];
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) mask.push(inside(x + 0.5, y + 0.5));
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 };
}
const C = 32, RAD = 13; // Kreis in der Bildmitte, damit die Drehung um den Kreismittelpunkt geht
map.addImage("dot", sdfIcon((x, y) => Math.hypot(x - C, y - C) <= RAD), { sdf: true, pixelRatio: 2 });
map.addImage("drop", sdfIcon((x, y) => {
if (Math.hypot(x - C, y - C) <= RAD) return true;
const tip = 6, h = C - tip; // Spitze oben (Norden); Seiten tangential an den Kreis
if (y < tip || y > C) return false;
const half = (RAD * (y - tip)) / h * 1.15;
return Math.abs(x - C) <= half;
}), { sdf: true, pixelRatio: 2 });
function render() { function render() {
// leicht über `ahead` hinaus weiterfahren, falls eine Abfrage hängt, dann anhalten // leicht über `ahead` hinaus weiterfahren, falls eine Abfrage hängt, dann anhalten
const k = Math.max(0, Math.min((Date.now() / 1000 - clockOffset - ts) / ahead, 1.5)); const k = Math.max(0, Math.min((Date.now() / 1000 - clockOffset - ts) / ahead, 1.5));
@@ -194,8 +241,16 @@ function closeTrip() {
document.getElementById("close").onclick = closeTrip; document.getElementById("close").onclick = closeTrip;
map.on("click", "trains", (e) => showTrip(e.features[0].properties.id)); 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 = ""; }); const hover = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 12, className: "hover" });
map.on("mousemove", "trains", (e) => {
map.getCanvas().style.cursor = "pointer";
const p = e.features[0].properties;
const delay = p.delay === undefined || p.delay === null ? "" : delayText(Number(p.delay)) || '<span class="d" style="color:var(--ok)">pünktlich</span>';
hover.setLngLat(e.features[0].geometry.coordinates)
.setHTML(`<b>${esc(p.line)} ${esc(p.nr)}</b> ${delay}<br>nach ${esc(p.to)}`).addTo(map);
});
map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; hover.remove(); });
map.on("load", () => { map.on("load", () => {
let sat = false; let sat = false;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M32 3 L45.6 23.4 A17 17 0 1 1 18.4 23.4 Z" fill="#b91c1c" stroke="#fff" stroke-width="3" stroke-linejoin="round"/><circle cx="32" cy="38" r="6" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 233 B

+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>TrainPulse</title> <title>TrainPulse</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css"> <link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.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/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/pmtiles@4.5.0/dist/pmtiles.js"></script>
+3
View File
@@ -43,3 +43,6 @@ h1::before { content: ""; display: inline-block; width: 10px; height: 10px; bord
#panel { top: auto; bottom: 0; right: 0; left: 0; width: auto; max-height: 55%; border-radius: 12px 12px 0 0; } #panel { top: auto; bottom: 0; right: 0; left: 0; width: auto; max-height: 55%; border-radius: 12px 12px 0 0; }
.legend { display: none; } .legend { display: none; }
} }
.hover .maplibregl-popup-content { background: var(--bg); color: var(--fg); padding: 6px 10px; border-radius: 8px; font-size: 13px; }
.hover .maplibregl-popup-tip { display: none; }