// 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 }; }