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:
+64
-9
@@ -52,16 +52,22 @@ const map = new maplibregl.Map({
|
||||
...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",
|
||||
{ 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: {
|
||||
"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],
|
||||
"icon-color": delayColor, "icon-halo-color": "#fff",
|
||||
"icon-halo-width": 4,
|
||||
} },
|
||||
{ id: "train-labels", type: "symbol", source: "trains", minzoom: 10,
|
||||
layout: {
|
||||
"text-field": ["concat", ["get", "line"], " ", ["get", "nr"]],
|
||||
"text-field": ["get", "line"],
|
||||
"text-font": ["Noto Sans Medium"], "text-size": 11,
|
||||
"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 d = await r.json();
|
||||
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;
|
||||
ts = d.ts;
|
||||
ahead = d.ahead || 10;
|
||||
} 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() {
|
||||
// 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));
|
||||
@@ -194,8 +241,16 @@ function closeTrip() {
|
||||
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 = ""; });
|
||||
|
||||
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", () => {
|
||||
let sat = false;
|
||||
|
||||
@@ -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 |
@@ -5,6 +5,7 @@
|
||||
<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="icon" href="favicon.svg" type="image/svg+xml">
|
||||
<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>
|
||||
|
||||
@@ -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; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user