Pfeil aus Guidos SVG, weiche Drehung entlang der Strecke, Dunkelmodus, lesbare Ortsnamen im Satellit
- Server liefert pro Zug Positionen alle 2 s für die nächsten 10 s, der Browser fährt Kurven nach und dreht die Spitze weich (~0,3 s) - Richtung unter ~2 m Bewegung ignoriert (kein Umklappen am Halt) - Eigene Icon-Namen (tp-*), die Protomaps-Sprites hatten arrow/dot schon - Modi Karte / Dunkel / Satellit; im Satellit weisse Schrift mit dunklem Rand Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
+11
-5
@@ -19,7 +19,7 @@ from . import realtime
|
||||
from .feed import TZ, Feed
|
||||
|
||||
log = logging.getLogger("trainpulse")
|
||||
AHEAD = 10
|
||||
STEP, STEPS = 2, 5 # Positionen alle 2 s für die nächsten 10 s, damit der Browser Kurven nachfährt
|
||||
WEB = Path(__file__).resolve().parent.parent / "web"
|
||||
|
||||
|
||||
@@ -84,11 +84,17 @@ def compute_positions():
|
||||
return
|
||||
now = time.time()
|
||||
feats = S.feed.vehicles(now, S.rt)
|
||||
# Position in AHEAD s mitliefern: der Browser fährt die Züge damit flüssig dazwischen
|
||||
ahead = {f["properties"]["id"]: f["geometry"]["coordinates"] for f in S.feed.vehicles(now + AHEAD, S.rt)}
|
||||
future = [{f["properties"]["id"]: f["geometry"]["coordinates"] for f in S.feed.vehicles(now + STEP * k, S.rt)}
|
||||
for k in range(1, STEPS + 1)]
|
||||
for f in feats:
|
||||
f["next"] = ahead.get(f["properties"]["id"])
|
||||
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "ahead": AHEAD, "features": feats},
|
||||
path = []
|
||||
for pos in future:
|
||||
p = pos.get(f["properties"]["id"])
|
||||
if p is None:
|
||||
break # Fahrt endet
|
||||
path.append(p)
|
||||
f["path"] = path
|
||||
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "step": STEP, "features": feats},
|
||||
separators=(",", ":")).encode()
|
||||
S.vehicles_gz = gzip.compress(S.vehicles, 5)
|
||||
S.count, S.computed_at = len(feats), time.time()
|
||||
|
||||
+96
-46
@@ -4,9 +4,16 @@ 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));
|
||||
// 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)
|
||||
const prefixed = (layers, pre, extra = {}) => layers.map((l) => ({ ...l, id: pre + l.id,
|
||||
layout: { ...l.layout, 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",
|
||||
@@ -49,21 +56,21 @@ const map = new maplibregl.Map({
|
||||
layers: [
|
||||
{ id: "s2", type: "raster", source: "s2", layout: { visibility: "none" } },
|
||||
{ id: "swissimage", type: "raster", source: "swissimage", layout: { visibility: "none" } },
|
||||
...baseLayers,
|
||||
...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: "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-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.7, 10, 1.1, 14, 1.6],
|
||||
"icon-size": ["interpolate", ["linear"], ["zoom"], 6, 0.6, 10, 0.9, 14, 1.3],
|
||||
"icon-allow-overlap": true, "icon-ignore-placement": true,
|
||||
},
|
||||
paint: {
|
||||
"icon-color": delayColor, "icon-halo-color": "#fff",
|
||||
"icon-halo-width": 4,
|
||||
"icon-halo-width": 6,
|
||||
} },
|
||||
{ id: "train-labels", type: "symbol", source: "trains", minzoom: 10,
|
||||
layout: {
|
||||
@@ -78,25 +85,31 @@ const map = new maplibregl.Map({
|
||||
map.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), "bottom-right");
|
||||
map.addControl(new maplibregl.ScaleControl(), "bottom-left");
|
||||
|
||||
// ---------- Karte / Satellit ----------
|
||||
// ---------- Karte / Dunkel / 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 */ }
|
||||
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 */ }
|
||||
}
|
||||
document.getElementById("btn-map").onclick = () => setBackground(false);
|
||||
document.getElementById("btn-sat").onclick = () => setBackground(true);
|
||||
for (const m of MODES) document.getElementById("btn-" + m).onclick = () => setMode(m);
|
||||
|
||||
// ---------- Züge ----------
|
||||
|
||||
// Server liefert je Zug die Position jetzt und in `ahead` Sekunden; dazwischen wird linear gefahren.
|
||||
// Server liefert je Zug die Position jetzt und alle `step` Sekunden danach (entlang der Strecke);
|
||||
// dazwischen wird gefahren, und die Spitze dreht sich weich in die jeweilige Richtung.
|
||||
let trains = { type: "FeatureCollection", features: [] };
|
||||
let ts = 0, ahead = 10, clockOffset = 0;
|
||||
let ts = 0, step = 2, clockOffset = 0;
|
||||
|
||||
async function refreshTrains() {
|
||||
if (document.hidden) return;
|
||||
@@ -108,14 +121,13 @@ async function refreshTrains() {
|
||||
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.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
|
||||
f.pts = [f.geometry.coordinates, ...(f.path || [])];
|
||||
const b = old.get(f.properties.id);
|
||||
if (b !== undefined) f.properties.bearing = b;
|
||||
}
|
||||
trains = d;
|
||||
ts = d.ts;
|
||||
ahead = d.ahead || 10;
|
||||
step = d.step || 2;
|
||||
} catch (e) { /* nächster Versuch in ein paar Sekunden */ }
|
||||
}
|
||||
|
||||
@@ -123,14 +135,14 @@ async function refreshTrains() {
|
||||
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;
|
||||
if (Math.hypot(dx, dy) < 3e-7) return null; // unter ~2 m: steht (Rundungsrauschen würde die Spitze umklappen)
|
||||
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));
|
||||
// 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
|
||||
@@ -144,22 +156,60 @@ function sdfIcon(inside) {
|
||||
}
|
||||
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 });
|
||||
|
||||
// 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() {
|
||||
// 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 t = Date.now() / 1000 - clockOffset - ts;
|
||||
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) {
|
||||
if (!f.next) continue;
|
||||
f.geometry.coordinates = [f.start[0] + (f.next[0] - f.start[0]) * k, f.start[1] + (f.next[1] - f.start[1]) * k];
|
||||
const p = f.pts;
|
||||
if (!p) continue;
|
||||
const x = Math.max(0, t / step);
|
||||
const i = Math.min(Math.floor(x), p.length - 1);
|
||||
const a = p[i], b = p[Math.min(i + 1, p.length - 1)], k = i + 1 < p.length ? x - i : 0;
|
||||
f.geometry.coordinates = [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k];
|
||||
const target = bearing(a, b);
|
||||
if (target === null) continue; // steht: Richtung behalten
|
||||
const cur = f.properties.bearing;
|
||||
f.properties.bearing = cur === undefined ? target : cur + (((((target - cur) % 360) + 540) % 360) - 180) * ease;
|
||||
}
|
||||
map.getSource("trains").setData(trains);
|
||||
}
|
||||
@@ -253,9 +303,9 @@ map.on("mousemove", "trains", (e) => {
|
||||
map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; hover.remove(); });
|
||||
|
||||
map.on("load", () => {
|
||||
let sat = false;
|
||||
try { sat = localStorage.getItem("sat") === "1"; } catch (e) { /* egal */ }
|
||||
setBackground(sat);
|
||||
let mode = "light";
|
||||
try { mode = localStorage.getItem("mode") || (localStorage.getItem("sat") === "1" ? "sat" : "light"); } catch (e) { /* egal */ }
|
||||
setMode(mode);
|
||||
refreshTrains();
|
||||
refreshStatus();
|
||||
setInterval(refreshTrains, 5000);
|
||||
|
||||
+2
-1
@@ -17,7 +17,8 @@
|
||||
<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-light" aria-pressed="true">Karte</button>
|
||||
<button id="btn-dark" aria-pressed="false">Dunkel</button>
|
||||
<button id="btn-sat" aria-pressed="false">Satellit</button>
|
||||
</div>
|
||||
<ul class="legend">
|
||||
|
||||
Reference in New Issue
Block a user