Files
2026-07-10 12:16:47 +02:00

506 lines
20 KiB
JavaScript

// VBB Netz Status - Transitous-Daten-Layer (EINZIGE Datenquelle)
//
// Transitous (https://transitous.org) ist ein community-betriebener,
// kostenloser Routing-Dienst auf MOTIS-2-Basis. Seit v35 ist er die
// alleinige Datenquelle der App (transport.rest wurde entfernt).
//
// Nutzungsrichtlinie von Transitous (Freiwilligen-Projekt, begrenzte
// Ressourcen, https://transitous.org/api/):
// - Eigenes Budget-Limit (90 Req/min) + aggressive Caches als Selbstschutz
// - Radar-Polling gedrosselt auf 30s, nur bei sichtbarer Live-Map
// - Quellenangabe: sichtbarer Link auf transitous.org/sources
// (siehe Entwickler-View)
//
// Dieser Adapter übersetzt die MOTIS-Endpoints (geocode, stoptimes,
// plan, trip, map/stops, map/trips) in die HAFAS-Form, die die App
// überall erwartet.
const TRANSITOUS_BASE = 'https://api.transitous.org/api';
const TRANSITOUS_ID_PREFIX = 'transitous:';
// Budget-gezählter, gecachter, deduplizierter, timeout-gesicherter Request
// (nutzt die zentrale apiFetch-Maschinerie aus js/api.js)
async function transitousFetch(path, ttl = 0) {
return apiFetch(TRANSITOUS_BASE + path, { ttl });
}
// MOTIS-Mode -> HAFAS-Produktname (für Farben, Icons, Filter)
function motisModeToProduct(mode) {
switch (mode) {
case 'SUBWAY': return 'subway';
case 'TRAM': return 'tram';
case 'BUS':
case 'COACH': return 'bus';
case 'SUBURBAN': return 'suburban';
case 'FERRY': return 'ferry';
case 'REGIONAL_RAIL':
case 'REGIONAL_FAST_RAIL': return 'regional';
case 'LONG_DISTANCE':
case 'HIGHSPEED_RAIL':
case 'NIGHT_RAIL': return 'express';
default: return 'bus';
}
}
// HAFAS-Produkt -> MOTIS-transitModes (für die Routensuche)
function productsToMotisModes(activeProducts) {
const map = {
suburban: ['SUBURBAN'],
subway: ['SUBWAY'],
tram: ['TRAM'],
bus: ['BUS', 'COACH'],
regional: ['REGIONAL_RAIL'],
express: ['LONG_DISTANCE', 'HIGHSPEED_RAIL', 'NIGHT_RAIL']
};
const modes = new Set(['FERRY']); // Fähre hat keinen UI-Button -> immer an
for (const p of activeProducts) {
(map[p] || []).forEach(m => modes.add(m));
}
return Array.from(modes).join(',');
}
// Verspätung in Sekunden aus Ist-/Sollzeit (HAFAS liefert delay in Sekunden)
function motisDelaySeconds(actual, scheduled) {
if (!actual || !scheduled) return null;
const diff = (new Date(actual) - new Date(scheduled)) / 1000;
return Number.isFinite(diff) ? Math.round(diff) : null;
}
// Geocode-Bias: letzte bekannte GPS-Position bevorzugen (bessere
// Trefferreihenfolge in Brandenburg), sonst Berlin-Mitte als Default
function geocodeBias() {
if (lastKnownLat != null && lastKnownLon != null) {
return `${lastKnownLat.toFixed(3)},${lastKnownLon.toFixed(3)}`;
}
return '52.52,13.41';
}
// GTFS-Farbe aus der API normalisieren ('DA421E' -> '#DA421E')
// Das sind die OFFIZIELLEN VBB-Linienfarben aus dem GTFS-Feed -
// kein Extra-Request nötig, sie stecken in stoptimes/plan/map-trips.
function motisColor(hex) {
if (!hex || typeof hex !== 'string') return null;
const clean = hex.replace('#', '').trim();
if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null;
return '#' + clean.toUpperCase();
}
// Lesbare Textfarbe für einen Hintergrund per Luminanz berechnen
// (ersetzt die alte hartcodierte "helle Hintergründe"-Liste)
function bestTextColor(hexBg) {
if (!hexBg) return '#FFF';
const c = hexBg.replace('#', '');
const r = parseInt(c.slice(0, 2), 16);
const g = parseInt(c.slice(2, 4), 16);
const b = parseInt(c.slice(4, 6), 16);
// Relative Luminanz (vereinfachte sRGB-Gewichtung)
return (0.299 * r + 0.587 * g + 0.114 * b) > 150 ? '#000' : '#FFF';
}
// ==========================================
// STATIONSSUCHE (geocode -> HAFAS-locations-Form)
// ==========================================
async function transitousLocations(query) {
// place=Berlin-Mitte als Bias, damit VBB-Gebiet zuerst kommt
// 6h Cache: Stationsnamen ändern sich praktisch nie
const matches = await transitousFetch(
`/v1/geocode?text=${encodeURIComponent(query)}&type=STOP&place=${geocodeBias()}&numResults=10`,
6 * 60 * 60 * 1000
);
return (matches || []).map(m => ({
type: 'stop',
id: TRANSITOUS_ID_PREFIX + m.id,
name: m.name,
location: { latitude: m.lat, longitude: m.lon },
products: {} // MOTIS liefert modes-Array; App braucht products nur optional
}));
}
// Station für Transitous auflösen: Stop-ID / Koordinaten / Name
// Rückgabe: Query-Fragment für stoptimes (stopId=... oder center=...&radius=...)
async function transitousStopQuery({ id, name, lat, lon }) {
if (id && id.startsWith(TRANSITOUS_ID_PREFIX)) {
return 'stopId=' + encodeURIComponent(id.slice(TRANSITOUS_ID_PREFIX.length));
}
if (lat != null && lon != null) {
// Koordinaten reichen: stoptimes bündelt Stops im Radius
return `center=${lat},${lon}&radius=250`;
}
// Nur Name bekannt (alte Favoriten): einmalig geocoden, Mapping cachen
const cacheKey = STORAGE_KEYS.stopMapPrefix + name;
const cached = storageGet(cacheKey);
// Neues Format {id, time}; altes Format (String) wird von
// cleanupStorage beim Start migriert, hier zur Sicherheit beides lesen
const cachedId = cached && (typeof cached === 'string' ? cached : cached.id);
if (cachedId) return 'stopId=' + encodeURIComponent(cachedId);
const matches = await transitousFetch(
`/v1/geocode?text=${encodeURIComponent(name)}&type=STOP&place=${geocodeBias()}&numResults=1`,
6 * 60 * 60 * 1000
);
if (!matches || matches.length === 0) throw new Error('Station bei Transitous nicht gefunden');
storageSet(cacheKey, { id: matches[0].id, time: Date.now() });
return 'stopId=' + encodeURIComponent(matches[0].id);
}
// ==========================================
// ABFAHRTEN (stoptimes -> HAFAS-departures-Form)
// ==========================================
async function transitousDepartures(station, n = 20) {
const stopQuery = await transitousStopQuery(station);
// 15s Cache: schützt vor Refresh-Spam
const data = await transitousFetch(`/v6/stoptimes?${stopQuery}&n=${n}`, 15000);
const departures = (data.stopTimes || []).map(st => {
const p = st.place || {};
return {
tripId: st.tripId,
direction: st.headsign || '',
line: {
name: st.routeShortName || st.displayName || '?',
product: motisModeToProduct(st.mode),
mode: st.mode === 'BUS' ? 'bus' : 'train',
color: motisColor(st.routeColor),
textColor: motisColor(st.routeTextColor),
operator: st.agencyName ? { name: st.agencyName } : undefined
},
when: p.departure || p.arrival || null,
plannedWhen: p.scheduledDeparture || p.scheduledArrival || null,
delay: motisDelaySeconds(
p.departure || p.arrival,
p.scheduledDeparture || p.scheduledArrival
),
platform: p.track || null,
plannedPlatform: p.scheduledTrack || null,
cancelled: st.cancelled || st.tripCancelled || p.cancelled || false
};
});
return { departures };
}
// ==========================================
// ROUTENSUCHE (plan -> HAFAS-journeys-Form)
// ==========================================
// Name -> Stop-ID auflösen (z.B. für via, das keine Koordinaten erlaubt)
async function transitousResolveStopId(name) {
if (!name) return null;
const matches = await transitousFetch(
`/v1/geocode?text=${encodeURIComponent(name)}&type=STOP&place=${geocodeBias()}&numResults=1`,
6 * 60 * 60 * 1000
);
return (matches && matches[0]) ? matches[0].id : null;
}
// fromPlace/toPlace: MOTIS akzeptiert "lat,lon" ODER Stop-ID
async function transitousPlace(station) {
if (station.id && station.id.startsWith(TRANSITOUS_ID_PREFIX)) {
return station.id.slice(TRANSITOUS_ID_PREFIX.length);
}
if (station.lat != null && station.lon != null) {
return `${station.lat},${station.lon}`;
}
const matches = await transitousFetch(
`/v1/geocode?text=${encodeURIComponent(station.name)}&type=STOP&place=${geocodeBias()}&numResults=1`,
6 * 60 * 60 * 1000
);
if (!matches || matches.length === 0) throw new Error('Station bei Transitous nicht gefunden');
return matches[0].id;
}
async function transitousJourneys(fromStation, toStation, {
timeISO = null, arriveBy = false, products = null, pageCursor = null,
via = null, wheelchair = false, bike = false
} = {}) {
const fromPlace = await transitousPlace(fromStation);
const toPlace = await transitousPlace(toStation);
let url = `/v6/plan?fromPlace=${encodeURIComponent(fromPlace)}` +
`&toPlace=${encodeURIComponent(toPlace)}` +
`&maxTransfers=3&minTransferTime=5` +
`&withFares=true`; // Experimentell: Preise, falls der Feed sie liefert
if (timeISO) url += `&time=${encodeURIComponent(timeISO)}&arriveBy=${arriveBy}`;
if (products) url += `&transitModes=${productsToMotisModes(products)}`;
// Blättern: Cursor aus der vorherigen Antwort ("Früher"/"Später")
if (pageCursor) url += `&pageCursor=${encodeURIComponent(pageCursor)}`;
// Via-Station: API akzeptiert NUR Stop-IDs (keine Koordinaten)
if (via) {
const viaId = via.id && via.id.startsWith(TRANSITOUS_ID_PREFIX)
? via.id.slice(TRANSITOUS_ID_PREFIX.length)
: await transitousResolveStopId(via.name);
if (viaId) url += `&via=${encodeURIComponent(viaId)}`;
}
// Barrierefrei: Rollstuhl-Profil für Fußwege/Umstiege
if (wheelchair) url += `&pedestrianProfile=WHEELCHAIR`;
// Fahrrad-Mitnahme in allen genutzten Fahrten erforderlich
if (bike) url += `&requireBikeTransport=true`;
// 30s Cache: identische Suche direkt hintereinander = 1 Request
const data = await transitousFetch(url, 30000);
const journeys = (data.itineraries || []).map(it => ({
fare: extractFare(it), // null, wenn der Feed keine Preisdaten liefert
legs: (it.legs || []).map(leg => {
const isWalk = leg.mode === 'WALK' || leg.mode === 'BIKE';
return {
walking: isWalk,
line: isWalk ? undefined : {
name: leg.routeShortName || leg.displayName || leg.mode,
product: motisModeToProduct(leg.mode),
color: motisColor(leg.routeColor),
textColor: motisColor(leg.routeTextColor)
},
direction: leg.headsign || '',
origin: { name: leg.from?.name || '?' },
destination: { name: leg.to?.name || '?' },
departure: leg.startTime,
plannedDeparture: leg.scheduledStartTime,
arrival: leg.endTime,
plannedArrival: leg.scheduledEndTime,
departurePlatform: leg.from?.track || null,
arrivalPlatform: leg.to?.track || null,
// Zwischenhalte (kamen bisher mit und wurden weggeworfen)
stopovers: (leg.intermediateStops || []).map(s => ({
stop: { name: s.name },
arrival: s.arrival || s.departure || null,
plannedArrival: s.scheduledArrival || s.scheduledDeparture || null
}))
};
})
}));
return {
journeys,
previousPageCursor: data.previousPageCursor || null,
nextPageCursor: data.nextPageCursor || null
};
}
// Preis aus der experimentellen Fare-Struktur ziehen - bewusst defensiv:
// liefert null bei fehlenden/unerwarteten Daten, dann zeigt die UI nichts.
function extractFare(itinerary) {
try {
let total = 0, currency = null, found = false;
for (const ft of (itinerary.fareTransfers || [])) {
// Erste Produkt-Alternative pro Transfer nehmen
const alternative = (ft.effectiveFareLegProducts || [])[0];
if (!alternative) continue;
for (const item of alternative) {
const product = item && (item.product || item);
if (product && typeof product.amount === 'number' && product.amount > 0) {
total += product.amount;
currency = product.currency || currency;
found = true;
}
}
}
return found ? { amount: total, currency: currency || 'EUR' } : null;
} catch (e) {
return null;
}
}
// ==========================================
// STATIONEN IN DER NÄHE (map/stops -> HAFAS-nearby-Form)
// GPS-Button: Stops in ~600m-Box um die Position, nach Distanz sortiert
// ==========================================
async function transitousNearby(lat, lon) {
const d = 0.006; // ~600m in Grad (Berlin-Breite)
const stops = await transitousFetch(
`/v6/map/stops?min=${(lat - d).toFixed(4)},${(lon - d).toFixed(4)}` +
`&max=${(lat + d).toFixed(4)},${(lon + d).toFixed(4)}`,
5 * 60 * 1000 // 5min Cache
);
const withDistance = (stops || [])
.filter(s => s.stopId && s.lat != null && s.lon != null)
.map(s => ({
type: 'stop',
id: TRANSITOUS_ID_PREFIX + s.stopId,
name: s.name,
location: { latitude: s.lat, longitude: s.lon },
products: {},
// Näherung reicht zum Sortieren (Haversine wäre Overkill für <1km)
distance: Math.round(Math.sqrt(
Math.pow((s.lat - lat) * 111320, 2) +
Math.pow((s.lon - lon) * 111320 * Math.cos(lat * Math.PI / 180), 2)
))
}))
.sort((a, b) => a.distance - b.distance);
return withDistance;
}
// ==========================================
// TRIP-DETAILS (trip -> HAFAS-trip-Form mit stopovers)
// ==========================================
async function transitousTrip(tripId) {
const rawId = tripId.startsWith(TRANSITOUS_ID_PREFIX)
? tripId.slice(TRANSITOUS_ID_PREFIX.length) : tripId;
const itinerary = await transitousFetch(
`/v6/trip?tripId=${encodeURIComponent(rawId)}`,
60000 // 60s Cache
);
// Der Trip kommt als Itinerary mit genau einem Transit-Leg
const leg = (itinerary.legs || []).find(l => l.mode !== 'WALK') || (itinerary.legs || [])[0];
if (!leg) return { trip: null };
const placeToStopover = (p) => ({
stop: { id: TRANSITOUS_ID_PREFIX + (p.stopId || ''), name: p.name },
arrival: p.arrival || null,
departure: p.departure || null,
plannedArrival: p.scheduledArrival || null,
plannedDeparture: p.scheduledDeparture || null,
arrivalDelay: motisDelaySeconds(p.arrival, p.scheduledArrival),
departureDelay: motisDelaySeconds(p.departure, p.scheduledDeparture)
});
const stopovers = [
placeToStopover(leg.from || {}),
...(leg.intermediateStops || []).map(placeToStopover),
placeToStopover(leg.to || {})
];
return { trip: { stopovers } };
}
// Stationen im Kartenausschnitt (für die Stations-Punkte auf der Live-Map).
// Koordinaten gerundet -> stabile Cache-Keys, 5min TTL.
async function transitousMapStops(bounds) {
const stops = await transitousFetch(
`/v6/map/stops?min=${bounds.south.toFixed(3)},${bounds.west.toFixed(3)}` +
`&max=${bounds.north.toFixed(3)},${bounds.east.toFixed(3)}`,
5 * 60 * 1000
);
return (stops || [])
.filter(s => s.stopId && s.lat != null && s.lon != null)
.map(s => ({
id: TRANSITOUS_ID_PREFIX + s.stopId,
name: s.name,
lat: s.lat,
lon: s.lon
}));
}
// Kompletter Streckenverlauf einer Fahrt (für die Live-Map).
// map/trips liefert nur das Segment im Kartenausschnitt - die GANZE
// Route steckt in der legGeometry des /trip-Endpoints. Gleiche URL wie
// transitousTrip -> geteilter 60s-Cache, Details+Route = 1 Request.
async function transitousTripGeometry(tripId) {
const rawId = tripId.startsWith(TRANSITOUS_ID_PREFIX)
? tripId.slice(TRANSITOUS_ID_PREFIX.length) : tripId;
const itinerary = await transitousFetch(
`/v6/trip?tripId=${encodeURIComponent(rawId)}`,
60000
);
const leg = (itinerary.legs || []).find(l => l.mode !== 'WALK') || (itinerary.legs || [])[0];
if (!leg || !leg.legGeometry || !leg.legGeometry.points) return null;
const precision = leg.legGeometry.precision || 6;
const points = decodePolyline(leg.legGeometry.points, precision);
if (points.length < 2) return null;
return {
points,
color: motisColor(leg.routeColor)
};
}
// ==========================================
// RADAR / LIVE-MAP (map/trips -> HAFAS-radar-Form)
//
// MOTIS liefert keine Punkt-Positionen, sondern Fahrt-SEGMENTE mit
// Polyline + Abfahrts-/Ankunftszeit. Die aktuelle Fahrzeugposition wird
// zeitlich entlang der Polyline interpoliert (so macht es die
// Transitous-Web-Karte selbst auch).
// ==========================================
// Google-Polyline dekodieren. Die Precision variiert je Endpoint:
// map/trips liefert 5, legGeometry (trip/plan) liefert precision als Feld mit.
function decodePolyline(encoded, precision = 5) {
const factor = Math.pow(10, precision);
const points = [];
let index = 0, lat = 0, lon = 0;
while (index < encoded.length) {
for (const which of [0, 1]) {
let result = 0, shift = 0, byte;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
const delta = (result & 1) ? ~(result >> 1) : (result >> 1);
if (which === 0) lat += delta; else lon += delta;
}
points.push([lat / factor, lon / factor]);
}
return points;
}
// Kompatibilitäts-Alias (Tests + map/trips)
function decodePolyline5(encoded) {
return decodePolyline(encoded, 5);
}
async function transitousRadarSegments(bounds, zoom) {
const now = new Date();
const path = `/v6/map/trips?zoom=${Math.round(zoom)}` +
`&min=${bounds.south.toFixed(3)},${bounds.west.toFixed(3)}` +
`&max=${bounds.north.toFixed(3)},${bounds.east.toFixed(3)}` +
`&startTime=${encodeURIComponent(now.toISOString())}` +
`&endTime=${encodeURIComponent(new Date(now.getTime() + 90000).toISOString())}`;
// 25s Cache: passt zum 30s-Polling, fängt Karten-Gezappel ab
const raw = await transitousFetch(path, 25000);
// Normalisieren: Polyline einmal dekodieren, Zeiten als ms.
// Die Live-Map interpoliert daraus jede Sekunde die Position LOKAL -
// flüssige Bewegung ohne einen einzigen zusätzlichen API-Call.
const segments = [];
for (const seg of (raw || [])) {
const depMs = new Date(seg.departure).getTime();
const arrMs = new Date(seg.arrival).getTime();
if (!Number.isFinite(depMs) || !Number.isFinite(arrMs) || !seg.polyline) continue;
let points;
try { points = decodePolyline5(seg.polyline); } catch (e) { continue; }
if (points.length === 0) continue;
const trip = (seg.trips && seg.trips[0]) || {};
segments.push({
tripId: trip.tripId || `${seg.mode}-${depMs}-${points[0][0].toFixed(4)}`,
lineName: trip.routeShortName || trip.displayName || '?',
product: motisModeToProduct(seg.mode),
color: motisColor(seg.routeColor),
direction: seg.to?.name || '',
delay: motisDelaySeconds(seg.departure, seg.scheduledDeparture),
depMs, arrMs, points
});
}
return segments;
}
// Aktuelle Position eines Segments zum Zeitpunkt nowMs (lineare Interpolation
// zwischen den Polyline-Punkten - genauer als nur den nächsten Punkt zu wählen)
function segmentPositionAt(seg, nowMs) {
if (nowMs < seg.depMs || nowMs > seg.arrMs) return null; // fährt gerade nicht
const pts = seg.points;
if (pts.length === 1) return { latitude: pts[0][0], longitude: pts[0][1] };
const frac = seg.arrMs > seg.depMs ? (nowMs - seg.depMs) / (seg.arrMs - seg.depMs) : 0;
const scaled = frac * (pts.length - 1);
const i = Math.min(pts.length - 2, Math.floor(scaled));
const t = scaled - i;
return {
latitude: pts[i][0] + (pts[i + 1][0] - pts[i][0]) * t,
longitude: pts[i][1] + (pts[i + 1][1] - pts[i][1]) * t
};
}