- Implemented StopsMap component to display bus stops on a map using MapLibre GL. - Integrated walking directions feature in WalkingDirections component to calculate routes from user location to selected bus stop. - Added geo utility functions for coordinate validation and distance calculation. - Created route grouping and stop filtering logic in routeGroups module. - Developed custom hooks for managing stop routes with React Query. - Added tests for routing and stop functionalities to ensure reliability.
279 lines
15 KiB
JavaScript
279 lines
15 KiB
JavaScript
import { ApiError } from './errors.mjs';
|
|
import { journeyStops, previousDate, routeDepartures, tripIdentity, validDate } from './timetable.mjs';
|
|
import { normalizeAlerts, normalizeNearby } from './features.mjs';
|
|
import { coordinates } from './coordinates.mjs';
|
|
import { normalizeWalking } from './walking.mjs';
|
|
export { ApiError } from './errors.mjs';
|
|
|
|
function positiveId(value) {
|
|
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
|
|
throw new ApiError(400, 'invalid_request');
|
|
}
|
|
return Number(value);
|
|
}
|
|
|
|
export function parseRequest(params) {
|
|
const resource = params.get('resource');
|
|
if (resource === 'cities') return { resource };
|
|
if (!['stops', 'stop', 'routes', 'routeStops', 'walk', 'departures', 'timetable', 'alerts', 'nearby', 'journey'].includes(resource)) throw new ApiError(400, 'invalid_request');
|
|
const cityId = positiveId(params.get('cityId'));
|
|
if (resource === 'alerts') return { resource, cityId };
|
|
if (resource === 'routes') return { resource, cityId };
|
|
if (resource === 'routeStops') return { resource, cityId, routeId: positiveId(params.get('routeId')) };
|
|
if (resource === 'nearby' || resource === 'walk') {
|
|
const coordinate = (name, limit) => {
|
|
const value = params.get(name);
|
|
if (!value || !/^-?\d+(?:\.\d+)?$/.test(value) || Math.abs(Number(value)) > limit) throw new ApiError(400, 'invalid_request');
|
|
return Number(value);
|
|
};
|
|
if (resource === 'walk') return { resource, cityId, stopId: positiveId(params.get('stopId')), lat: coordinate('lat', 90), lon: coordinate('lon', 180) };
|
|
const radius = params.has('radius') ? positiveId(params.get('radius')) : 1000;
|
|
if (radius < 100 || radius > 2000) throw new ApiError(400, 'invalid_request');
|
|
return { resource, cityId, lat: coordinate('lat', 90), lon: coordinate('lon', 180), radius };
|
|
}
|
|
if (resource === 'journey') {
|
|
const at = params.get('at');
|
|
const time = Date.parse(at);
|
|
if (!Number.isFinite(time) || new Date(time).toISOString() !== at) throw new ApiError(400, 'invalid_request');
|
|
const date = new Date(time + 10800000).toISOString().slice(0, 10);
|
|
if (date < '2000-01-02' || date > '2100-12-31') throw new ApiError(400, 'invalid_request');
|
|
const tripKey = params.get('tripKey') || undefined;
|
|
if (tripKey && (!/^[\w-]+$/.test(tripKey) || tripKey.length > 2000)) throw new ApiError(400, 'invalid_request');
|
|
return { resource, cityId, stopId: positiveId(params.get('stopId')), routeId: positiveId(params.get('routeId')), at, ...(tripKey ? { tripKey } : {}) };
|
|
}
|
|
if (resource === 'timetable') {
|
|
const date = params.get('date');
|
|
if (!validDate(date) || date < '2000-01-02' || date > '2100-12-31') throw new ApiError(400, 'invalid_request');
|
|
return { resource, cityId, stopId: positiveId(params.get('stopId')), date };
|
|
}
|
|
return resource === 'stops' ? { resource, cityId } : { resource, cityId, stopId: positiveId(params.get('stopId')) };
|
|
}
|
|
|
|
const record = value => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ApiError(502);
|
|
return value;
|
|
};
|
|
const list = value => {
|
|
if (!Array.isArray(value)) throw new ApiError(502);
|
|
return value;
|
|
};
|
|
const text = value => {
|
|
if (typeof value !== 'string' || !value.trim()) throw new ApiError(502);
|
|
return value.trim();
|
|
};
|
|
const id = value => {
|
|
const result = Number(value);
|
|
if (!Number.isSafeInteger(result) || result <= 0) throw new ApiError(502);
|
|
return result;
|
|
};
|
|
|
|
export function normalizeCities(raw) {
|
|
return list(raw).filter(item => item?.is_active !== false).map(item => {
|
|
const city = record(item);
|
|
return { id: id(city.id), name: text(city.name), slug: typeof city.slug === 'string' ? city.slug : '',
|
|
...coordinates(city.center_lat, city.center_lon) };
|
|
});
|
|
}
|
|
|
|
export function normalizeStops(raw) {
|
|
return list(raw).map(item => {
|
|
const stop = record(item);
|
|
return {
|
|
id: id(stop.id), name: text(stop.name),
|
|
direction: typeof stop.description === 'string' ? stop.description.trim() : '',
|
|
routesCount: Math.max(0, Number(stop.routes_count) || 0),
|
|
address: typeof stop.address === 'string' ? stop.address.trim() : '',
|
|
...coordinates(stop.latitude, stop.longitude),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function normalizeStop(raw) {
|
|
const stop = record(raw);
|
|
const routes = [...new Map(list(stop.routes).filter(route => route?.is_active !== false).map(route => {
|
|
const value = { id: id(route.id), number: text(route.route_number), name: text(route.name) };
|
|
return [value.id, value];
|
|
})).values()].sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true }));
|
|
return { ...normalizeStops([{ ...stop, routes_count: routes.length }])[0], routes };
|
|
}
|
|
|
|
export function normalizeRoutes(raw) {
|
|
return [...new Map(list(raw).filter(route => route?.is_active !== false).map(route => {
|
|
const value = { id: id(route.id), number: text(route.route_number), name: text(route.name) };
|
|
return [value.id, value];
|
|
})).values()].sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true }) || a.id - b.id);
|
|
}
|
|
|
|
export function normalizeRouteStops(raw) {
|
|
const route = record(raw);
|
|
const stops = list(route.stops).filter(stop => stop?.is_active !== false).map(stop => ({
|
|
...normalizeStops([stop])[0], sequence: id(stop.sequence),
|
|
})).sort((a, b) => a.sequence - b.sequence);
|
|
return { route: { id: id(route.id), number: text(route.route_number), name: text(route.name) }, stops };
|
|
}
|
|
|
|
export function normalizeDepartures(raw, now = Date.now()) {
|
|
const departures = [];
|
|
const routes = [];
|
|
let partial = false;
|
|
for (const item of list(record(raw).routes)) {
|
|
const route = record(item);
|
|
const routeInfo = { id: id(route.id), number: text(route.route_number), name: text(route.name) };
|
|
routes.push(routeInfo);
|
|
if (route.error) { partial = true; continue; }
|
|
for (const value of list(route.arrivals)) {
|
|
const arrival = record(value);
|
|
const time = typeof arrival.scheduledTime === 'string' && /^(\d{1,2}):([0-5]\d)(?::[0-5]\d)?$/.exec(arrival.scheduledTime);
|
|
// RideBus ETA includes historical delay; this view explicitly shows the timetable.
|
|
const minutes = Number(arrival.minutesUntil) - Number(arrival.delay ?? 0);
|
|
if (!time || Number(time[1]) > 23 || arrival.minutesUntil == null || !Number.isFinite(minutes)) {
|
|
partial = true; continue;
|
|
}
|
|
if (minutes < 0 || minutes >= 1440) continue;
|
|
const scheduledAt = new Date(now + minutes * 60000).toISOString();
|
|
let serviceDate = new Date(Date.parse(scheduledAt) + 10800000).toISOString().slice(0, 10);
|
|
const originTime = typeof arrival.trip?.departureTime === 'string' && /^(\d{1,2}):([0-5]\d)/.exec(arrival.trip.departureTime);
|
|
if (originTime && Number(time[1]) * 60 + Number(time[2]) < Number(originTime[1]) * 60 + Number(originTime[2])) serviceDate = previousDate(serviceDate);
|
|
departures.push({
|
|
routeId: routeInfo.id, routeNumber: routeInfo.number, direction: routeInfo.name,
|
|
scheduledTime: `${time[1].padStart(2, '0')}:${time[2]}`,
|
|
// Use an absolute instant so old snapshots expire, including across midnight.
|
|
scheduledAt,
|
|
...(arrival.trip ? { tripKey: tripIdentity(arrival.trip, serviceDate) } : {}),
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
departures: departures.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)),
|
|
routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })),
|
|
updatedAt: new Date(now).toISOString(), partial,
|
|
};
|
|
}
|
|
|
|
export function createRideBusClient({
|
|
baseUrl = process.env.RIDEBUS_API_BASE_URL || 'https://api.ridebus.by/',
|
|
apiKey = process.env.RIDEBUS_API_KEY,
|
|
fetchData = fetch,
|
|
now = Date.now,
|
|
} = {}) {
|
|
const cache = new Map();
|
|
const pending = new Map();
|
|
const rawCache = new Map();
|
|
const rawPending = new Map();
|
|
const queue = [];
|
|
let active = 0;
|
|
async function upstream(path, cityId, allowMissing = false, allowRoutingError = false) {
|
|
if (!apiKey?.trim()) throw new ApiError(503, 'api_not_configured');
|
|
if (active >= 6) await new Promise(resolve => queue.push(resolve));
|
|
else active++;
|
|
const headers = { Accept: 'application/json', 'X-API-Key': apiKey };
|
|
if (cityId) headers['X-City-Id'] = String(cityId);
|
|
try {
|
|
const response = await fetchData(new URL(path, `${baseUrl.replace(/\/+$/, '')}/`), {
|
|
headers, redirect: 'error', signal: AbortSignal.timeout(12000),
|
|
});
|
|
if (allowMissing && response.status === 404) return null;
|
|
if (!response.ok && !(allowRoutingError && response.status === 400)) throw new ApiError(response.status === 404 ? 404 : 502);
|
|
return await response.json();
|
|
} catch (error) { throw error instanceof ApiError ? error : new ApiError(502); }
|
|
finally {
|
|
const next = queue.shift();
|
|
if (next) next(); else active--;
|
|
}
|
|
}
|
|
async function cachedUpstream(path, cityId, allowMissing = false) {
|
|
const key = `${cityId}:${path}`;
|
|
const entry = rawCache.get(key);
|
|
if (entry && entry.expires > now()) return entry.data;
|
|
if (rawPending.has(key)) return rawPending.get(key);
|
|
const request = upstream(path, cityId, allowMissing).then(data => {
|
|
if (rawCache.size >= 200) rawCache.delete(rawCache.keys().next().value);
|
|
rawCache.set(key, { data, expires: now() + 300000 });
|
|
return data;
|
|
}).finally(() => rawPending.delete(key));
|
|
rawPending.set(key, request);
|
|
return request;
|
|
}
|
|
async function timetable(input) {
|
|
const stop = record(await cachedUpstream(`api/v1/stops/${input.stopId}`, input.cityId));
|
|
const routes = list(stop.routes).map(route => ({ id: id(route.id), number: text(route.route_number), name: text(route.name) }));
|
|
const dates = [previousDate(input.date), input.date];
|
|
const results = await Promise.allSettled(routes.map(async route => {
|
|
const [stops, ...schedules] = await Promise.all([
|
|
cachedUpstream(`api/v1/routes/${route.id}/stops`, input.cityId),
|
|
...dates.map(date => cachedUpstream(`api/v1/routes/${route.id}/schedule/today?date=${date}`, input.cityId, true)),
|
|
]);
|
|
const days = schedules.map((schedule, index) => schedule
|
|
? routeDepartures(route, stops, schedule, input.stopId, dates[index]) : { departures: [], partial: false });
|
|
return { departures: days.flatMap(day => day.departures).filter(departure =>
|
|
new Date(Date.parse(departure.scheduledAt) + 10800000).toISOString().slice(0, 10) === input.date),
|
|
partial: days.some(day => day.partial), isHoliday: !!schedules[1]?.isHoliday,
|
|
holidayName: typeof schedules[1]?.holidayName === 'string' ? schedules[1].holidayName : null };
|
|
}));
|
|
if (routes.length && results.every(result => result.status === 'rejected')) throw new ApiError(502);
|
|
const days = results.filter(result => result.status === 'fulfilled').map(result => result.value);
|
|
const departures = days.flatMap(day => day.departures).map(({ trip, serviceDate, ...departure }) => departure);
|
|
return {
|
|
date: input.date, routes: routes.sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })),
|
|
departures: departures.sort((a, b) => Date.parse(a.scheduledAt) - Date.parse(b.scheduledAt)),
|
|
updatedAt: new Date(now()).toISOString(), partial: days.some(day => day.partial) || days.length < routes.length,
|
|
isHoliday: days.some(day => day.isHoliday), holidayName: days.find(day => day.holidayName)?.holidayName ?? null,
|
|
};
|
|
}
|
|
async function journey(input) {
|
|
const rawRoute = record(await cachedUpstream(`api/v1/routes/${input.routeId}`, input.cityId));
|
|
const route = { id: id(rawRoute.id), number: text(rawRoute.route_number), name: text(rawRoute.name) };
|
|
const stops = list(rawRoute.stops).map(stop => ({ ...stop, stop_id: stop.id }));
|
|
const date = new Date(Date.parse(input.at) + 10800000).toISOString().slice(0, 10);
|
|
const dates = [previousDate(date), date];
|
|
const schedules = await Promise.all(dates.map(value => cachedUpstream(`api/v1/routes/${input.routeId}/schedule/today?date=${value}`, input.cityId, true)));
|
|
const candidates = schedules.flatMap((schedule, index) => schedule ? routeDepartures(route, stops, schedule, input.stopId, dates[index]).departures : [])
|
|
.filter(item => Math.abs(Date.parse(item.scheduledAt) - Date.parse(input.at)) < 60000 && (!input.tripKey || item.tripKey === input.tripKey));
|
|
const unique = [...new Map(candidates.map(item => [item.tripKey, item])).values()];
|
|
if (!unique.length) throw new ApiError(404, 'journey_not_found');
|
|
if (unique.length > 1) throw new ApiError(409, 'journey_ambiguous');
|
|
const departure = unique[0];
|
|
return { route, stops: journeyStops(stops, departure), selectedStopId: input.stopId, note: departure.note,
|
|
updatedAt: new Date(now()).toISOString(), tripKey: departure.tripKey };
|
|
}
|
|
async function load(input) {
|
|
if (input.resource === 'routes') return normalizeRoutes(await cachedUpstream('api/v1/routes', input.cityId));
|
|
if (input.resource === 'routeStops') return normalizeRouteStops(await cachedUpstream(`api/v1/routes/${input.routeId}`, input.cityId));
|
|
if (input.resource === 'walk') {
|
|
const stop = record(await cachedUpstream(`api/v1/stops/${input.stopId}`, input.cityId));
|
|
const target = coordinates(stop.latitude, stop.longitude);
|
|
if (target.latitude === undefined) throw new ApiError(404, 'walking_not_found');
|
|
const points = `${input.lon.toFixed(6)},${input.lat.toFixed(6)};${target.longitude.toFixed(6)},${target.latitude.toFixed(6)}`;
|
|
return normalizeWalking(await upstream(`routing/foot/route/v1/foot/${points}?alternatives=false&steps=false&overview=full&geometries=geojson&skip_waypoints=true`, input.cityId, false, true));
|
|
}
|
|
if (input.resource === 'stop') return normalizeStop(await cachedUpstream(`api/v1/stops/${input.stopId}`, input.cityId));
|
|
if (input.resource === 'timetable') return timetable(input);
|
|
if (input.resource === 'journey') return journey(input);
|
|
if (input.resource === 'alerts') return normalizeAlerts(await upstream('api/v1/alerts/active', input.cityId), input.cityId);
|
|
if (input.resource === 'nearby') return normalizeNearby(await upstream(`api/v1/stops/nearby?${new URLSearchParams({ lat: String(input.lat), lon: String(input.lon), radius: String(input.radius) })}`, input.cityId));
|
|
const path = input.resource === 'cities' ? 'api/v1/cities'
|
|
: input.resource === 'stops' ? 'api/v1/stops' : `api/v1/eta/stop/${input.stopId}`;
|
|
const raw = await upstream(path, input.cityId);
|
|
return input.resource === 'cities' ? normalizeCities(raw)
|
|
: input.resource === 'stops' ? normalizeStops(raw) : normalizeDepartures(raw, now());
|
|
}
|
|
return {
|
|
async get(input) {
|
|
// Location searches are ephemeral: coordinates and results never enter caches.
|
|
if (['nearby', 'walk'].includes(input.resource)) return load(input);
|
|
const key = `${input.resource}:${input.cityId ?? ''}:${input.stopId ?? ''}:${input.date ?? ''}:${input.routeId ?? ''}:${input.at ?? ''}:${input.tripKey ?? ''}`;
|
|
const entry = cache.get(key);
|
|
if (entry && entry.expires > now()) return entry.data;
|
|
if (pending.has(key)) return pending.get(key);
|
|
const request = load(input).then(data => {
|
|
if (cache.size >= 200) cache.delete(cache.keys().next().value);
|
|
const ttl = input.resource === 'cities' ? 3600000 : ['stops', 'stop', 'routes', 'routeStops', 'timetable', 'journey'].includes(input.resource) ? 300000 : 30000;
|
|
cache.set(key, { data, expires: now() + ttl });
|
|
return data;
|
|
}).finally(() => pending.delete(key));
|
|
pending.set(key, request);
|
|
return request;
|
|
},
|
|
};
|
|
}
|