Add files via upload
This commit is contained in:
1 parent
cbc9fd2510
commit
a976642183
10 files changed
+953
-280
No files matched your search
@@ -0,0 +1,379 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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=52.52,13.41&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 = 'vbb_transitous_stop_' + name;
|
||||
const cached = storageGet(cacheKey);
|
||||
if (cached) return 'stopId=' + encodeURIComponent(cached);
|
||||
|
||||
const matches = await transitousFetch(
|
||||
`/v1/geocode?text=${encodeURIComponent(name)}&type=STOP&place=52.52,13.41&numResults=1`,
|
||||
6 * 60 * 60 * 1000
|
||||
);
|
||||
if (!matches || matches.length === 0) throw new Error('Station bei Transitous nicht gefunden');
|
||||
storageSet(cacheKey, matches[0].id);
|
||||
return 'stopId=' + encodeURIComponent(matches[0].id);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ABFAHRTEN (stoptimes -> HAFAS-departures-Form)
|
||||
// ==========================================
|
||||
async function transitousDepartures(station) {
|
||||
const stopQuery = await transitousStopQuery(station);
|
||||
// 15s Cache: schützt vor Refresh-Spam
|
||||
const data = await transitousFetch(`/v6/stoptimes?${stopQuery}&n=20`, 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)
|
||||
// ==========================================
|
||||
|
||||
// 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=52.52,13.41&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 } = {}) {
|
||||
const fromPlace = await transitousPlace(fromStation);
|
||||
const toPlace = await transitousPlace(toStation);
|
||||
|
||||
let url = `/v6/plan?fromPlace=${encodeURIComponent(fromPlace)}` +
|
||||
`&toPlace=${encodeURIComponent(toPlace)}` +
|
||||
`&maxTransfers=3&minTransferTime=5`;
|
||||
if (timeISO) url += `&time=${encodeURIComponent(timeISO)}&arriveBy=${arriveBy}`;
|
||||
if (products) url += `&transitModes=${productsToMotisModes(products)}`;
|
||||
|
||||
// 30s Cache: identische Suche direkt hintereinander = 1 Request
|
||||
const data = await transitousFetch(url, 30000);
|
||||
|
||||
const journeys = (data.itineraries || []).map(it => ({
|
||||
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
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
return { journeys };
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// 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 } };
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 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 (Precision 5 laut MOTIS-Spec für map/trips)
|
||||
function decodePolyline5(encoded) {
|
||||
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 / 1e5, lon / 1e5]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user