feat: add StopsMap and WalkingDirections components for enhanced map functionality

- 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.
This commit is contained in:
2026-10-08 00:18:10 +03:00
parent 1492f179eb
commit 6fe039cefa
43 changed files with 1518 additions and 59 deletions

10
server/coordinates.mjs Normal file
View File

@@ -0,0 +1,10 @@
// A missing/invalid pair must never become a marker at (0, 0).
export function coordinates(latitude, longitude) {
const number = (value, limit) => {
if ((typeof value !== 'number' && typeof value !== 'string') || String(value).trim() === '') return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) && Math.abs(parsed) <= limit ? parsed : undefined;
};
const lat = number(latitude, 90), lon = number(longitude, 180);
return lat === undefined || lon === undefined ? {} : { latitude: lat, longitude: lon };
}

View File

@@ -1,4 +1,5 @@
import { ApiError } from './errors.mjs';
import { coordinates } from './coordinates.mjs';
const routeNumber = value => typeof value === 'string' ? value.replace(/[\s№]/g, '').toLocaleUpperCase('ru') : '';
const positive = value => Number.isSafeInteger(Number(value)) && Number(value) > 0;
@@ -32,6 +33,6 @@ export function normalizeNearby(raw) {
const distance = Number(stop?.distance_meters);
if (!stop || !positive(stop.id) || typeof stop.name !== 'string' || !stop.name.trim() || stop.distance_meters == null || !Number.isFinite(distance) || distance < 0) throw new ApiError(502);
return { id: Number(stop.id), name: stop.name.trim(), direction: typeof stop.description === 'string' ? stop.description.trim() : '',
routesCount: Number(stop.routes_count) || 0, distance: Math.round(distance) };
routesCount: Number(stop.routes_count) || 0, distance: Math.round(distance), ...coordinates(stop.latitude, stop.longitude) };
}).sort((a, b) => a.distance - b.distance);
}

View File

@@ -1,6 +1,8 @@
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) {
@@ -13,15 +15,18 @@ function positiveId(value) {
export function parseRequest(params) {
const resource = params.get('resource');
if (resource === 'cities') return { resource };
if (!['stops', 'departures', 'timetable', 'alerts', 'nearby', 'journey'].includes(resource)) throw new ApiError(400, 'invalid_request');
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 === 'nearby') {
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 };
@@ -65,7 +70,8 @@ const id = value => {
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 : '' };
return { id: id(city.id), name: text(city.name), slug: typeof city.slug === 'string' ? city.slug : '',
...coordinates(city.center_lat, city.center_lon) };
});
}
@@ -76,10 +82,36 @@ export function normalizeStops(raw) {
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 = [];
@@ -130,7 +162,7 @@ export function createRideBusClient({
const rawPending = new Map();
const queue = [];
let active = 0;
async function upstream(path, cityId, allowMissing = false) {
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++;
@@ -141,7 +173,7 @@ export function createRideBusClient({
headers, redirect: 'error', signal: AbortSignal.timeout(12000),
});
if (allowMissing && response.status === 404) return null;
if (!response.ok) throw new ApiError(response.status === 404 ? 404 : 502);
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 {
@@ -205,6 +237,16 @@ export function createRideBusClient({
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);
@@ -218,14 +260,14 @@ export function createRideBusClient({
return {
async get(input) {
// Location searches are ephemeral: coordinates and results never enter caches.
if (input.resource === 'nearby') return load(input);
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', 'timetable', 'journey'].includes(input.resource) ? 300000 : 30000;
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));

17
server/walking.mjs Normal file
View File

@@ -0,0 +1,17 @@
import { ApiError } from './errors.mjs';
import { coordinates } from './coordinates.mjs';
export function normalizeWalking(raw) {
if (['NoRoute', 'NoSegment'].includes(raw?.code)) throw new ApiError(404, 'walking_not_found');
const route = raw?.routes?.[0];
if (raw?.code !== 'Ok' || !route || typeof route.distance !== 'number' || !Number.isFinite(route.distance) || route.distance < 0
|| typeof route.duration !== 'number' || !Number.isFinite(route.duration) || route.duration < 0
|| route.geometry?.type !== 'LineString' || !Array.isArray(route.geometry.coordinates)
|| route.geometry.coordinates.length < 2 || route.geometry.coordinates.length > 100000) throw new ApiError(502);
const points = route.geometry.coordinates.map(point => {
if (!Array.isArray(point) || point.length !== 2 || typeof point[0] !== 'number' || typeof point[1] !== 'number'
|| Object.keys(coordinates(point[1], point[0])).length !== 2) throw new ApiError(502);
return [point[0], point[1]];
});
return { distance: route.distance, duration: route.duration, geometry: { type: 'LineString', coordinates: points } };
}