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

View File

@@ -3,14 +3,48 @@
## Возможности ## Возможности
- Четыре раздела: «Главная», «Остановки», «Избранное», «Настройки». - Четыре раздела: «Главная», «Остановки», «Избранное», «Настройки».
На главной компактный выбор города и остановки; полный каталог открывается На главной компактный выбор города и остановки; окно выбора содержит
отдельно или в окне выбора. Общий список и поиск рядом переключаются, каталог. Общий список и поиск рядом переключаются,
чтобы не занимать экран двумя списками. На телефоне выбор остановки чтобы не занимать экран двумя списками. На телефоне выбор остановки
закрывает окно и плавно переводит к отправлениям, в том числе для выбранной даты. закрывает окно и плавно переводит к отправлениям, в том числе для выбранной даты.
- Знакомство при первом посещении: пять подсказок с подсветкой настоящих - Знакомство при первом посещении: пять подсказок с подсветкой настоящих
функций главной страницы. Можно пропустить, пройти с клавиатуры или повторить функций главной страницы. Можно пропустить, пройти с клавиатуры или повторить
из настроек. Завершение/пропуск сохраняются в этом браузере. из настроек. Завершение/пропуск сохраняются в этом браузере.
- Города из RideBus API, поиск остановок без повторяющихся названий и выбор направления чипами в карточке отправлений. - Города из RideBus API, поиск остановок без повторяющихся названий и выбор направления чипами в карточке отправлений.
- «Остановки» — самостоятельная карта и справочник, без второй карточки
отправлений. На карте отдельные точки посадки для разных направлений,
сгруппированные при отдалении; список показывает каждое название один раз.
Поиск синхронизирует список и точки. Карточка остановки содержит направления,
действующие маршруты, добавление в избранное и построение пешего пути внутри приложения.
«Открыть расписание» переводит на Главную с выбранной точкой посадки,
сохраняет дату просмотра и прокручивает к отправлениям.
В режиме списка на десктопе карточка занимает всю правую область.
На телефоне карточка открывается диалогом по нажатию, в обоих режимах.
Диалог закрывается кнопкой, нажатием вне окна или Escape и возвращает фокус;
список не удлиняется из-за карточки. При входе в раздел мобильный диалог
не открывается автоматически из-за ранее выбранной остановки.
Кнопка геолокации в поиске показывает остановки в радиусе 1 км;
расстояния рассчитываются по прямой, координаты пользователя остаются в памяти.
Без сети доступен список и ранее просмотренные карточки остановок.
MapLibre загружается только при открытии карты; библиотека и её worker не входят
в предзагрузку офлайн-оболочки. Светлая/тёмная подложки
[OpenFreeMap](https://openfreemap.org/quick_start/) следуют теме приложения,
требуют подключения и не нуждаются в отдельном ключе. Атрибуция источников сохранена.
- Фильтр по маршруту синхронизирует список и карту. Одинаковые номера
объединяются, направления выбираются отдельно; при выборе одного направления
остановки идут в порядке движения. Названия не повторяются, точки посадки
и их идентификаторы сохраняются. Фильтр остаётся в URL (`line`, `direction`)
после перезагрузки; просмотренные направления доступны без сети.
Поиск по названию и рядом работает вместе с фильтром. При переходе к расписанию
выбранный маршрут сохраняется. Частично недоступные направления обозначаются,
не заменяются полным списком города. Смена города сбрасывает фильтр.
- «Как добраться пешком» запрашивает местоположение по нажатию и использует
RideBus OSRM `/routing/foot/route/v1/foot/…` с геометрией GeoJSON.
Путь, расстояние и приблизительное время показываются в отдельном диалоге.
При отказе в геолокации или ошибке доступны повтор и внешние карты.
В демонстрации построение пути отключено, чтобы примеры не выдавать за реальный маршрут.
Начальная точка и геометрия пешего пути остаются в памяти, не сохраняются
в IndexedDB/localStorage и серверном кеше приложения.
- Ближайшие отправления и полное расписание остановки на выбранную дату, - Ближайшие отправления и полное расписание остановки на выбранную дату,
переключение направлений и фильтр по маршруту. В компактной карточке переключение направлений и фильтр по маршруту. В компактной карточке
название и закладка находятся рядом, кнопка «+» сохраняет поездку, название и закладка находятся рядом, кнопка «+» сохраняет поездку,
@@ -214,6 +248,10 @@ docker compose down
- `src/components/TripCarousel.tsx` — адаптивная карусель с кнопками, свайпами и клавиатурой. - `src/components/TripCarousel.tsx` — адаптивная карусель с кнопками, свайпами и клавиатурой.
- `src/components/ShareDeparture.tsx` — отправка/копирование ссылки на конкретное отправление. - `src/components/ShareDeparture.tsx` — отправка/копирование ссылки на конкретное отправление.
- `src/components/StopList.tsx` — общие строки и контейнер для обоих списков остановок. - `src/components/StopList.tsx` — общие строки и контейнер для обоих списков остановок.
- `src/components/StopsExplorer.tsx`, `StopsMap.tsx`, `src/_stops.scss` — карта,
каталог и карточка остановки; `src/lib/geo.ts` — расстояния и пешие ссылки.
- `src/lib/useStopRoutes.ts`, `routeGroups.ts` — номера/направления маршрутов и их остановки.
- `src/components/WalkingDirections.tsx`, `server/walking.mjs` — пеший путь и проверка геометрии OSRM.
- `src/components/Settings.tsx`, `src/lib/theme.ts` — выбор темы и её сохранение. - `src/components/Settings.tsx`, `src/lib/theme.ts` — выбор темы и её сохранение.
- `src/components/FeatureDiscovery.tsx`, `src/_discovery.scss` — подсказки первого входа. - `src/components/FeatureDiscovery.tsx`, `src/_discovery.scss` — подсказки первого входа.
- `src/_navigation.scss`, `src/_departures.scss` — разделы, компактный выбор и отправления. - `src/_navigation.scss`, `src/_departures.scss` — разделы, компактный выбор и отправления.
@@ -226,7 +264,16 @@ docker compose down
- `server/features.mjs` — нормализация сообщений и ближайших остановок. - `server/features.mjs` — нормализация сообщений и ближайших остановок.
Прокси использует `GET /api/v1/cities`, `GET /api/v1/stops` и Прокси использует `GET /api/v1/cities`, `GET /api/v1/stops` и
`GET /api/v1/eta/stop/:id`. Для полного расписания используются `GET /api/v1/eta/stop/:id`. Карточка остановки получает направления/маршруты через
`/api/data?resource=stop&cityId=…&stopId=…` → `GET /api/v1/stops/:id`;
координаты точек посадки и центра города нормализуются из исходных ответов.
Отсутствующие или неверные координаты не заменяются нулями: такие остановки
доступны в списке. Для фильтра используются `resource=routes` → `GET /api/v1/routes`
и `resource=routeStops&routeId=…` → `GET /api/v1/routes/:id`.
`resource=walk&stopId=…&lat=…&lon=…` берёт координаты назначения из остановки
и запрашивает `/routing/foot/route/v1/foot/lon,lat;lon,lat` без кеширования начала/пути.
Формат проверен по [документации OSRM](https://project-osrm.org/docs/v5.24.0/api/#route-service)
и живому RideBus. Для полного расписания используются
`GET /api/v1/stops/:id`, `GET /api/v1/routes/:id/stops` и `GET /api/v1/stops/:id`, `GET /api/v1/routes/:id/stops` и
`GET /api/v1/routes/:id/schedule/today?date=YYYY-MM-DD`. `GET /api/v1/routes/:id/schedule/today?date=YYYY-MM-DD`.
Экран рейса дополнительно использует `GET /api/v1/routes/:id`; Экран рейса дополнительно использует `GET /api/v1/routes/:id`;
@@ -263,6 +310,12 @@ Playwright проверяет desktop и экран iPhone 13 в Chromium: по
выбор/сохранение темы, системные изменения, синхронизацию вкладок, выбор/сохранение темы, системные изменения, синхронизацию вкладок,
первое отображение до запуска JavaScript-приложения и настройки без сети, первое отображение до запуска JavaScript-приложения и настройки без сети,
четыре раздела, компактность выбора на ширинах 320–1440 px, четыре раздела, компактность выбора на ширинах 320–1440 px,
выбор точки на карте, отдельные направления карточки остановки,
пешую ссылку, сохранение выбранной стороны в избранном,
полную ширину карточки в списке, мобильный диалог, номера/направления и порядок
остановок маршрута, сохранение фильтра без сети, частичный отказ направления,
пешую геометрию/метрики, отказ в разрешении и отсутствие сохранённого начала/пути,
список/просмотренную карточку остановки без сети и темы карты,
выбор остановки с прокруткой и уменьшением движения, переход на нужную дату, выбор остановки с прокруткой и уменьшением движения, переход на нужную дату,
один статус при отключении сети и предупреждение после реального старения данных, один статус при отключении сети и предупреждение после реального старения данных,
первый вход с подсветкой, повторное обучение и подсказки на экране 320 × 568, первый вход с подсветкой, повторное обучение и подсказки на экране 320 × 568,

View File

@@ -5,9 +5,10 @@ test('stop search, route filter and city-scoped favorites survive reload', async
page.on('pageerror', error => errors.push(error.message)); page.on('pageerror', error => errors.push(error.message));
await page.goto('/stops?demo=1'); await page.goto('/stops?demo=1');
await expect(page.getByRole('combobox', { name: 'Ваш город' })).toHaveValue('1'); await expect(page.getByRole('combobox', { name: 'Ваш город' })).toHaveValue('1');
await page.getByRole('searchbox', { name: 'Ваша остановка' }).fill('Площадь'); await page.getByRole('searchbox', { name: 'Поиск остановки' }).fill('Площадь');
await expect(page.locator('.stopItem')).toHaveCount(1); await expect(page.locator('.stopItem')).toHaveCount(1);
await page.locator('.stopItem').first().click(); await page.locator('.stopItem').first().click();
await page.getByRole('button', { name: 'Открыть расписание', exact: true }).click();
await expect(page.locator('.departureRow')).toHaveCount(8); await expect(page.locator('.departureRow')).toHaveCount(8);
const directions = page.getByRole('group', { name: 'Направления', exact: true }); const directions = page.getByRole('group', { name: 'Направления', exact: true });
await expect(directions.getByRole('button')).toHaveCount(2); await expect(directions.getByRole('button')).toHaveCount(2);
@@ -56,13 +57,16 @@ test('stop search, route filter and city-scoped favorites survive reload', async
test('every grouped stop is visible without an eighty-stop cap', async ({ page }) => { test('every grouped stop is visible without an eighty-stop cap', async ({ page }) => {
await page.route('**/api/data?**', route => { await page.route('**/api/data?**', route => {
const resource = new URL(route.request().url()).searchParams.get('resource'); const resource = new URL(route.request().url()).searchParams.get('resource');
const data = resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk' }] const data = resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk' }] : resource === 'routes' ? []
: Array.from({ length: 115 }, (_, i) => ({ id: i + 1, name: `Остановка ${String(i + 1).padStart(3, '0')}`, direction: '', routesCount: 1 })); : Array.from({ length: 115 }, (_, i) => ({ id: i + 1, name: `Остановка ${String(i + 1).padStart(3, '0')}`, direction: '', routesCount: 1 }));
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }); return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
}); });
await page.goto('/stops'); await page.goto('/stops');
await page.getByRole('button', { name: 'Список', exact: true }).click();
await expect(page.locator('.stopItem')).toHaveCount(115); await expect(page.locator('.stopItem')).toHaveCount(115);
await expect(page.locator('.stopsFooter')).toContainText('115 остановок'); await expect(page.locator('.explorerListHeading')).toContainText('115 остановок');
await expect(page.locator('.maplibregl-canvas')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Карта', exact: true })).toBeDisabled();
await page.locator('.stopItem').last().scrollIntoViewIfNeeded(); await page.locator('.stopItem').last().scrollIntoViewIfNeeded();
await expect(page.locator('.stopItem').last()).toBeVisible(); await expect(page.locator('.stopItem').last()).toBeVisible();
await expect(page.locator('main')).not.toContainText('Показаны первые'); await expect(page.locator('main')).not.toContainText('Показаны первые');

View File

@@ -8,7 +8,7 @@ async function mockLive(page: Page, extra: (resource: string, params: URLSearchP
const params = new URL(route.request().url()).searchParams; const params = new URL(route.request().url()).searchParams;
const resource = params.get('resource')!; const resource = params.get('resource')!;
const data = extra(resource, params) ?? (resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk' }, { id: 2, name: 'Новополоцк', slug: 'novopolotsk' }] const data = extra(resource, params) ?? (resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk' }, { id: 2, name: 'Новополоцк', slug: 'novopolotsk' }]
: resource === 'stops' ? stops : resource === 'alerts' ? [] : { routes, partial: false, updatedAt: new Date(time).toISOString(), departures: routes.map((item, index) => ({ routeId: item.id, routeNumber: item.number, direction: item.name, scheduledTime: `12:${10 + index * 10}`, scheduledAt: new Date(time + (10 + index * 10) * 60000).toISOString() })) }); : resource === 'stops' ? stops : resource === 'routes' ? routes : resource === 'alerts' ? [] : { routes, partial: false, updatedAt: new Date(time).toISOString(), departures: routes.map((item, index) => ({ routeId: item.id, routeNumber: item.number, direction: item.name, scheduledTime: `12:${10 + index * 10}`, scheduledAt: new Date(time + (10 + index * 10) * 60000).toISOString() })) });
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }); return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
}); });
} }
@@ -119,7 +119,8 @@ test('nearby search requests location only on click, groups stops, and never per
searches++; expect(params.get('lat')).toBe('55.49000'); expect(params.get('lon')).toBe('28.78300'); searches++; expect(params.get('lat')).toBe('55.49000'); expect(params.get('lon')).toBe('28.78300');
return [{ ...stops[0], distance: 120 }, { ...stops[1], distance: 75 }, { ...stops[2], distance: 250 }]; return [{ ...stops[0], distance: 120 }, { ...stops[1], distance: 75 }, { ...stops[2], distance: 250 }];
}); });
await page.goto('/stops?city=1'); await page.goto('/?city=1');
await page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click();
await expect(page.locator('.stopItem')).toHaveCount(2); await expect(page.locator('.stopItem')).toHaveCount(2);
expect(searches).toBe(0); expect(searches).toBe(0);
await page.getByRole('button', { name: 'Рядом', exact: true }).click(); await page.getByRole('button', { name: 'Рядом', exact: true }).click();
@@ -138,8 +139,8 @@ test('nearby search requests location only on click, groups stops, and never per
database.close(); return JSON.stringify({ keys: result, preferences: { ...localStorage } }); database.close(); return JSON.stringify({ keys: result, preferences: { ...localStorage } });
}); });
expect(persisted).not.toContain('nearby'); expect(persisted).not.toContain('55.49'); expect(persisted).not.toContain('28.783'); expect(persisted).not.toContain('nearby'); expect(persisted).not.toContain('55.49'); expect(persisted).not.toContain('28.783');
await page.getByRole('button', { name: 'Все остановки', exact: true }).click(); await page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click();
await page.getByRole('combobox', { name: 'Ваш город' }).selectOption('2'); await page.getByRole('dialog', { name: 'Выбрать остановку', exact: true }).getByRole('combobox', { name: 'Ваш город' }).selectOption('2');
await expect(page.locator('.nearbyResults')).toHaveCount(0); await expect(page.locator('.nearbyResults')).toHaveCount(0);
}); });

View File

@@ -182,7 +182,8 @@ test('blocked native sharing and clipboard offer a selectable manual link', asyn
test('nearby and all stops share rows, selection, typography and scrollbar styles', async ({ page, context }, info) => { test('nearby and all stops share rows, selection, typography and scrollbar styles', async ({ page, context }, info) => {
await context.grantPermissions(['geolocation']); await context.setGeolocation({ latitude: 55.49, longitude: 28.783 }); await context.grantPermissions(['geolocation']); await context.setGeolocation({ latitude: 55.49, longitude: 28.783 });
await page.goto('/stops?demo=1&city=1&stop=101'); await page.goto('/?demo=1&city=1&stop=101');
await page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click();
const all = page.locator('.stopsList[aria-label="Остановки"]'); const all = page.locator('.stopsList[aria-label="Остановки"]');
await expect(all.locator('.stopItem')).toHaveCount(5); await expect(all.locator('.stopItem')).toHaveCount(5);
const styles = (element: HTMLElement) => { const styles = (element: HTMLElement) => {
@@ -191,13 +192,13 @@ test('nearby and all stops share rows, selection, typography and scrollbar style
}; };
const normalStyles = await all.evaluate(styles); const normalStyles = await all.evaluate(styles);
await page.getByRole('button', { name: 'Рядом', exact: true }).click(); await page.getByRole('button', { name: 'Рядом', exact: true }).click();
const nearby = page.getByLabel('Остановки рядом', { exact: true }); const nearby = page.locator('.stopsList[aria-label="Остановки рядом"]');
await expect(nearby.locator('.stopItem')).toHaveCount(3); await expect(nearby.locator('.stopItem')).toHaveCount(3);
await expect(nearby.locator('.activeStop')).toHaveText('Площадь Свободы120 м'); await expect(nearby.locator('.activeStop')).toHaveText('Площадь Свободы120 м');
expect(await nearby.evaluate(styles)).toEqual(normalStyles); expect(await nearby.evaluate(styles)).toEqual(normalStyles);
await nearby.getByRole('button', { name: /Больница/ }).click(); await nearby.getByRole('button', { name: /Больница/ }).click();
await expect(nearby.locator('.activeStop')).toContainText('Больница'); await expect(page.locator('#board-title')).toHaveText('Больница');
await page.getByRole('button', { name: 'Все остановки', exact: true }).click(); await page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click();
await expect(all.locator('.activeStop')).toHaveText('Больница'); await expect(all.locator('.activeStop')).toHaveText('Больница');
await page.locator('.stopsCard').screenshot({ path: `test-results/unified-stops-${info.project.name}.png`, animations: 'disabled' }); await page.locator('.appDialog').screenshot({ path: `test-results/unified-stops-${info.project.name}.png`, animations: 'disabled' });
}); });

View File

@@ -61,13 +61,16 @@ test('choosing a stop from the home dialog closes it and scrolls to departures o
await expect.poll(() => page.evaluate(() => (window as any).stopScrolls.at(-1)?.behavior)).toBe('auto'); await expect.poll(() => page.evaluate(() => (window as any).stopScrolls.at(-1)?.behavior)).toBe('auto');
}); });
test('catalog selection scrolls on a phone while retaining the current date', async ({ page }) => { test('catalog opens a stop card and its schedule on the home page while retaining the date', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 780 }); await page.setViewportSize({ width: 320, height: 780 });
await page.goto('/stops?demo=1&city=1&view=day&date=2026-10-08'); await page.goto('/stops?demo=1&city=1&view=day&date=2026-10-08');
await page.getByRole('searchbox').fill('Площадь'); await page.getByRole('searchbox').fill('Площадь');
await page.locator('.stopItem').click(); await page.locator('.stopItem').click();
await expect(page.locator('.stopPassport')).toBeFocused();
await expect(page.locator('#departures')).toHaveCount(0);
await page.getByRole('button', { name: 'Открыть расписание', exact: true }).click();
await expect(page.locator('#departures')).toBeFocused(); await expect(page.locator('#departures')).toBeFocused();
await expect(page).toHaveURL(/\/stops\?.*stop=101.*date=2026-10-08/); await expect(page).toHaveURL(/\/\?.*stop=101.*date=2026-10-08/);
await expect(page.getByLabel('Дата поездки', { exact: true })).toHaveValue('2026-10-08'); await expect(page.getByLabel('Дата поездки', { exact: true })).toHaveValue('2026-10-08');
await expect(page.locator('.hourDeparture')).toHaveCount(32); await expect(page.locator('.hourDeparture')).toHaveCount(32);
await expect.poll(() => page.locator('#board-title').evaluate(element => element.getBoundingClientRect().top)).toBeLessThan(180); await expect.poll(() => page.locator('#board-title').evaluate(element => element.getBoundingClientRect().top)).toBeLessThan(180);

175
e2e/routing.spec.ts Normal file
View File

@@ -0,0 +1,175 @@
import { test, expect, type Page } from './fixtures';
const stops = [
{ id: 1, name: 'Вокзал', direction: 'В центр', routesCount: 1, latitude: 55.486, longitude: 28.783 },
{ id: 2, name: 'Вокзал', direction: 'В район', routesCount: 2, latitude: 55.4861, longitude: 28.7831 },
{ id: 3, name: 'Центр', direction: 'На площадь', routesCount: 2, latitude: 55.4869, longitude: 28.7856 },
{ id: 4, name: 'Район', direction: 'К вокзалу', routesCount: 1, latitude: 55.495, longitude: 28.789 },
];
const routes = [{ id: 10, number: '4', name: 'Вокзал → Центр' }, { id: 11, number: '4', name: 'Район → Вокзал' }, { id: 24, number: '112С', name: 'Вокзал → Площадь' }];
const membership: Record<number, number[]> = { 10: [1, 3], 11: [4, 2], 24: [2, 3] };
async function mockTransit(page: Page, extra: (resource: string, params: URLSearchParams) => { status?: number; data: unknown } | undefined = () => undefined) {
await page.route('https://tiles.openfreemap.org/**', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ version: 8, sources: {}, layers: [{ id: 'base', type: 'background', paint: { 'background-color': '#f3f3fa' } }] }) }));
await page.route('**/api/data?**', route => {
const params = new URL(route.request().url()).searchParams, resource = params.get('resource')!;
const custom = extra(resource, params);
if (custom) return route.fulfill({ status: custom.status ?? 200, contentType: 'application/json', body: JSON.stringify(custom.data) });
const stopId = Number(params.get('stopId')), routeId = Number(params.get('routeId'));
const serving = routes.filter(route => membership[route.id]?.includes(stopId));
const now = Date.now();
const data = resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk', latitude: 55.486, longitude: 28.783 }, { id: 2, name: 'Другой город', slug: 'other' }]
: resource === 'stops' ? stops : resource === 'routes' ? routes : resource === 'routeStops' ? { route: routes.find(route => route.id === routeId), stops: membership[routeId].map((id, index) => ({ ...stops.find(stop => stop.id === id), sequence: index + 1 })) }
: resource === 'stop' ? { ...stops.find(stop => stop.id === stopId), routes: serving } : resource === 'alerts' ? []
: resource === 'departures' ? { routes: serving, partial: false, updatedAt: new Date(now).toISOString(), departures: serving.map((route, index) => ({ routeId: route.id, routeNumber: route.number, direction: route.name, scheduledTime: '12:30', scheduledAt: new Date(now + (index + 5) * 60000).toISOString() })) } : {};
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
});
}
test('desktop list card fills the detail pane and mobile stop cards are dialogs', async ({ page }) => {
await mockTransit(page);
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto('/stops?city=1');
await page.getByRole('button', { name: 'Список', exact: true }).click();
await page.locator('.stopItem').filter({ hasText: 'Вокзал' }).click();
const pane = (await page.locator('.explorerMapArea').boundingBox())!, card = (await page.locator('.stopPassport').boundingBox())!;
expect(Math.abs(pane.width - card.width)).toBeLessThan(2); expect(Math.abs(pane.height - card.height)).toBeLessThan(2);
await page.setViewportSize({ width: 320, height: 568 });
const dialog = page.getByRole('dialog', { name: 'Вокзал', exact: true });
await expect(dialog).toBeVisible();
const bounds = (await dialog.boundingBox())!;
expect(bounds.x).toBeGreaterThanOrEqual(0); expect(bounds.y).toBeGreaterThanOrEqual(0); expect(bounds.y + bounds.height).toBeLessThanOrEqual(568);
await expect(page.locator('.explorerBody .stopPassport')).toHaveCount(0);
await expect(page.locator('body')).toHaveCSS('overflow', 'hidden');
await page.keyboard.press('Escape'); await expect(dialog).toHaveCount(0);
await page.locator('.stopItem').filter({ hasText: 'Вокзал' }).click();
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Закрыть карточку остановки' }).click();
await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden');
});
test('route number groups directions, filters exact platforms and retains the filter on reload and schedule navigation', async ({ page }) => {
await mockTransit(page);
await page.goto('/stops?city=1');
const route = page.getByRole('combobox', { name: 'Маршрут', exact: true });
await expect(route.locator('option')).toHaveCount(3);
await route.selectOption('4');
await expect(page.locator('.stopItem')).toHaveCount(3);
await page.getByRole('combobox', { name: 'Направление маршрута' }).selectOption('10');
await expect(page.locator('.stopItem')).toHaveCount(2);
await expect(page.locator('.stopItem').first()).toContainText('Вокзал');
await expect(page.locator('.stopItem').last()).toContainText('Центр');
await expect(page).toHaveURL(/line=4.*direction=10/);
await page.reload();
await expect(route).toHaveValue('4');
await expect(page.getByRole('combobox', { name: 'Направление маршрута' })).toHaveValue('10');
await expect(page.locator('.stopItem')).toHaveCount(2);
await page.getByRole('button', { name: 'Список', exact: true }).click();
await page.locator('.stopItem').filter({ hasText: 'Вокзал' }).click();
await expect(page).toHaveURL(/stop=1.*line=4.*direction=10/);
await expect(page.locator('.passportDirections')).toHaveCount(0);
await page.getByRole('button', { name: 'Открыть расписание', exact: true }).click();
await expect(page).toHaveURL(/\/\?city=1&stop=1&route=10/);
await expect(page.getByRole('button', { name: 'Маршрут 4', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#departures')).toBeFocused();
await expect(page.locator('.departureRow')).toHaveCount(1);
});
test('visited route filters survive offline and changing cities resets the filter', async ({ page, context }) => {
await mockTransit(page);
await page.goto('/stops?city=1&line=4&direction=11');
await expect(page.locator('.stopItem')).toHaveCount(2);
await expect(page.locator('.stopItem').first()).toContainText('Район');
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) await new Promise<void>(resolve => navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }));
});
await context.setOffline(true); await page.reload();
await expect(page.locator('.stopItem')).toHaveCount(2);
await expect(page.locator('.offlineBanner')).toHaveCount(1);
await expect(page.getByRole('combobox', { name: 'Направление маршрута' })).toHaveValue('11');
await context.setOffline(false);
await page.getByRole('combobox', { name: 'Ваш город' }).selectOption('2');
await expect(page.getByRole('combobox', { name: 'Маршрут', exact: true })).toHaveValue('');
await expect(page.getByRole('combobox', { name: 'Направление маршрута' })).toHaveCount(0);
await expect(page.locator('.stopItem')).toHaveCount(3);
});
test('a failed route direction is marked as partial and never replaced with every city stop', async ({ page }) => {
await mockTransit(page, (resource, params) => resource === 'routeStops' && params.get('routeId') === '11' ? { status: 502, data: { error: 'data_unavailable' } } : undefined);
await page.goto('/stops?city=1&line=4');
await expect(page.locator('.routeFilterError')).toContainText('Часть направлений недоступна');
await expect(page.locator('.stopItem')).toHaveCount(2);
await expect(page.locator('.stopItem').filter({ hasText: 'Район' })).toHaveCount(0);
await page.getByRole('combobox', { name: 'Направление маршрута' }).selectOption('11');
await expect(page.locator('.listMessage')).toContainText('Остановки маршрута недоступны');
await page.getByRole('button', { name: 'Сбросить фильтр маршрута' }).click();
await expect(page.locator('.stopItem')).toHaveCount(3);
});
test('walking uses RideBus foot routing only after a click and never saves origin or path', async ({ page, context }) => {
const origin = { latitude: 55.487123, longitude: 28.782321, accuracy: 20 };
await context.grantPermissions(['geolocation']); await context.setGeolocation(origin);
const walks: URLSearchParams[] = [], errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await mockTransit(page, (resource, params) => {
if (resource !== 'walk') return;
walks.push(params);
return { data: { distance: 230, duration: 165.4, geometry: { type: 'LineString', coordinates: [[origin.longitude, origin.latitude], [28.7835, 55.4863], [28.7856, 55.4869]] } } };
});
await page.goto('/stops?city=1');
await page.getByRole('button', { name: 'Список', exact: true }).click();
await page.locator('.stopItem').filter({ hasText: 'Центр' }).click();
expect(walks).toHaveLength(0);
await page.getByRole('button', { name: 'Как добраться пешком', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Пешком к остановке' });
await expect(dialog.locator('.walkingSummary')).toContainText('≈ 3 мин');
await expect(dialog.locator('.walkingSummary')).toContainText('230 м');
await expect(dialog.locator('.stopsMapCanvas')).toHaveAttribute('data-ready', 'true');
expect(walks).toHaveLength(1); expect(walks[0].get('stopId')).toBe('3'); expect(walks[0].get('lat')).toBe('55.487123'); expect(walks[0].get('lon')).toBe('28.782321');
const persisted = await page.evaluate(async () => {
const request = indexedDB.open('ridebus-pwa');
const database = await new Promise<IDBDatabase>(resolve => { request.onsuccess = () => resolve(request.result); });
const values = database.transaction('snapshots').objectStore('snapshots').getAll();
const snapshots = await new Promise(resolve => { values.onsuccess = () => resolve(values.result); });
database.close(); return JSON.stringify({ snapshots, preferences: { ...localStorage } });
});
expect(persisted).not.toContain('55.487123'); expect(persisted).not.toContain('28.782321'); expect(persisted).not.toContain('"geometry"');
expect(errors).toEqual([]);
await page.keyboard.press('Escape'); await expect(dialog).toHaveCount(0);
await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden');
});
test('walking permission denial offers retry and a map fallback without requesting a path', async ({ page }) => {
await page.addInitScript(() => Object.defineProperty(navigator, 'geolocation', { value: { getCurrentPosition: (_success: unknown, failure: (error: { code: number }) => void) => failure({ code: 1 }) } }));
let walks = 0;
await mockTransit(page, resource => { if (resource === 'walk') walks++; return undefined; });
await page.goto('/stops?city=1'); await page.getByRole('button', { name: 'Список', exact: true }).click();
await page.locator('.stopItem').filter({ hasText: 'Центр' }).click();
await page.getByRole('button', { name: 'Как добраться пешком', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Пешком к остановке' });
await expect(dialog).toContainText('Доступ к местоположению закрыт');
const link = new URL((await dialog.getByRole('link', { name: 'Открыть в картах' }).getAttribute('href'))!);
expect(link.searchParams.get('destination')).toBe('55.4869,28.7856'); expect(walks).toBe(0);
await dialog.getByRole('button', { name: 'Повторить', exact: true }).click();
await expect(dialog).toContainText('Доступ к местоположению закрыт'); expect(walks).toBe(0);
});
test('walking route failure stays explicit and retry can show a recovered path', async ({ page, context }) => {
await context.grantPermissions(['geolocation']); await context.setGeolocation({ latitude: 55.486, longitude: 28.783 });
let attempts = 0;
await mockTransit(page, resource => {
if (resource !== 'walk') return;
return ++attempts === 1 ? { status: 404, data: { error: 'walking_not_found' } }
: { data: { distance: 230, duration: 165.4, geometry: { type: 'LineString', coordinates: [[28.783, 55.486], [28.7856, 55.4869]] } } };
});
await page.goto('/stops?city=1'); await page.getByRole('button', { name: 'Список', exact: true }).click();
await page.locator('.stopItem').filter({ hasText: 'Центр' }).click();
await page.getByRole('button', { name: 'Как добраться пешком', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Пешком к остановке' });
await expect(dialog).toContainText('Не удалось найти пеший путь');
await expect(dialog.locator('.walkingMap')).toHaveCount(0);
await dialog.getByRole('button', { name: 'Повторить', exact: true }).click();
await expect(dialog.locator('.walkingSummary')).toContainText('≈ 3 мин'); expect(attempts).toBe(2);
await dialog.getByRole('button', { name: 'Закрыть окно', exact: true }).click();
await expect(page.locator('.walkingMap')).toHaveCount(0);
});

148
e2e/stops.spec.ts Normal file
View File

@@ -0,0 +1,148 @@
import { test, expect, type Page } from './fixtures';
async function localBasemap(page: Page) {
await page.route('https://tiles.openfreemap.org/**', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ version: 8, sources: {}, layers: [{ id: 'base', type: 'background', paint: { 'background-color': '#f3f3fa' } }] }) }));
}
test('stop card distinguishes platforms, favorites the selected direction and opens its schedule', async ({ page }) => {
await localBasemap(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/stops?demo=1&city=1');
await expect(page.locator('#departures')).toHaveCount(0);
await page.getByRole('searchbox', { name: 'Поиск остановки' }).fill('Площадь');
await expect(page.locator('.stopItem')).toHaveCount(1);
await page.locator('.stopItem').click();
await expect(page.locator('#stop-passport-title')).toHaveText('Площадь Свободы');
const directions = page.getByRole('group', { name: 'Направления остановки' });
await expect(directions.getByRole('button')).toHaveCount(2);
await directions.getByRole('button', { name: 'В сторону центра', exact: true }).click();
await expect(page).toHaveURL(/stop=102/);
await expect(page.locator('.passportRoute')).toHaveCount(3);
await expect(page.getByRole('button', { name: 'Как добраться пешком', exact: true })).toBeDisabled();
await page.getByRole('button', { name: 'Добавить остановку в избранное', exact: true }).click();
await expect(page.getByRole('button', { name: 'Удалить остановку из избранного', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: 'Открыть расписание', exact: true }).click();
await expect.poll(() => new URL(page.url()).pathname).toBe('/');
await expect(page.locator('#departures')).toBeFocused();
await expect(page.locator('.departureRow')).toHaveCount(8);
await expect(page.getByRole('group', { name: 'Направления', exact: true }).getByRole('button', { name: 'В сторону центра', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: 'Убрать остановку из избранного', exact: true })).toBeVisible();
});
test('map points are clickable and the map never requests departures before opening a schedule', async ({ page }) => {
await localBasemap(page);
const errors: string[] = [], resources: string[] = [];
page.on('pageerror', error => errors.push(error.message));
const stop = { id: 91, name: 'Место посадки', direction: 'В центр', routesCount: 1, latitude: 55.49, longitude: 28.78 };
await page.route('**/api/data?**', route => {
const resource = new URL(route.request().url()).searchParams.get('resource')!; resources.push(resource);
const data = resource === 'cities' ? [{ id: 1, name: 'Полоцк', slug: 'polotsk', latitude: 55.49, longitude: 28.78 }]
: resource === 'stops' ? [stop] : resource === 'routes' ? [] : { ...stop, routes: [{ id: 1, number: '112С', name: 'Вокзал → Центр' }] };
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
});
await page.goto('/stops?city=1');
await expect(page.locator('.stopsMapCanvas')).toHaveAttribute('data-ready', 'true');
await expect(page.locator('.explorerMapMode .explorerCatalog')).toHaveCount(0);
const canvas = page.locator('.maplibregl-canvas');
const bounds = (await canvas.boundingBox())!;
expect(bounds.height).toBeGreaterThan(350);
await canvas.click({ position: { x: bounds.width / 2, y: bounds.height / 2 } });
await expect(page.locator('#stop-passport-title')).toHaveText('Место посадки');
await expect(page.locator('.passportRoute')).toHaveText('112С');
expect(resources).toContain('stop'); expect(resources).not.toContain('departures'); expect(resources).not.toContain('timetable');
expect(errors).toEqual([]);
});
test('nearby discovery uses a single list, chooses the nearest platform and keeps location ephemeral', async ({ page, context }) => {
await localBasemap(page);
await context.grantPermissions(['geolocation']);
await context.setGeolocation({ latitude: 55.48495, longitude: 28.7686, accuracy: 20 });
await page.addInitScript(() => {
const original = navigator.geolocation.getCurrentPosition.bind(navigator.geolocation);
(window as any).locationRequests = 0;
navigator.geolocation.getCurrentPosition = (...args) => { (window as any).locationRequests++; return original(...args); };
});
await page.goto('/stops?demo=1&city=1');
await page.getByRole('button', { name: 'Список', exact: true }).click();
await expect(page.locator('.stopItem')).toHaveCount(5);
expect(await page.evaluate(() => (window as any).locationRequests)).toBe(0);
await page.getByRole('button', { name: 'Рядом со мной', exact: true }).click();
await expect(page.locator('.explorerHint')).toContainText('расстояния по прямой');
await expect(page.locator('.stopItem').first()).toContainText('Площадь Свободы');
await expect(page.locator('.stopItem').first()).toContainText('м');
await page.locator('.stopItem').first().click();
await expect(page).toHaveURL(/stop=101/);
expect(await page.evaluate(() => (window as any).locationRequests)).toBe(1);
const persisted = await page.evaluate(() => JSON.stringify({ ...localStorage }));
expect(persisted).not.toContain('55.48495'); expect(persisted).not.toContain('28.7686');
await page.getByRole('button', { name: 'Закрыть карточку остановки', exact: true }).click();
await page.getByRole('button', { name: 'Все остановки', exact: true }).click();
await expect(page.locator('.stopItem')).toHaveCount(5);
await page.getByRole('combobox', { name: 'Ваш город' }).selectOption('2');
await expect(page.locator('.stopDistanceHint')).toHaveCount(0);
await expect(page.locator('.explorerNearby')).toHaveAttribute('aria-pressed', 'false');
});
test('stop catalog and visited cards remain usable offline without importing the map', async ({ page, context }) => {
await localBasemap(page);
await page.goto('/stops?demo=1&city=1&stop=101');
if ((await page.viewportSize())!.width <= 760) { await page.getByRole('button', { name: 'Список', exact: true }).click(); await page.locator('.stopItem').filter({ hasText: 'Площадь Свободы' }).click(); }
await expect(page.locator('.passportRoute')).toHaveCount(4);
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) await new Promise<void>(resolve => navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true }));
});
await context.setOffline(true); await page.reload();
await expect(page.locator('.offlineBanner')).toHaveCount(1);
await expect(page.locator('.maplibregl-canvas')).toHaveCount(0);
await expect(page.locator('.stopItem')).toHaveCount(5);
if ((await page.viewportSize())!.width > 760) await expect(page.locator('.passportRoute')).toHaveCount(4);
await page.getByRole('searchbox').fill('Площадь');
await page.locator('.stopItem').click();
await expect(page.locator('.passportRoute')).toHaveCount(4);
await expect(page.locator('#stop-passport-title')).toHaveText('Площадь Свободы');
await expect(page.locator('main')).not.toContainText('устарев');
});
test('a basemap failure leaves search, stop information and the list usable', async ({ page }) => {
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
await page.route('https://tiles.openfreemap.org/**', route => route.abort());
await page.goto('/stops?demo=1&city=1');
await expect(page.locator('.mapProblem')).toContainText('Не удалось загрузить подложку карты');
await page.getByRole('button', { name: 'К списку', exact: true }).click();
await expect(page.locator('.maplibregl-canvas')).toHaveCount(0);
await page.getByRole('searchbox').fill('Больница');
await page.locator('.stopItem').click();
await expect(page.locator('#stop-passport-title')).toHaveText('Больница');
await expect(page.locator('.passportRoute')).toHaveCount(3);
expect(errors).toEqual([]);
});
test('map and list fit narrow phones and respond to the app theme', async ({ page }, info) => {
const styles: string[] = [];
await page.route('https://tiles.openfreemap.org/**', route => {
styles.push(route.request().url());
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ version: 8, sources: {}, layers: [{ id: 'base', type: 'background', paint: { 'background-color': '#f3f3fa' } }] }) });
});
await page.goto('/stops?demo=1&city=1&stop=101');
await expect(page.locator('.stopsMapCanvas')).toHaveAttribute('data-ready', 'true');
for (const width of [320, 390, 760, 761, 1050, 1440]) {
await page.setViewportSize({ width, height: 900 });
if (width <= 760 && await page.locator('.stopPassport').count()) await expect(page.locator('.stopPassportDialog')).toBeVisible();
if (await page.locator('.stopPassportDialog').count()) await page.getByRole('button', { name: 'Закрыть карточку остановки', exact: true }).click();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
const map = (await page.locator('.stopsMapCanvas').boundingBox())!;
expect(map.height).toBeGreaterThan(350);
await page.getByRole('button', { name: 'Список', exact: true }).click();
if (width > 760) await expect(page.locator('.stopPassport')).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.getByRole('button', { name: 'Карта', exact: true }).click();
await expect(page.locator('.stopsMapCanvas')).toHaveAttribute('data-ready', 'true');
}
await page.getByRole('navigation', { name: 'Основная навигация' }).getByRole('link', { name: 'Настройки' }).click();
await page.getByRole('radio', { name: /^Тёмная/ }).check();
await page.getByRole('navigation', { name: 'Основная навигация' }).getByRole('link', { name: 'Остановки' }).click();
await expect.poll(() => styles.some(url => url.endsWith('/dark'))).toBe(true);
await expect(page.locator('.stopPassport')).toHaveCSS('background-color', 'rgb(28, 35, 48)');
await page.screenshot({ path: `test-results/stops-dark-${info.project.name}.png`, fullPage: true, animations: 'disabled' });
});

220
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@vkontakte/icons": "^3.75.0", "@vkontakte/icons": "^3.75.0",
"dexie": "^4.4.6", "dexie": "^4.4.6",
"dexie-react-hooks": "^4.4.0", "dexie-react-hooks": "^4.4.0",
"maplibre-gl": "^6.13.0",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-router-dom": "^7.18.4" "react-router-dom": "^7.18.4"
@@ -2308,6 +2309,92 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.2.tgz",
"integrity": "sha512-2ZYhobdAY+y4/P/z1xe0698165LywD0yDdWugdu3NRUrajgVWut+tLkcz7i/RJguMTURwK7gxwmwYMFJbgbQFw==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "26.4.4",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/mlt": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -3507,6 +3594,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "26.6.4", "version": "26.6.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
@@ -4133,6 +4226,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.12", "version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
@@ -4519,6 +4621,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/earcut": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz",
"integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==",
"license": "ISC"
},
"node_modules/ejs": { "node_modules/ejs": {
"version": "3.1.10", "version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -5021,6 +5129,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob": { "node_modules/glob": {
"version": "11.1.0", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
@@ -5711,6 +5825,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json5": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -5747,6 +5867,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/leven": { "node_modules/leven": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -6057,6 +6183,39 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/maplibre-gl": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.13.0.tgz",
"integrity": "sha512-ELXk4h+xl0URFQEnVbAN6MGn61P6jq4amUWHiskLVsvHCaKdf1Zq6G8UNOD0AQ3UaXoL5ECc8ovz8yqYEBGPKw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.2.0",
"@mapbox/unitbezier": "^1.0.0",
"@mapbox/vector-tile": "^3.0.0",
"@maplibre/geojson-vt": "^6.1.2",
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
"@maplibre/mlt": "^1.3.0",
"@maplibre/vt-pbf": "^4.3.2",
"@types/geojson": "^7946.0.16",
"bidi-js": "^1.1.0",
"earcut": "^3.2.4",
"gl-matrix": "^3.4.4",
"kdbush": "^4.1.0",
"murmurhash-js": "^1.0.0",
"pbf": "^5.1.2",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6083,6 +6242,15 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": { "node_modules/minipass": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
@@ -6100,6 +6268,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.20", "version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
@@ -6251,6 +6425,18 @@
"node": "20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6339,6 +6525,12 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/pretty-bytes": { "node_modules/pretty-bytes": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
@@ -6352,6 +6544,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/react": { "node_modules/react": {
"version": "19.3.0", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
@@ -6531,7 +6735,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6559,6 +6762,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.12", "version": "1.2.12",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
@@ -7217,6 +7429,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@@ -22,6 +22,7 @@
"@vkontakte/icons": "^3.75.0", "@vkontakte/icons": "^3.75.0",
"dexie": "^4.4.6", "dexie": "^4.4.6",
"dexie-react-hooks": "^4.4.0", "dexie-react-hooks": "^4.4.0",
"maplibre-gl": "^6.13.0",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-router-dom": "^7.18.4" "react-router-dom": "^7.18.4"

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
qa/stops-map-live-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

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 { ApiError } from './errors.mjs';
import { coordinates } from './coordinates.mjs';
const routeNumber = value => typeof value === 'string' ? value.replace(/[\s№]/g, '').toLocaleUpperCase('ru') : ''; const routeNumber = value => typeof value === 'string' ? value.replace(/[\s№]/g, '').toLocaleUpperCase('ru') : '';
const positive = value => Number.isSafeInteger(Number(value)) && Number(value) > 0; const positive = value => Number.isSafeInteger(Number(value)) && Number(value) > 0;
@@ -32,6 +33,6 @@ export function normalizeNearby(raw) {
const distance = Number(stop?.distance_meters); 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); 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() : '', 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); }).sort((a, b) => a.distance - b.distance);
} }

View File

@@ -1,6 +1,8 @@
import { ApiError } from './errors.mjs'; import { ApiError } from './errors.mjs';
import { journeyStops, previousDate, routeDepartures, tripIdentity, validDate } from './timetable.mjs'; import { journeyStops, previousDate, routeDepartures, tripIdentity, validDate } from './timetable.mjs';
import { normalizeAlerts, normalizeNearby } from './features.mjs'; import { normalizeAlerts, normalizeNearby } from './features.mjs';
import { coordinates } from './coordinates.mjs';
import { normalizeWalking } from './walking.mjs';
export { ApiError } from './errors.mjs'; export { ApiError } from './errors.mjs';
function positiveId(value) { function positiveId(value) {
@@ -13,15 +15,18 @@ function positiveId(value) {
export function parseRequest(params) { export function parseRequest(params) {
const resource = params.get('resource'); const resource = params.get('resource');
if (resource === 'cities') return { 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')); const cityId = positiveId(params.get('cityId'));
if (resource === 'alerts') return { resource, 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 coordinate = (name, limit) => {
const value = params.get(name); const value = params.get(name);
if (!value || !/^-?\d+(?:\.\d+)?$/.test(value) || Math.abs(Number(value)) > limit) throw new ApiError(400, 'invalid_request'); if (!value || !/^-?\d+(?:\.\d+)?$/.test(value) || Math.abs(Number(value)) > limit) throw new ApiError(400, 'invalid_request');
return Number(value); 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; const radius = params.has('radius') ? positiveId(params.get('radius')) : 1000;
if (radius < 100 || radius > 2000) throw new ApiError(400, 'invalid_request'); if (radius < 100 || radius > 2000) throw new ApiError(400, 'invalid_request');
return { resource, cityId, lat: coordinate('lat', 90), lon: coordinate('lon', 180), radius }; return { resource, cityId, lat: coordinate('lat', 90), lon: coordinate('lon', 180), radius };
@@ -65,7 +70,8 @@ const id = value => {
export function normalizeCities(raw) { export function normalizeCities(raw) {
return list(raw).filter(item => item?.is_active !== false).map(item => { return list(raw).filter(item => item?.is_active !== false).map(item => {
const city = record(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), id: id(stop.id), name: text(stop.name),
direction: typeof stop.description === 'string' ? stop.description.trim() : '', direction: typeof stop.description === 'string' ? stop.description.trim() : '',
routesCount: Math.max(0, Number(stop.routes_count) || 0), 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()) { export function normalizeDepartures(raw, now = Date.now()) {
const departures = []; const departures = [];
const routes = []; const routes = [];
@@ -130,7 +162,7 @@ export function createRideBusClient({
const rawPending = new Map(); const rawPending = new Map();
const queue = []; const queue = [];
let active = 0; 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 (!apiKey?.trim()) throw new ApiError(503, 'api_not_configured');
if (active >= 6) await new Promise(resolve => queue.push(resolve)); if (active >= 6) await new Promise(resolve => queue.push(resolve));
else active++; else active++;
@@ -141,7 +173,7 @@ export function createRideBusClient({
headers, redirect: 'error', signal: AbortSignal.timeout(12000), headers, redirect: 'error', signal: AbortSignal.timeout(12000),
}); });
if (allowMissing && response.status === 404) return null; 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(); return await response.json();
} catch (error) { throw error instanceof ApiError ? error : new ApiError(502); } } catch (error) { throw error instanceof ApiError ? error : new ApiError(502); }
finally { finally {
@@ -205,6 +237,16 @@ export function createRideBusClient({
updatedAt: new Date(now()).toISOString(), tripKey: departure.tripKey }; updatedAt: new Date(now()).toISOString(), tripKey: departure.tripKey };
} }
async function load(input) { 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 === 'timetable') return timetable(input);
if (input.resource === 'journey') return journey(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 === 'alerts') return normalizeAlerts(await upstream('api/v1/alerts/active', input.cityId), input.cityId);
@@ -218,14 +260,14 @@ export function createRideBusClient({
return { return {
async get(input) { async get(input) {
// Location searches are ephemeral: coordinates and results never enter caches. // 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 key = `${input.resource}:${input.cityId ?? ''}:${input.stopId ?? ''}:${input.date ?? ''}:${input.routeId ?? ''}:${input.at ?? ''}:${input.tripKey ?? ''}`;
const entry = cache.get(key); const entry = cache.get(key);
if (entry && entry.expires > now()) return entry.data; if (entry && entry.expires > now()) return entry.data;
if (pending.has(key)) return pending.get(key); if (pending.has(key)) return pending.get(key);
const request = load(input).then(data => { const request = load(input).then(data => {
if (cache.size >= 200) cache.delete(cache.keys().next().value); 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 }); cache.set(key, { data, expires: now() + ttl });
return data; return data;
}).finally(() => pending.delete(key)); }).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 } };
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { NavLink, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
@@ -22,6 +22,8 @@ import { isTravelDate, localDate } from './lib/time';
import { countLabel, groupStops, type StopGroup } from './lib/stopGroups'; import { countLabel, groupStops, type StopGroup } from './lib/stopGroups';
import type { Board, City, Stop } from './types'; import type { Board, City, Stop } from './types';
const StopsExplorer = lazy(() => import('./components/StopsExplorer').then(module => ({ default: module.StopsExplorer })));
function useOnline() { function useOnline() {
const [online, setOnline] = useState(navigator.onLine); const [online, setOnline] = useState(navigator.onLine);
useEffect(() => { useEffect(() => {
@@ -77,13 +79,13 @@ export default function App() {
const selectedGroup = stopGroups.find(group => group.stops.some(item => item.id === stop?.id)) ?? (stop ? groupStops([stop])[0] : undefined); const selectedGroup = stopGroups.find(group => group.stops.some(item => item.id === stop?.id)) ?? (stop ? groupStops([stop])[0] : undefined);
const board = useQuery({ const board = useQuery({
queryKey: ['departures', mode, cityId, stop?.id], queryKey: ['departures', mode, cityId, stop?.id],
queryFn: () => loadResource<Board>('departures', mode, cityId, stop?.id), enabled: !!cityId && !!stop && schedulePage && view === 'next', queryFn: () => loadResource<Board>('departures', mode, cityId, stop?.id), enabled: !!cityId && !!stop && homePage && view === 'next',
staleTime: 30000, refetchInterval: online ? 60000 : false, staleTime: 30000, refetchInterval: online ? 60000 : false,
}); });
const timetable = useQuery({ const timetable = useQuery({
queryKey: ['timetable', mode, cityId, stop?.id, date], queryKey: ['timetable', mode, cityId, stop?.id, date],
queryFn: () => loadResource<Board>('timetable', mode, cityId, stop?.id, date), queryFn: () => loadResource<Board>('timetable', mode, cityId, stop?.id, date),
enabled: !!cityId && !!stop && schedulePage && view === 'day', staleTime: 300000, enabled: !!cityId && !!stop && homePage && view === 'day', staleTime: 300000,
}); });
const key = cityId && stop ? stopKey(mode, cityId, stop.id) : ''; const key = cityId && stop ? stopKey(mode, cityId, stop.id) : '';
const isFavorite = favorites.some(item => item.key === key); const isFavorite = favorites.some(item => item.key === key);
@@ -104,7 +106,7 @@ export default function App() {
const boardTarget = selectionScroll ?? location.state?.boardTarget as { cityId: number; stopId: number; routeId?: number } | undefined; const boardTarget = selectionScroll ?? location.state?.boardTarget as { cityId: number; stopId: number; routeId?: number } | undefined;
const boardPending = (view === 'day' ? timetable : board).isPending; const boardPending = (view === 'day' ? timetable : board).isPending;
useEffect(() => { useEffect(() => {
if (!schedulePage || !boardTarget || scrolledLocation.current === location.key || stops.isPending || boardPending if (!homePage || !boardTarget || scrolledLocation.current === location.key || stops.isPending || boardPending
|| cityId !== boardTarget.cityId || stop?.id !== boardTarget.stopId || cityId !== boardTarget.cityId || stop?.id !== boardTarget.stopId
|| (boardTarget.routeId !== undefined && (routeId !== boardTarget.routeId || view !== 'next'))) return; || (boardTarget.routeId !== undefined && (routeId !== boardTarget.routeId || view !== 'next'))) return;
const target = boardRef.current; const target = boardRef.current;
@@ -113,7 +115,7 @@ export default function App() {
target.focus({ preventScroll: true }); target.focus({ preventScroll: true });
target.scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); target.scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
setSelectionScroll(null); setSelectionScroll(null);
}, [schedulePage, boardTarget, location.key, stops.isPending, boardPending, cityId, stop?.id, routeId, view]); }, [homePage, boardTarget, location.key, stops.isPending, boardPending, cityId, stop?.id, routeId, view]);
function selectCity(value: string) { function selectCity(value: string) {
setCityChoice(value); setStopChoice(''); setSearch(''); setSelectionScroll(null); setCityChoice(value); setStopChoice(''); setSearch(''); setSelectionScroll(null);
@@ -125,7 +127,8 @@ export default function App() {
setPickerOpen(false); setPickerOpen(false);
if (fromList && cityId && matchMedia('(max-width: 760px)').matches) setSelectionScroll({ cityId, stopId: value.id }); if (fromList && cityId && matchMedia('(max-width: 760px)').matches) setSelectionScroll({ cityId, stopId: value.id });
savePreference(`${mode}:city`, String(cityId)); savePreference(`${mode}:stop`, String(value.id)); savePreference(`${mode}:city`, String(cityId)); savePreference(`${mode}:stop`, String(value.id));
setParams({ ...(mode === 'demo' ? { demo: '1' } : {}), city: String(cityId), stop: String(value.id), ...(view === 'day' ? { view, date } : {}) }); setParams({ ...(mode === 'demo' ? { demo: '1' } : {}), city: String(cityId), stop: String(value.id), ...(view === 'day' ? { view, date } : {}),
...(stopsPage && params.get('line') ? { line: params.get('line')!, ...(params.get('direction') ? { direction: params.get('direction')! } : {}) } : {}) });
} }
function selectView(nextView: 'next' | 'day', nextDate = date) { function selectView(nextView: 'next' | 'day', nextDate = date) {
setView(nextView); setDate(nextDate); setView(nextView); setDate(nextDate);
@@ -134,6 +137,12 @@ export default function App() {
if (nextView === 'day') { nextParams.set('view', 'day'); nextParams.set('date', nextDate); } if (nextView === 'day') { nextParams.set('view', 'day'); nextParams.set('date', nextDate); }
setParams(nextParams); setParams(nextParams);
} }
function openStopSchedule(value: Stop, filteredRoute?: number) {
if (!cityId) return;
savePreference(`${mode}:city`, String(cityId)); savePreference(`${mode}:stop`, String(value.id));
const next = new URLSearchParams({ ...(mode === 'demo' ? { demo: '1' } : {}), city: String(cityId), stop: String(value.id), ...(view === 'day' ? { view, date } : {}), ...(filteredRoute ? { route: String(filteredRoute) } : {}) });
navigate(`/?${next}`, { state: { boardTarget: { cityId, stopId: value.id, ...(filteredRoute && view === 'next' ? { routeId: filteredRoute } : {}) } } });
}
function selectRoute(route: number | null) { function selectRoute(route: number | null) {
const nextParams = new URLSearchParams(params); const nextParams = new URLSearchParams(params);
if (route) nextParams.set('route', String(route)); else nextParams.delete('route'); if (route) nextParams.set('route', String(route)); else nextParams.delete('route');
@@ -190,7 +199,7 @@ export default function App() {
{navigation(false)} {navigation(false)}
<div className="headerActions"><PwaControls /></div> <div className="headerActions"><PwaControls /></div>
</header> </header>
<main id="main"> <main id="main" className={schedulePage ? 'scheduleMain' : undefined}>
{mode === 'demo' && <div className="demoBanner" role="status"><span>Демонстрация · остановки и отправления — примеры</span><button onClick={() => switchMode(false)}>К реальному расписанию <ArrowRight size={15} /></button></div>} {mode === 'demo' && <div className="demoBanner" role="status"><span>Демонстрация · остановки и отправления — примеры</span><button onClick={() => switchMode(false)}>К реальному расписанию <ArrowRight size={15} /></button></div>}
{!online && <div className="offlineBanner" role="status"><WifiOff size={17} /><span>Нет подключения · загруженное расписание доступно</span></div>} {!online && <div className="offlineBanner" role="status"><WifiOff size={17} /><span>Нет подключения · загруженное расписание доступно</span></div>}
{settingsPage ? <Settings choice={theme.choice} resolved={theme.resolved} saved={theme.saved} onChange={theme.changeChoice} scheduleLink={`/${location.search}`} onDiscovery={restartDiscovery} /> : routePage ? <RoutePage online={online} onNotice={setNotice} /> : aboutPage ? <About urlSuffix={urlSuffix} /> : favoritesPage ? <> {settingsPage ? <Settings choice={theme.choice} resolved={theme.resolved} saved={theme.saved} onChange={theme.changeChoice} scheduleLink={`/${location.search}`} onDiscovery={restartDiscovery} /> : routePage ? <RoutePage online={online} onNotice={setNotice} /> : aboutPage ? <About urlSuffix={urlSuffix} /> : favoritesPage ? <>
@@ -198,23 +207,25 @@ export default function App() {
<QuickTrips trips={trips} online={online} onNotice={setNotice} /> <QuickTrips trips={trips} online={online} onNotice={setNotice} />
{favorites.length === 0 && trips.length === 0 ? <section className="favoritesEmpty"><span className="emptyIcon"><Bookmark size={32} /></span><h2>Здесь будут ваши остановки</h2><p>Нажмите на закладку рядом с названием остановки<br />или сохраните поездку с нужным маршрутом.</p><NavLink className="primaryButton" to={`/${urlSuffix}`}>Найти остановку <ArrowRight size={18} /></NavLink></section> {favorites.length === 0 && trips.length === 0 ? <section className="favoritesEmpty"><span className="emptyIcon"><Bookmark size={32} /></span><h2>Здесь будут ваши остановки</h2><p>Нажмите на закладку рядом с названием остановки<br />или сохраните поездку с нужным маршрутом.</p><NavLink className="primaryButton" to={`/${urlSuffix}`}>Найти остановку <ArrowRight size={18} /></NavLink></section>
: <div className="favoritesGrid">{[...favorites].sort((a, b) => b.addedAt - a.addedAt).map(item => <FavoriteCard key={item.key} stop={item} online={online} onRemove={() => void db.favorites.delete(item.key).catch(() => setNotice('Не удалось удалить остановку'))} />)}</div>} : <div className="favoritesGrid">{[...favorites].sort((a, b) => b.addedAt - a.addedAt).map(item => <FavoriteCard key={item.key} stop={item} online={online} onRemove={() => void db.favorites.delete(item.key).catch(() => setNotice('Не удалось удалить остановку'))} />)}</div>}
</> : stopsPage ? <>
<section className="catalogHero explorerHero"><h1>Остановки</h1><p>Найдите место посадки и нужное направление.</p></section>
<Suspense fallback={<div className="listMessage" role="status">Загружаем остановки…</div>}><StopsExplorer key={`${mode}:${cityId ?? ''}`} city={city} cityPicker={cityPicker('explorer-city')} stops={allStops} selected={stop} favorites={favorites} loading={cities.isPending || (!!cityId && stops.isPending)} error={cities.isError || stops.isError} online={online} mode={mode} theme={theme.resolved} onSelect={value => selectStop(value)} onSchedule={openStopSchedule} onFavorite={() => void toggleFavorite()} onRetry={() => { void cities.refetch(); if (cityId) void stops.refetch(); }} /></Suspense>
</> : <> </> : <>
{homePage ? <section className="homeHero"><h1>Расписание<br /><span>в вашем кармане.</span></h1><p>Найдите свою остановку и отправление.</p></section> : <section className="catalogHero"><h1>Остановки</h1><p>Поиск по названию или рядом с вами.</p></section>} {homePage ? <section className="homeHero"><h1>Расписание<br /><span>в вашем кармане.</span></h1><p>Найдите свою остановку и отправление.</p></section> : <section className="catalogHero"><h1>Остановки</h1><p>Поиск по названию или рядом с вами.</p></section>}
{homePage && trips.length > 0 && <div data-discovery="quick-trips"><QuickTrips trips={trips} online={online} onNotice={setNotice} /></div>} {homePage && trips.length > 0 && <div data-discovery="quick-trips"><QuickTrips trips={trips} online={online} onNotice={setNotice} /></div>}
<div className={`workspace ${homePage ? 'homeWorkspace' : 'stopsWorkspace'}`}> <div className="workspace homeWorkspace">
{stopsPage ? <section className="stopsCard catalogCard" aria-labelledby="stops-title"><div className="sectionHeading"><h2 id="stops-title">Найти остановку</h2><MapPin size={22} /></div>{stopCatalog()}</section> <section className="stopsCard compactPicker" aria-labelledby="stops-title"><div className="sectionHeading"><h2 id="stops-title">Куда едем?</h2></div>{cityPicker('home-city')}
: <section className="stopsCard compactPicker" aria-labelledby="stops-title"><div className="sectionHeading"><h2 id="stops-title">Куда едем?</h2></div>{cityPicker('home-city')}
<button className="stopPickerButton" data-discovery="stop-picker" aria-label="Выбрать остановку" onClick={() => openPicker()}><Search size={20} /><span>{selectedGroup?.name ?? stop?.name ?? 'Выбрать остановку'}</span><ChevronDown size={18} /></button> <button className="stopPickerButton" data-discovery="stop-picker" aria-label="Выбрать остановку" onClick={() => openPicker()}><Search size={20} /><span>{selectedGroup?.name ?? stop?.name ?? 'Выбрать остановку'}</span><ChevronDown size={18} /></button>
<div className="pickerShortcuts"><NavLink to={`/stops${location.search}`}>Все остановки <ArrowRight size={17} /></NavLink><button className="textButton" aria-label="Остановки рядом" disabled={!online || !cityId} onClick={() => openPicker(true)}><MapPin size={17} />Рядом</button></div> <div className="pickerShortcuts"><NavLink to={`/stops${location.search}`}>Все остановки <ArrowRight size={17} /></NavLink><button className="textButton" aria-label="Остановки рядом" disabled={!online || !cityId} onClick={() => openPicker(true)}><MapPin size={17} />Рядом</button></div>
{cities.isError && !city && <StopList label="Остановки">{stopMessage()}</StopList>} {cities.isError && !city && <StopList label="Остановки">{stopMessage()}</StopList>}
</section>} </section>
<Departures stop={stop} group={selectedGroup} onDirection={selectStop} query={view === 'day' ? timetable : board} favorite={isFavorite} onFavorite={() => void toggleFavorite()} online={online} view={view} date={date} onView={selectView} routeId={routeId} onRoute={selectRoute} city={city} mode={mode} onTripSaved={() => setNotice('Поездка сохранена')} onNotice={setNotice} boardRef={boardRef} /> <Departures stop={stop} group={selectedGroup} onDirection={selectStop} query={view === 'day' ? timetable : board} favorite={isFavorite} onFavorite={() => void toggleFavorite()} online={online} view={view} date={date} onView={selectView} routeId={routeId} onRoute={selectRoute} city={city} mode={mode} onTripSaved={() => setNotice('Поездка сохранена')} onNotice={setNotice} boardRef={boardRef} />
</div> </div>
{homePage && trips.length === 0 && <section className="emptyTrips"><Home size={25} /><div data-discovery="quick-trips"><h2>Мои поездки</h2><p>Сохраните поездку домой или на работу в карточке отправлений.</p></div></section>} {homePage && trips.length === 0 && <section className="emptyTrips"><Home size={25} /><div data-discovery="quick-trips"><h2>Мои поездки</h2><p>Сохраните поездку домой или на работу в карточке отправлений.</p></div></section>}
{pickerOpen && <Dialog title="Выбрать остановку" onClose={() => setPickerOpen(false)}>{stopCatalog()}</Dialog>} {pickerOpen && <Dialog title="Выбрать остановку" onClose={() => setPickerOpen(false)}>{stopCatalog()}</Dialog>}
</>} </>}
</main> </main>
<footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><NavLink to={`/settings${location.search}`}>Настройки <SettingsIcon size={18} /></NavLink><NavLink to={`/about${urlSuffix}`}>О проекте <ArrowRight size={18} /></NavLink><a href="https://ridebus.by" target="_blank" rel="noreferrer">Сайт RideBus <ArrowUpRight size={20} /></a>{(import.meta.env.DEV || mode === 'demo') && <button onClick={() => switchMode(mode !== 'demo')}>{mode === 'demo' ? 'Реальное расписание' : 'Посмотреть пример'}</button>}</div></footer> <footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><NavLink to={`/about${urlSuffix}`}>О проекте <ArrowRight size={18} /></NavLink><a href="https://ridebus.by" target="_blank" rel="noreferrer">Сайт RideBus <ArrowUpRight size={20} /></a>{(import.meta.env.DEV || mode === 'demo') && <button onClick={() => switchMode(mode !== 'demo')}>{mode === 'demo' ? 'Реальное расписание' : 'Посмотреть пример'}</button>}</div></footer>
{navigation(true)} {navigation(true)}
<FeatureDiscovery home={homePage} restartKey={discoveryRestart} /> <FeatureDiscovery home={homePage} restartKey={discoveryRestart} />
{notice && <div className="toast" role="status">{notice}<button aria-label="Закрыть уведомление" onClick={() => setNotice('')}><X size={16} /></button></div>} {notice && <div className="toast" role="status">{notice}<button aria-label="Закрыть уведомление" onClick={() => setNotice('')}><X size={16} /></button></div>}

View File

@@ -34,6 +34,7 @@
.desktopNav a { min-width: 44px; min-height: 44px; padding: 10px 12px; } .desktopNav a { min-width: 44px; min-height: 44px; padding: 10px 12px; }
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.scheduleMain { padding-bottom: 30px; }
.homeHero, .catalogHero { padding: 26px 0 22px; } .homeHero, .catalogHero { padding: 26px 0 22px; }
.homeHero h1, .catalogHero h1 { font-size: 30px; } .homeHero h1, .catalogHero h1 { font-size: 30px; }
.homeHero p, .catalogHero p { font-size: 15px; } .homeHero p, .catalogHero p { font-size: 15px; }

153
src/_stops.scss Normal file
View File

@@ -0,0 +1,153 @@
@mixin components {
.explorerHero { padding-top: 28px; padding-bottom: 24px; }
.stopsExplorer { border: 1px solid var(--line); background: var(--lavender); border-radius: var(--card-radius); overflow: hidden; color: var(--blue); }
.explorerToolbar { display: grid; grid-template-columns: 180px minmax(170px, 1fr) auto; gap: 12px; padding: 18px; align-items: center; border-bottom: 1px solid var(--subtle-line); }
.explorerTools { display: flex; gap: 10px; align-items: center; }
.explorerSearchWrap { position: relative; min-width: 0; }
.explorerSearch { width: 100%; }
.explorerSuggestions { position: absolute; z-index: 15; top: calc(100% + 8px); left: 0; right: 0; padding: 14px; border: 1px solid var(--control-border); border-radius: 20px; background: var(--surface); box-shadow: 0 12px 32px #172d6024; }
.explorerSuggestions .stopsList { max-height: min(280px, 40dvh); margin: 10px -8px 0; }
.explorerSuggestions .stopItem { padding: 12px; }
.explorerRouteFilters { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 12px 18px; border-bottom: 1px solid var(--subtle-line); }
.explorerRouteSelect, .explorerRouteDirection { display: flex; align-items: center; gap: 8px; padding: 0 12px; background: var(--surface); border-radius: 13px; min-height: 44px; min-width: 0; }
.explorerRouteSelect { width: 190px; }
.explorerRouteDirection { flex: 1; max-width: 530px; }
.explorerRouteFilters select { appearance: none; width: 100%; min-width: 0; border: 0; background: transparent; color: var(--blue); font-size: 15px; font-weight: 700; padding: 8px 0; }
.explorerRouteFilters label:focus-within { box-shadow: 0 0 0 2px var(--focus-soft); }
.explorerRouteFilters select:focus-visible { outline: 0; }
.routeFilterError { display: flex; gap: 5px; align-items: center; font-size: 14px; color: var(--muted); }
.explorerNearby { min-height: 46px; gap: 7px; padding: 10px 14px; font-size: 14px; white-space: nowrap; }
.explorerNearby[aria-pressed='true'] { background: var(--green); color: var(--green-ink); }
.explorerViews { display: flex; padding: 4px; background: var(--blue-soft); border-radius: 14px; }
.explorerViews button { min-height: 42px; padding: 8px 14px; border: 0; border-radius: 11px; background: transparent; color: var(--blue); font-weight: 750; font-size: 14px; }
.explorerViews button[aria-pressed='true'] { background: var(--surface); }
.explorerLocationMessage { padding: 12px 20px; font-size: 14px; line-height: 1.65; background: var(--warning-bg); color: var(--warning-ink); }
.explorerBody { display: grid; grid-template-columns: 320px minmax(0, 1fr); min-height: 600px; }
.explorerMapMode .explorerBody { grid-template-columns: minmax(0, 1fr); }
.explorerCatalog { padding: 22px 16px 12px; border-right: 1px solid var(--subtle-line); min-width: 0; }
.explorerListHeading { padding: 0 6px; }
.explorerListHeading h2 { font-size: 18px; font-weight: 800; }
.explorerListHeading > span { display: block; margin-top: 5px; font-size: 14px; color: var(--blue-muted); }
.explorerHint { padding: 8px 6px 0; color: var(--blue-muted); font-size: 14px; }
.explorerCatalog .stopsList { max-height: 512px; margin-top: 14px; margin-bottom: 0; }
.explorerCatalog .stopItem { padding: 13px 12px; }
.explorerMapArea { position: relative; min-width: 0; min-height: 600px; }
.stopsMapFrame, .stopsMapFrame .stopsMapCanvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.stopsMapCanvas .maplibregl-canvas { outline-offset: -4px; }
.stopsMapCanvas .maplibregl-ctrl-attrib { font-family: inherit; font-size: 11px; color: #242424; }
.stopsMapCanvas .maplibregl-ctrl-bottom-right { bottom: auto; top: 0; right: 0; }
.mapZoom { position: absolute; top: 52px; right: 16px; display: grid; border-radius: 16px; padding: 4px; background: var(--surface); box-shadow: 0 3px 20px #00000016; }
.mapZoom button { display: grid; place-items: center; height: 44px; width: 44px; border: 0; background: transparent; color: var(--blue); border-radius: 12px; }
.mapZoom button:hover { background: var(--lavender); }
.mapInstruction { position: absolute; bottom: 20px; left: 20px; right: 20px; width: fit-content; max-width: calc(100% - 40px); padding: 12px 16px; border-radius: 14px; background: var(--nav-glass); font-size: 14px; pointer-events: none; }
.mapLoading { position: absolute; inset: 0; display: grid; place-items: center; background: var(--lavender); font-size: 16px; }
.mapProblem { position: absolute; top: 12px; left: 12px; right: 80px; display: flex; gap: 8px; align-items: center; padding: 12px; border-radius: 15px; background: var(--surface); box-shadow: 0 3px 16px #00000016; font-size: 14px; line-height: 1.55; }
.mapProblem .textButton { white-space: nowrap; min-height: 44px; }
.mapProblem .iconButton { margin-left: auto; padding: 5px; }
.userLocationDot { width: 18px; height: 18px; background: #528cff; border: 3px solid #ffffff; border-radius: 50%; box-shadow: 0 0 0 8px #528cff30; }
.stopPassport { outline: 0; padding: 22px; background: var(--surface); border: 1px solid var(--control-border); border-radius: 24px; scroll-margin-top: 24px; scroll-margin-bottom: 100px; }
.explorerMapMode .explorerMapArea > .stopPassport { position: absolute; bottom: 20px; left: 20px; right: 20px; max-height: calc(100% - 180px); width: min(400px, calc(100% - 40px)); overflow-y: auto; box-shadow: 0 10px 32px #172d6024; animation: passportEnter .24s ease both; }
.stopPassportHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.stopPassportHeading > div { min-width: 0; }
.stopPassportHeading .eyebrow { font-size: 12px; letter-spacing: .5px; color: var(--muted); margin-bottom: 5px; }
.stopPassportHeading h2 { font-size: 23px; line-height: 1.35; overflow-wrap: anywhere; }
.stopPassportHeading .iconButton { margin: -7px -9px 0 0; }
.stopAddress, .stopDirection { margin-top: 10px; color: var(--blue-muted); font-size: 15px; }
.passportDirections { display: flex; gap: 7px; flex-wrap: wrap; margin-top: 14px; }
.passportDirections .routeChip { border: 0; border-radius: 13px; background: var(--lavender); color: var(--blue-muted); font-weight: 750; font-size: 14px; padding: 10px 13px; min-height: 44px; text-align: left; line-height: 1.4; white-space: normal; }
.passportDirections .routeChip[aria-pressed='true'] { background: var(--blue-soft); color: var(--blue); }
.stopDistanceHint { margin-top: 10px; font-size: 14px; color: var(--muted); }
.passportRoutes { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; margin: 17px 0; }
.passportRoutes h3 { font-size: 14px; font-weight: 650; color: var(--muted); }
.passportRoutes p { font-size: 14px; }
.passportRouteChips { display: flex; flex-wrap: wrap; gap: 7px; }
.passportRoute { padding: 5px 12px; border-radius: 10px; background: var(--lavender); font-size: 15px; font-weight: 800; }
.passportActions { display: flex; gap: 10px; align-items: center; }
.passportActions .primaryButton { flex: 1; justify-content: space-between; gap: 10px; padding: 12px 16px; min-height: 48px; font-size: 15px; border-radius: 14px; }
.passportFavorite { border-radius: 14px; background: var(--lavender); width: 48px; height: 48px; }
.passportFavorite.isFavorite { background: var(--green); color: var(--green-ink); }
.passportWalking { display: inline-flex; align-items: center; gap: 7px; font-size: 14px; padding: 10px 0 0; min-height: 44px; }
.passportEmpty { padding: 50px 35px; text-align: center; color: var(--blue-muted); }
.passportEmpty > .vkuiIcon { margin: 0 auto 16px; }
.passportEmpty h2 { font-size: 23px; }
.passportEmpty p { margin-top: 10px; font-size: 16px; line-height: 1.7; }
.explorerListMode .explorerMapArea { display: flex; flex-direction: column; padding: 0; }
.explorerListMode .stopPassport { width: 100%; max-width: none; flex: 1; margin: 0; padding: 30px; border: 0; border-radius: 0; }
.stopPassportDialog.appDialog { width: min(520px, calc(100% - 24px)); }
.stopPassportDialog .dialogBody { padding: 0; }
.stopPassportDialog .stopPassport { position: static; width: 100%; max-height: none; border: 0; box-shadow: none; margin: 0; padding: 22px; border-radius: 0; animation: none; }
.walkingDialog.appDialog { width: min(700px, calc(100% - 24px)); }
.walkingDestination { display: flex; gap: 9px; align-items: flex-start; color: var(--blue); }
.walkingDestination strong { font-size: 17px; }
.walkingDestination small { display: block; color: var(--muted); font-size: 14px; margin-top: 5px; }
.walkingStatus { display: flex; gap: 9px; align-items: center; margin: 24px 0 12px; font-size: 15px; color: var(--muted); }
.walkingError { margin-top: 20px; font-size: 15px; line-height: 1.7; }
.walkingError > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
.walkingSummary { display: flex; align-items: center; gap: 15px; margin: 20px 0 16px; padding: 12px 16px; border-radius: 16px; background: var(--green); color: var(--green-ink); }
.walkingSummary strong { font-size: 23px; }
.walkingSummary span { font-size: 15px; }
.walkingMap { position: relative; height: 330px; border-radius: 20px; overflow: hidden; }
.walkingHint { font-size: 14px; color: var(--muted); line-height: 1.65; margin-top: 14px; }
@keyframes passportEnter { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 1120px) {
.explorerToolbar { grid-template-columns: 180px minmax(0, 1fr); }
.explorerTools { grid-column: 1 / -1; justify-content: space-between; }
.explorerBody { grid-template-columns: 280px minmax(0, 1fr); }
}
@media (max-width: 760px) {
.explorerHero { padding: 18px 0 14px; }
.explorerHero h1 { font-size: 28px; }
.explorerHero p { display: none; }
.stopsExplorer { border-radius: 24px; }
.explorerToolbar { position: relative; grid-template-columns: minmax(0, 1fr) auto; padding: 14px; gap: 10px; }
.explorerTools { display: contents; }
.explorerViews { grid-column: 2; grid-row: 1; }
.explorerSearchWrap { grid-column: 1 / -1; grid-row: 2; }
.explorerSearch { padding-right: 58px; }
.explorerNearby { position: absolute; bottom: 17px; right: 17px; padding: 0; min-height: 44px; width: 44px; border-radius: 11px; }
.explorerNearby > span { display: none; }
.explorerViews button { padding: 8px 12px; font-size: 13px; }
.explorerLocationMessage { padding: 10px 14px; }
.explorerRouteFilters { gap: 8px; padding: 10px 14px; flex-wrap: nowrap; }
.explorerRouteSelect { flex: 1; width: auto; }
.explorerRouteFilters:has(.explorerRouteDirection) .explorerRouteSelect { flex: 0 0 78px; padding: 0 10px; }
.explorerRouteFilters:has(.explorerRouteDirection) .explorerRouteSelect > .vkuiIcon { display: none; }
.explorerRouteDirection { flex: 1; min-width: 0; padding: 0 10px; }
.explorerRouteFilters select { font-size: 14px; }
.routeFilterError { width: 100%; }
.explorerRouteFilters:has(.routeFilterError) { flex-wrap: wrap; }
.explorerRouteFilters:has(.explorerRouteDirection) { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
.explorerRouteFilters:has(.explorerRouteDirection) .explorerRouteSelect { grid-column: 1; width: auto; }
.explorerRouteFilters:has(.explorerRouteDirection) .explorerRouteDirection { grid-column: 1 / -1; grid-row: 2; }
.explorerRouteFilters:has(.explorerRouteDirection) > .iconButton { grid-column: 2; grid-row: 1; }
.explorerRouteFilters:has(.explorerRouteDirection) .routeFilterError { grid-column: 1 / -1; }
.explorerCity .citySelect > .vkuiIcon:first-child { display: none; }
.explorerCity .citySelect { padding: 0 9px; gap: 5px; }
.explorerBody { display: block; min-height: 0; }
.explorerCatalog { padding: 18px 14px 12px; border-right: 0; }
.explorerCatalog .stopsList { max-height: 350px; }
.explorerMapArea { min-height: 0; }
.explorerMapMode .explorerMapArea { height: clamp(380px, calc(100dvh - 440px), 660px); }
.stopsExplorer:has(.explorerRouteDirection).explorerMapMode .explorerMapArea { height: clamp(320px, calc(100dvh - 490px), 660px); }
.explorerListMode .explorerMapArea { padding: 0 14px 14px; }
.explorerMapMode .explorerMapArea > .stopPassport { left: 12px; right: 12px; bottom: 12px; width: auto; max-height: calc(100% - 120px); }
.explorerListMode .explorerMapArea:empty { display: none; }
.stopPassport { padding: 17px; border-radius: 21px; }
.stopPassportHeading h2 { font-size: 21px; }
.passportDirections { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: thin; }
.passportDirections .routeChip { flex-shrink: 0; max-width: 260px; }
.passportRoutes { margin: 14px 0; }
.passportActions .primaryButton { font-size: 14px; padding: 12px; }
.passportEmpty { padding: 25px 15px; }
.mapProblem { right: 74px; flex-wrap: wrap; padding: 10px; }
.mapProblem > span { flex: 1; min-width: 100px; }
.mapProblem .iconButton { position: absolute; top: 0; right: -2px; }
.mapProblem > span { padding-right: 24px; }
.mapProblem > .vkuiIcon { display: none; }
.mapZoom { display: flex; right: 12px; top: 44px; }
.walkingDialog .dialogBody { padding: 20px; }
.walkingDialog .dialogHeading h2 { font-size: 22px; }
.walkingMap { height: min(300px, 42dvh); min-height: 180px; }
}
@media (prefers-reduced-motion: reduce) { .stopPassport { animation: none !important; } }
}

View File

@@ -11,6 +11,7 @@ import { QuickTripForm } from './QuickTrips';
import { HourlyTimetable } from './HourlyTimetable'; import { HourlyTimetable } from './HourlyTimetable';
import { Alerts } from './ServiceAlerts'; import { Alerts } from './ServiceAlerts';
import { ShareDeparture } from './ShareDeparture'; import { ShareDeparture } from './ShareDeparture';
import { ScrollFade } from './ScrollFade';
export function Departures({ stop, group, onDirection, query, favorite, onFavorite, online, view, date, onView, routeId, onRoute, city, mode, onTripSaved, onNotice, boardRef }: { export function Departures({ stop, group, onDirection, query, favorite, onFavorite, online, view, date, onView, routeId, onRoute, city, mode, onTripSaved, onNotice, boardRef }: {
stop?: Stop; group?: StopGroup; onDirection: (stop: Stop) => void; stop?: Stop; group?: StopGroup; onDirection: (stop: Stop) => void;
@@ -37,7 +38,7 @@ export function Departures({ stop, group, onDirection, query, favorite, onFavori
{stop && <> {stop && <>
<div className="scheduleControls"><div className="scheduleViews" role="group" aria-label="Режим расписания"><button className={!fullDay ? 'selected' : ''} aria-pressed={!fullDay} onClick={() => onView('next')}>Ближайшие</button><button className={fullDay ? 'selected' : ''} aria-pressed={fullDay} onClick={() => onView('day')}>На дату</button></div>{city && !!board?.routes.length && <button className="iconButton saveTripButton" data-discovery="save-trip" title="Сохранить поездку" aria-label="Сохранить поездку" onClick={() => setSaveTrip(true)}><Plus size={24} /></button>}{fullDay && <div className="scheduleDate"><label htmlFor="schedule-date">Дата поездки</label><input id="schedule-date" type="date" value={date} min="2000-01-02" max="2100-12-31" onChange={event => { if (event.target.value && event.target.validity.valid) onView('day', event.target.value); }} /><button className="textButton" onClick={() => onView('day', localDate(now))}>Сегодня</button></div>}</div> <div className="scheduleControls"><div className="scheduleViews" role="group" aria-label="Режим расписания"><button className={!fullDay ? 'selected' : ''} aria-pressed={!fullDay} onClick={() => onView('next')}>Ближайшие</button><button className={fullDay ? 'selected' : ''} aria-pressed={fullDay} onClick={() => onView('day')}>На дату</button></div>{city && !!board?.routes.length && <button className="iconButton saveTripButton" data-discovery="save-trip" title="Сохранить поездку" aria-label="Сохранить поездку" onClick={() => setSaveTrip(true)}><Plus size={24} /></button>}{fullDay && <div className="scheduleDate"><label htmlFor="schedule-date">Дата поездки</label><input id="schedule-date" type="date" value={date} min="2000-01-02" max="2100-12-31" onChange={event => { if (event.target.value && event.target.validity.valid) onView('day', event.target.value); }} /><button className="textButton" onClick={() => onView('day', localDate(now))}>Сегодня</button></div>}</div>
{fullDay && <p className="timetableSummary">{formatDate(`${date}T12:00:00+03:00`)}{board ? ` · ${countLabel(departures.length, ['рейс', 'рейса', 'рейсов'])}` : ''}{board?.isHoliday && ` · ${board.holidayName || 'праздничный день'}`}</p>} {fullDay && <p className="timetableSummary">{formatDate(`${date}T12:00:00+03:00`)}{board ? ` · ${countLabel(departures.length, ['рейс', 'рейса', 'рейсов'])}` : ''}{board?.isHoliday && ` · ${board.holidayName || 'праздничный день'}`}</p>}
{board && board.routes.length > 0 && <div className="routeFilters appScrollArea" role="group" aria-label="Фильтр по маршруту"><button className={!routeId ? 'selected' : ''} onClick={() => onRoute(null)} aria-pressed={!routeId}>Все маршруты</button>{board.routes.map(route => <button key={route.id} title={route.name} className={routeId === route.id ? 'selected' : ''} aria-label={`Маршрут ${route.number}`} aria-pressed={routeId === route.id} onClick={() => onRoute(route.id)}>{route.number}</button>)}</div>} {board && board.routes.length > 0 && <ScrollFade key={`${city?.id}:${stop.id}`} className="routeFilters appScrollArea" role="group" aria-label="Фильтр по маршруту"><button className={!routeId ? 'selected' : ''} onClick={() => onRoute(null)} aria-pressed={!routeId}>Все маршруты</button>{board.routes.map(route => <button key={route.id} title={route.name} className={routeId === route.id ? 'selected' : ''} aria-label={`Маршрут ${route.number}`} aria-pressed={routeId === route.id} onClick={() => onRoute(route.id)}>{route.number}</button>)}</ScrollFade>}
{board && city && <Alerts mode={mode} cityId={city.id} online={online} routes={board.routes.filter(route => !routeId || route.id === routeId)} />} {board && city && <Alerts mode={mode} cityId={city.id} online={online} routes={board.routes.filter(route => !routeId || route.id === routeId)} />}
</>} </>}
{board && (outdated || updateFailed) && <p className="savedNotice" role="status">{outdated ? 'Расписание давно не обновлялось. Проверьте изменения при подключении к сети.' : 'Не удалось обновить расписание. Попробуйте ещё раз.'}</p>} {board && (outdated || updateFailed) && <p className="savedNotice" role="status">{outdated ? 'Расписание давно не обновлялось. Проверьте изменения при подключении к сети.' : 'Не удалось обновить расписание. Попробуйте ещё раз.'}</p>}

View File

@@ -1,19 +1,21 @@
import { useLayoutEffect, useId, useRef, type ReactNode } from 'react'; import { useLayoutEffect, useId, useRef, type ReactNode } from 'react';
import { X } from './Icons'; import { X } from './Icons';
export function Dialog({ title, children, onClose }: { title: string; children: ReactNode; onClose: () => void }) { export function Dialog({ title, children, onClose, className = '', hideHeading = false, focusContent = false }: { title: string; children: ReactNode; onClose: () => void; className?: string; hideHeading?: boolean; focusContent?: boolean }) {
const ref = useRef<HTMLDialogElement>(null); const ref = useRef<HTMLDialogElement>(null);
const titleId = useId(); const titleId = useId();
useLayoutEffect(() => { useLayoutEffect(() => {
const dialog = ref.current!; const dialog = ref.current!;
const trigger = document.activeElement instanceof HTMLElement ? document.activeElement : null; const trigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const overflow = document.body.style.overflow;
dialog.showModal(); dialog.showModal();
dialog.querySelector<HTMLElement>('input, select, textarea')?.focus({ preventScroll: true }); document.body.style.overflow = 'hidden';
return () => { dialog.close(); if (trigger?.isConnected) trigger.focus({ preventScroll: true }); }; dialog.querySelector<HTMLElement>(focusContent ? '[tabindex="-1"]' : 'input, select, textarea')?.focus({ preventScroll: true });
return () => { document.body.style.overflow = overflow; dialog.close(); if (trigger?.isConnected) trigger.focus({ preventScroll: true }); };
}, []); }, []);
return <dialog ref={ref} className="appDialog" aria-labelledby={titleId} onCancel={event => { event.preventDefault(); onClose(); }} onClick={event => { return <dialog ref={ref} className={`appDialog ${className}`} aria-labelledby={titleId} onCancel={event => { event.preventDefault(); onClose(); }} onClick={event => {
if (event.target !== event.currentTarget) return; if (event.target !== event.currentTarget) return;
const rect = event.currentTarget.getBoundingClientRect(); const rect = event.currentTarget.getBoundingClientRect();
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose();
}}><div className="dialogBody"><div className="dialogHeading"><h2 id={titleId}>{title}</h2><button className="iconButton" aria-label="Закрыть окно" onClick={onClose}><X size={22} /></button></div>{children}</div></dialog>; }}><div className="dialogBody">{hideHeading ? <h2 id={titleId} className="visuallyHidden">{title}</h2> : <div className="dialogHeading"><h2 id={titleId}>{title}</h2><button className="iconButton" aria-label="Закрыть окно" onClick={onClose}><X size={22} /></button></div>}{children}</div></dialog>;
} }

View File

@@ -4,7 +4,7 @@ import {
Icon20GlobeCrossOutline, Icon20RefreshOutline, Icon20ShareOutline, Icon20GlobeCrossOutline, Icon20RefreshOutline, Icon20ShareOutline,
Icon24Bookmark, Icon24BookmarkOutline, Icon24Cancel, Icon24Bookmark, Icon24BookmarkOutline, Icon24Cancel,
Icon24ChevronDown, Icon24LocationOutline, Icon24SearchOutline, Icon24ChevronDown, Icon24LocationOutline, Icon24SearchOutline,
Icon24Add, Icon24HomeOutline, Icon24WorkOutline, Icon24WarningTriangleOutline, Icon24Add, Icon24MinusOutline, Icon24HomeOutline, Icon24WorkOutline, Icon24WarningTriangleOutline,
Icon28SettingsOutline, Icon28MoonOutline, Icon28SunOutline, Icon28ComputerSmartphoneOutline, Icon28SettingsOutline, Icon28MoonOutline, Icon28SunOutline, Icon28ComputerSmartphoneOutline,
} from '@vkontakte/icons'; } from '@vkontakte/icons';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
@@ -32,6 +32,7 @@ export const ChevronDown = adapt(Icon24ChevronDown);
export const MapPin = adapt(Icon24LocationOutline); export const MapPin = adapt(Icon24LocationOutline);
export const Search = adapt(Icon24SearchOutline); export const Search = adapt(Icon24SearchOutline);
export const Plus = adapt(Icon24Add); export const Plus = adapt(Icon24Add);
export const Minus = adapt(Icon24MinusOutline);
export const Home = adapt(Icon24HomeOutline); export const Home = adapt(Icon24HomeOutline);
export const Work = adapt(Icon24WorkOutline); export const Work = adapt(Icon24WorkOutline);
export const Warning = adapt(Icon24WarningTriangleOutline); export const Warning = adapt(Icon24WarningTriangleOutline);

View File

@@ -0,0 +1,37 @@
import { useLayoutEffect, useRef, type HTMLAttributes } from 'react';
// Fades follow the actual scroll position, including resized or changed lists.
export function ScrollFade({ className = '', children, ...props }: HTMLAttributes<HTMLDivElement>) {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const element = ref.current!;
let active = true;
const update = () => {
if (!active) return;
element.dataset.fadeStart = String(element.scrollLeft > 2);
element.dataset.fadeEnd = String(element.scrollWidth - element.clientWidth - element.scrollLeft > 2);
};
const resize = new ResizeObserver(update);
const observe = () => {
resize.disconnect(); resize.observe(element);
for (const child of element.children) resize.observe(child);
update();
};
const mutation = new MutationObserver(observe);
mutation.observe(element, { childList: true, subtree: true, characterData: true });
const focus = (event: FocusEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement) || !target.matches(':focus-visible')) return;
const outer = element.getBoundingClientRect(), inner = target.getBoundingClientRect();
const padding = 26;
if (inner.left < outer.left + padding && element.scrollLeft > 2) element.scrollLeft += inner.left - outer.left - padding;
else if (inner.right > outer.right - padding) element.scrollLeft += inner.right - outer.right + padding;
update();
};
element.addEventListener('scroll', update, { passive: true });
element.addEventListener('focusin', focus);
observe();
return () => { active = false; resize.disconnect(); mutation.disconnect(); element.removeEventListener('scroll', update); element.removeEventListener('focusin', focus); };
}, []);
return <div {...props} ref={ref} className={`horizontalScrollFade ${className}`}>{children}</div>;
}

View File

@@ -0,0 +1,154 @@
import { lazy, Suspense, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { loadResource } from '../lib/api';
import { countLabel, directionLabel, groupStops } from '../lib/stopGroups';
import { distanceMeters, hasCoordinates } from '../lib/geo';
import { useStopRoutes } from '../lib/useStopRoutes';
import { stopKey } from '../lib/storage';
import { ArrowRight, Bookmark, BusFront, ChevronDown, MapPin, RefreshCw, Search, X } from './Icons';
import { StopList, StopListItem } from './StopList';
import { Dialog } from './Dialog';
import { WalkingDirections } from './WalkingDirections';
import { ScrollFade } from './ScrollFade';
import type { City, Coordinates, SavedStop, Stop, StopDetails } from '../types';
const StopsMap = lazy(() => import('./StopsMap'));
type Props = {
city?: City; cityPicker: ReactNode; stops: Stop[]; selected?: Stop; favorites: SavedStop[];
loading: boolean; error: boolean; online: boolean; mode: 'live' | 'demo'; theme: 'light' | 'dark';
onSelect: (stop: Stop) => void; onSchedule: (stop: Stop, routeId?: number) => void; onFavorite: () => void; onRetry: () => void;
};
export function StopsExplorer({ city, cityPicker, stops, selected, favorites, loading, error, online, mode, theme, onSelect, onSchedule, onFavorite, onRetry }: Props) {
const [view, setView] = useState<'map' | 'list'>('map');
const [search, setSearch] = useState('');
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
const suggestionsId = useId();
const [cardOpen, setCardOpen] = useState(() => !!selected && !matchMedia('(max-width: 760px)').matches);
const [position, setPosition] = useState<Coordinates | null>(null);
const [nearby, setNearby] = useState(false);
const [locating, setLocating] = useState(false);
const [locationMessage, setLocationMessage] = useState('');
const [cardInset, setCardInset] = useState(0);
const [mobile, setMobile] = useState(() => matchMedia('(max-width: 760px)').matches);
const [walkingStop, setWalkingStop] = useState<(Stop & Coordinates) | null>(null);
const request = useRef(0);
const cardRef = useRef<HTMLElement>(null);
const focusCard = useRef(false);
useEffect(() => () => { request.current++; }, []);
useEffect(() => {
const media = matchMedia('(max-width: 760px)');
const update = () => setMobile(media.matches);
media.addEventListener('change', update); return () => media.removeEventListener('change', update);
}, []);
useEffect(() => { if (!mobile) setCardOpen(!!selected); }, [selected?.id, mobile]);
const details = useQuery({ queryKey: ['stop', mode, city?.id, selected?.id], queryFn: () => loadResource<StopDetails>('stop', mode, city?.id, selected?.id), enabled: !!city && !!selected && cardOpen, staleTime: 300000 });
const stop = details.data && details.data.data.id === selected?.id ? details.data.data : selected;
const routes = useStopRoutes(mode, city?.id, stops);
const groups = useMemo(() => groupStops(routes.filtered), [routes.filtered]);
const group = groups.find(group => group.stops.some(stop => stop.id === selected?.id));
const query = search.trim().toLocaleLowerCase('ru');
const rows = useMemo(() => groups.map(group => {
const nearest = position ? group.stops.filter((stop): stop is Stop & Coordinates => hasCoordinates(stop))
.map(stop => ({ stop, distance: distanceMeters(position, stop) })).sort((a, b) => a.distance - b.distance)[0] : undefined;
return { group, nearest };
}).filter(({ group, nearest }) => `${group.name} ${group.stops.map(stop => stop.direction).join(' ')}`.toLocaleLowerCase('ru').includes(query)
&& (!nearby || (nearest && nearest.distance <= 1000)))
.sort((a, b) => nearby ? (a.nearest?.distance ?? Infinity) - (b.nearest?.distance ?? Infinity)
: routes.order.size ? Math.min(...a.group.stops.map(stop => routes.order.get(stop.id) ?? Infinity)) - Math.min(...b.group.stops.map(stop => routes.order.get(stop.id) ?? Infinity))
: a.group.name.localeCompare(b.group.name, 'ru', { numeric: true })), [groups, position, nearby, query, routes.order]);
const points = useMemo(() => rows.flatMap(({ group }) => group.stops), [rows]);
const mapped = routes.filtered.some(hasCoordinates);
const showingMap = view === 'map' && online && !!city && mapped && !loading && !routes.pending;
const favorite = !!city && !!selected && favorites.some(item => item.key === stopKey(mode, city.id, selected.id));
const distance = position && stop && hasCoordinates(stop) ? distanceMeters(position, stop) : undefined;
useEffect(() => {
const card = cardRef.current;
if (!cardOpen || !card || mobile) { setCardInset(0); return; }
let active = true;
const measure = () => { if (active && card.isConnected) setCardInset(Math.ceil(card.getBoundingClientRect().height) + 32); };
const observer = new ResizeObserver(measure); observer.observe(card); measure();
return () => { active = false; observer.disconnect(); };
}, [cardOpen, selected?.id, showingMap, mobile]);
function choose(stop: Stop) {
focusCard.current = true;
setSuggestionsOpen(false); setCardOpen(true); onSelect(stop);
}
useEffect(() => {
if (!cardOpen || !focusCard.current || !cardRef.current) return;
focusCard.current = false;
cardRef.current.focus({ preventScroll: true });
}, [cardOpen, selected?.id, showingMap, mobile]);
function filterRoute(line: string, direction = '') { setCardOpen(false); setSuggestionsOpen(false); routes.change(line, direction); }
async function locate() {
if (!online || locating) return;
if (nearby) { setNearby(false); return; }
if (!navigator.geolocation || !window.isSecureContext) { setLocationMessage('Местоположение недоступно в этом браузере. Найдите остановку по названию.'); return; }
const current = ++request.current;
setLocating(true); setLocationMessage('');
try {
const result = await new Promise<GeolocationPosition>((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 }));
if (current !== request.current) return;
setPosition({ latitude: result.coords.latitude, longitude: result.coords.longitude }); setNearby(true); setCardOpen(false);
if (result.coords.accuracy > 300) setLocationMessage('Местоположение приблизительное. Расстояния указаны по прямой.');
} catch (error) {
if (current !== request.current) return;
const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
setLocationMessage(code === 1 ? 'Доступ к местоположению закрыт. Можно найти остановку по названию.' : 'Не удалось определить местоположение. Попробуйте ещё раз или найдите остановку по названию.');
} finally { if (current === request.current) setLocating(false); }
}
const list = <>
<div className="explorerListHeading"><h2>{nearby ? 'Рядом с вами' : routes.group ? `Остановки №${routes.group.number}` : 'Остановки города'}</h2><span>{countLabel(rows.length, ['остановка', 'остановки', 'остановок'])}</span></div>
{nearby && <p className="explorerHint">В радиусе 1 км · расстояния по прямой</p>}
<StopList label="Остановки" busy={loading || routes.pending}>
{loading || routes.pending ? <div className="listMessage" role="status">{routes.pending ? 'Загружаем остановки маршрута…' : 'Загружаем остановки…'}</div> : routes.failed ? <div className="listMessage"><h3>Остановки маршрута недоступны</h3><p>Попробуйте обновить данные или выберите все маршруты.</p><button className="secondaryButton" onClick={routes.retry}>Повторить</button><button className="textButton" onClick={() => filterRoute('')}>Все маршруты</button></div> : error && !stops.length ? <div className="listMessage"><h3>Остановки пока недоступны</h3><p>Попробуйте обновить список.</p><button className="secondaryButton" onClick={onRetry}>Повторить</button></div> : !rows.length ? <div className="listMessage" role="status"><Search size={25} /><h3>{query ? 'Ничего не нашлось' : nearby ? 'Рядом нет остановок' : 'Пока нет остановок'}</h3><p>{query ? 'Попробуйте другое название.' : nearby ? 'Посмотрите все остановки или выберите другой город.' : 'Выберите другой город.'}</p>{nearby && <button className="textButton" onClick={() => setNearby(false)}>Все остановки <ArrowRight size={17} /></button>}</div> : rows.map(({ group, nearest }) => <StopListItem key={group.key} name={group.name} selected={cardOpen && group.stops.some(stop => stop.id === selected?.id)} favorite={group.stops.some(stop => city && favorites.some(favorite => favorite.key === stopKey(mode, city.id, stop.id)))} distance={nearby ? nearest?.distance : undefined} onSelect={() => choose((nearby ? nearest?.stop : group.stops.find(stop => stop.id === selected?.id)) ?? group.stops.find(stop => stop.routesCount > 0) ?? group.stops[0])} />)}
</StopList>
</>;
return <section className={`stopsExplorer ${showingMap ? 'explorerMapMode' : 'explorerListMode'} ${cardOpen && stop ? 'hasStopCard' : ''}`} aria-label="Поиск остановок">
<div className="explorerToolbar">
<div className="explorerCity">{cityPicker}</div>
<div className="explorerSearchWrap" onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setSuggestionsOpen(false); }} onKeyDown={event => { if (event.key === 'Escape' && suggestionsOpen) { event.preventDefault(); setSuggestionsOpen(false); } }}>
<label className="searchField explorerSearch"><Search size={20} /><input aria-label="Поиск остановки" aria-controls={showingMap && query && suggestionsOpen ? suggestionsId : undefined} type="search" placeholder="Поиск остановки" value={search} onFocus={() => setSuggestionsOpen(true)} onChange={event => { setSearch(event.target.value); setSuggestionsOpen(true); setCardOpen(false); }} autoComplete="off" /></label>
{showingMap && query && suggestionsOpen && <div className="explorerSuggestions" id={suggestionsId} role="region" aria-label="Результаты поиска остановок">{list}</div>}
</div>
<div className="explorerTools"><button className="secondaryButton explorerNearby" aria-label={nearby ? 'Все остановки' : 'Рядом со мной'} title={nearby ? 'Все остановки' : 'Рядом со мной'} aria-pressed={nearby} disabled={!online || !city || locating || !stops.some(hasCoordinates)} onClick={() => void locate()}>{locating ? <RefreshCw size={18} className="spinning" /> : <MapPin size={18} />}<span>{locating ? 'Ищем…' : nearby ? 'Все остановки' : 'Рядом со мной'}</span></button>
<div className="explorerViews" role="group" aria-label="Вид остановок"><button aria-pressed={showingMap} disabled={!online || !mapped || loading} title={!online ? 'Карта доступна при подключении' : undefined} onClick={() => setView('map')}>Карта</button><button aria-pressed={!showingMap} onClick={() => setView('list')}>Список</button></div>
</div>
</div>
<div className="explorerRouteFilters" aria-label="Фильтр по маршруту"><label className="explorerRouteSelect"><BusFront size={18} /><select aria-label="Маршрут" value={routes.line} disabled={routes.catalog.isPending || !routes.groups.length} onChange={event => filterRoute(event.target.value)}><option value="">{routes.catalog.isPending ? 'Загружаем маршруты…' : 'Все маршруты'}</option>{routes.line && !routes.group && <option value={routes.line}>Маршрут {routes.line}</option>}{routes.groups.map(group => <option key={group.key} value={group.key}>№{group.number}</option>)}</select><ChevronDown size={16} /></label>
{routes.group && routes.group.routes.length > 1 && <label className="explorerRouteDirection"><select aria-label="Направление маршрута" value={routes.direction ?? ''} onChange={event => filterRoute(routes.line, event.target.value)}><option value="">Все направления</option>{routes.group.routes.map(route => <option key={route.id} value={route.id}>{route.name}</option>)}</select><ChevronDown size={16} /></label>}
{routes.line && <button className="iconButton" aria-label="Сбросить фильтр маршрута" onClick={() => filterRoute('')}><X size={18} /></button>}
{(routes.catalog.isError || routes.partial) && <p className="routeFilterError" role="status">{routes.partial ? 'Часть направлений недоступна.' : 'Маршруты пока недоступны.'}<button className="textButton" onClick={routes.retry}>Повторить</button></p>}
</div>
{locationMessage && <p className="explorerLocationMessage" role="status">{locationMessage}</p>}
<div className="explorerBody">
{!showingMap && <aside className="explorerCatalog">{list}</aside>}
<div className="explorerMapArea">
{showingMap ? <Suspense fallback={<div className="mapLoading" role="status">Загружаем карту…</div>}><StopsMap city={city!} stops={points} selected={cardOpen ? stop : undefined} position={position} theme={theme} bottomInset={cardInset} fitStopsKey={routes.line ? `${routes.line}:${routes.direction ?? ''}` : undefined} onSelect={choose} onList={() => setView('list')} /></Suspense> : null}
{cardOpen && stop && !mobile ? passport() : !showingMap && rows.length > 0 && !mobile ? <div className="passportEmpty"><MapPin size={32} /><h2>Выберите остановку</h2><p>Посмотрите направления и маршруты, затем откройте расписание.</p></div> : null}
{showingMap && !cardOpen && <p className="mapInstruction">{!rows.length ? nearby ? 'Рядом нет остановок с выбранными фильтрами.' : 'Ничего не нашлось. Попробуйте другое название.' : nearby ? 'Остановки в радиусе 1 км · расстояния по прямой' : 'Нажмите на точку, чтобы выбрать остановку'}</p>}
</div>
</div>
{cardOpen && stop && mobile && <Dialog title={group?.name ?? stop.name} className="stopPassportDialog" hideHeading focusContent onClose={() => setCardOpen(false)}>{passport()}</Dialog>}
{walkingStop && city && <WalkingDirections city={city} stop={walkingStop} online={online} theme={theme} onClose={() => setWalkingStop(null)} />}
</section>;
function passport() {
if (!stop) return null;
return <section className="stopPassport appScrollArea" aria-labelledby="stop-passport-title" ref={cardRef} tabIndex={-1}>
<div className="stopPassportHeading"><div><h2 id="stop-passport-title">{group?.name ?? stop.name}</h2></div><button className="iconButton" aria-label="Закрыть карточку остановки" onClick={() => setCardOpen(false)}><X size={20} /></button></div>
{stop.address && <p className="stopAddress">{stop.address}</p>}
{group && group.stops.length > 1 ? <ScrollFade className="passportDirections appScrollArea" role="group" aria-label="Направления остановки">{group.stops.map((direction, index) => <button key={direction.id} className="routeChip" aria-pressed={direction.id === selected?.id} onClick={() => choose(direction)}>{directionLabel(group, direction, index)}</button>)}</ScrollFade> : stop.direction && <p className="stopDirection">{stop.direction}</p>}
{distance !== undefined && <p className="stopDistanceHint">{distance < 1000 ? `${distance} м` : `${(distance / 1000).toFixed(1)} км`} от вас по прямой</p>}
<div className="passportRoutes"><h3>Маршруты</h3>{details.isPending ? <p role="status">Загружаем маршруты…</p> : details.isError && !details.data ? <p className="passportError">Маршруты пока недоступны. <button className="textButton" onClick={() => void details.refetch()}>Повторить</button></p> : details.data?.data.routes.length ? <div className="passportRouteChips">{details.data.data.routes.map(route => <span className="passportRoute" key={route.id} title={route.name}>{route.number}</span>)}</div> : <p>Маршруты пока не указаны.</p>}</div>
<div className="passportActions"><button className="primaryButton" onClick={() => onSchedule(stop, routes.line ? routes.routeForStop(stop.id) : undefined)}>Открыть расписание <ArrowRight size={18} /></button><button className={`passportFavorite iconButton ${favorite ? 'isFavorite' : ''}`} aria-label={favorite ? 'Удалить остановку из избранного' : 'Добавить остановку в избранное'} aria-pressed={favorite} onClick={onFavorite}><Bookmark size={23} fill={favorite ? 'currentColor' : undefined} /></button></div>
{hasCoordinates(stop) && <button className="textButton passportWalking" disabled={!online || mode === 'demo'} title={mode === 'demo' ? 'Пеший путь доступен для настоящих остановок' : undefined} onClick={() => { if (hasCoordinates(stop)) { setCardOpen(false); setWalkingStop(stop); } }}><MapPin size={17} />Как добраться пешком</button>}
</section>;
}
}

138
src/components/StopsMap.tsx Normal file
View File

@@ -0,0 +1,138 @@
import { useEffect, useRef, useState } from 'react';
import { Map, Marker, setWorkerUrl, type GeoJSONSource, type StyleSpecification } from 'maplibre-gl';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
import 'maplibre-gl/dist/maplibre-gl.css';
import { hasCoordinates } from '../lib/geo';
import { Minus, Plus, MapPin, X } from './Icons';
import type { City, Coordinates, Stop, WalkingRoute } from '../types';
setWorkerUrl(workerUrl);
type Props = { city: City; stops: Stop[]; selected?: Stop; position: Coordinates | null; theme: 'light' | 'dark'; bottomInset: number; walking?: WalkingRoute; fitStopsKey?: string; onSelect: (stop: Stop) => void; onList?: () => void };
export default function StopsMap({ city, stops, selected, position, theme, bottomInset, walking, fitStopsKey, onSelect, onList }: Props) {
const container = useRef<HTMLDivElement>(null);
const mapRef = useRef<Map | null>(null);
const latest = useRef({ stops, selected, onSelect, theme, walking });
latest.current = { stops, selected, onSelect, theme, walking };
const [ready, setReady] = useState(false);
const [problem, setProblem] = useState('');
function updatePoints() {
const map = mapRef.current;
const source = map?.getSource('ridebus-stops') as GeoJSONSource | undefined;
source?.setData({ type: 'FeatureCollection', features: latest.current.stops.filter(hasCoordinates).map(stop => ({
type: 'Feature', id: stop.id, geometry: { type: 'Point', coordinates: [stop.longitude, stop.latitude] },
properties: { id: stop.id, name: stop.name },
})) });
if (map?.getLayer('selected-stop')) map.setFilter('selected-stop', ['==', ['get', 'id'], latest.current.selected?.id ?? -1]);
const foot = map?.getSource('ridebus-walking') as GeoJSONSource | undefined;
foot?.setData({ type: 'FeatureCollection', features: latest.current.walking ? [{ type: 'Feature', properties: {}, geometry: latest.current.walking.geometry }] : [] });
}
useEffect(() => {
if (!container.current) return;
let map: Map;
try {
const first = stops.find(hasCoordinates);
map = new Map({ container: container.current, style: { version: 8, sources: {}, layers: [{ id: 'background', type: 'background', paint: { 'background-color': theme === 'dark' ? '#202735' : '#f3f3fa' } }] },
center: hasCoordinates(city) ? [city.longitude, city.latitude] : first ? [first.longitude!, first.latitude!] : [0, 0],
zoom: 13, minZoom: 2, maxZoom: 19, pitchWithRotate: false, dragRotate: false,
attributionControl: { compact: true }, maplibreLogo: false });
} catch { setProblem('Карта недоступна в этом браузере. Выберите остановку из списка.'); return; }
mapRef.current = map;
map.on('idle', () => container.current?.setAttribute('data-ready', 'true'));
map.getCanvas().setAttribute('aria-label', 'Карта остановок. Для выбора с клавиатуры используйте список.');
map.touchZoomRotate.disableRotation();
map.on('style.load', () => {
if (!map.getSource('ridebus-stops')) {
map.addSource('ridebus-walking', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({ id: 'walking-outline', type: 'line', source: 'ridebus-walking', layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#ffffff', 'line-width': 7 } });
map.addLayer({ id: 'walking-path', type: 'line', source: 'ridebus-walking', layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#528cff', 'line-width': 4, 'line-dasharray': [2, 1] } });
map.addSource('ridebus-stops', { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, cluster: true, clusterRadius: 44, clusterMaxZoom: 13 });
map.addLayer({ id: 'stop-clusters', type: 'circle', source: 'ridebus-stops', filter: ['has', 'point_count'], paint: { 'circle-radius': 20, 'circle-color': '#2c4678', 'circle-stroke-width': 3, 'circle-stroke-color': '#d8e2ff' } });
if (map.getStyle().glyphs) map.addLayer({ id: 'cluster-count', type: 'symbol', source: 'ridebus-stops', filter: ['has', 'point_count'], layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-font': ['Noto Sans Regular'], 'text-size': 13 }, paint: { 'text-color': '#ffffff' } });
map.addLayer({ id: 'stop-points', type: 'circle', source: 'ridebus-stops', filter: ['!', ['has', 'point_count']], paint: { 'circle-radius': 9, 'circle-color': '#2c4678', 'circle-stroke-width': 3, 'circle-stroke-color': '#ffffff' } });
map.addLayer({ id: 'selected-stop', type: 'circle', source: 'ridebus-stops', filter: ['==', ['get', 'id'], -1], paint: { 'circle-radius': 14, 'circle-color': '#ddf9d0', 'circle-stroke-width': 4, 'circle-stroke-color': '#254817' } });
if (map.getStyle().glyphs) map.addLayer({ id: 'stop-names', type: 'symbol', source: 'ridebus-stops', minzoom: 15, filter: ['!', ['has', 'point_count']], layout: { 'text-field': ['get', 'name'], 'text-font': ['Noto Sans Regular'], 'text-size': 13, 'text-anchor': 'top', 'text-offset': [0, 1.4] }, paint: { 'text-color': latest.current.theme === 'dark' ? '#f0f1f5' : '#2c4678', 'text-halo-color': latest.current.theme === 'dark' ? '#202735' : '#ffffff', 'text-halo-width': 2 } });
}
updatePoints(); setReady(true);
});
map.on('click', async event => {
const layers = ['stop-points', 'stop-clusters'].filter(id => map.getLayer(id));
if (!layers.length) return;
const hits = map.queryRenderedFeatures([[event.point.x - 16, event.point.y - 16], [event.point.x + 16, event.point.y + 16]], { layers });
const hit = hits.sort((a, b) => {
const distance = (feature: typeof a) => {
if (feature.geometry.type !== 'Point') return Infinity;
const p = map.project(feature.geometry.coordinates as [number, number]);
return Math.hypot(p.x - event.point.x, p.y - event.point.y);
};
return distance(a) - distance(b);
})[0];
if (!hit || hit.geometry.type !== 'Point') return;
if (hit.properties.cluster) {
try {
const zoom = await (map.getSource('ridebus-stops') as GeoJSONSource).getClusterExpansionZoom(hit.properties.cluster_id);
if (mapRef.current === map) map.easeTo({ center: hit.geometry.coordinates as [number, number], zoom, duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 350 });
} catch { /* The map may be removed or its style replaced during a click. */ }
} else {
const stop = latest.current.stops.find(stop => stop.id === Number(hit.properties.id));
if (stop) latest.current.onSelect(stop);
}
});
map.on('mousemove', event => {
const layers = ['stop-points', 'stop-clusters'].filter(id => map.getLayer(id));
map.getCanvas().style.cursor = layers.length && map.queryRenderedFeatures(event.point, { layers }).length ? 'pointer' : '';
});
map.on('error', () => setProblem('Не удалось загрузить подложку карты. Можно выбрать остановку из списка.'));
const resize = new ResizeObserver(() => { if (mapRef.current === map) map.resize(); });
resize.observe(container.current);
return () => { resize.disconnect(); mapRef.current = null; map.remove(); };
}, [city.id]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const controller = new AbortController();
setProblem('');
// Load the basemap independently of RideBus points; retain points if the provider fails.
fetch(`https://tiles.openfreemap.org/styles/${theme === 'dark' ? 'dark' : 'positron'}`, { signal: controller.signal }).then(async response => {
if (!response.ok) throw new Error();
const style = await response.json() as StyleSpecification;
if (!controller.signal.aborted) map.setStyle(style);
}).catch(() => { if (!controller.signal.aborted) setProblem('Не удалось загрузить подложку карты. Можно выбрать остановку из списка.'); });
return () => controller.abort();
}, [city.id, theme]);
useEffect(() => { if (ready) updatePoints(); }, [stops, selected?.id, ready, walking]);
useEffect(() => {
const map = mapRef.current;
if (!map || !selected || !hasCoordinates(selected)) return;
map.easeTo({ center: [selected.longitude, selected.latitude], zoom: Math.max(map.getZoom(), 16), padding: { top: 24, left: 24, right: 24, bottom: Math.min(bottomInset, Math.max(0, map.getContainer().clientHeight - 110)) }, duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 420 });
}, [selected?.id, selected?.latitude, selected?.longitude, city.id, bottomInset]);
useEffect(() => {
const map = mapRef.current;
if (!map || !position) return;
const element = document.createElement('span'); element.className = 'userLocationDot'; element.setAttribute('aria-label', 'Ваше местоположение');
const marker = new Marker({ element }).setLngLat([position.longitude, position.latitude]).addTo(map);
map.easeTo({ center: [position.longitude, position.latitude], zoom: 15, duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 420 });
return () => { marker.remove(); };
}, [position, city.id]);
useEffect(() => {
const map = mapRef.current;
if (!map || !ready || (!walking && !fitStopsKey)) return;
const coordinates = walking ? [...walking.geometry.coordinates, ...(position ? [[position.longitude, position.latitude] as [number, number]] : []), ...(selected && hasCoordinates(selected) ? [[selected.longitude, selected.latitude] as [number, number]] : [])]
: stops.filter(hasCoordinates).map(stop => [stop.longitude!, stop.latitude!] as [number, number]);
if (!coordinates.length) return;
const bounds = coordinates.reduce((extent, point) => [[Math.min(extent[0][0], point[0]), Math.min(extent[0][1], point[1])], [Math.max(extent[1][0], point[0]), Math.max(extent[1][1], point[1])]] as [[number, number], [number, number]], [coordinates[0], coordinates[0]] as [[number, number], [number, number]]);
map.fitBounds(bounds, { padding: 48, maxZoom: walking ? 17 : 14, duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 420 });
}, [fitStopsKey, walking, ready, city.id]);
return <div className="stopsMapFrame">
<div className="stopsMapCanvas" ref={container} />
<div className="mapZoom"><button aria-label="Приблизить карту" onClick={() => mapRef.current?.zoomIn()}><Plus size={22} /></button><button aria-label="Отдалить карту" onClick={() => mapRef.current?.zoomOut()}><Minus size={22} /></button></div>
{problem && <div className="mapProblem" role="status"><MapPin size={18} /><span>{problem}</span>{onList && <button className="textButton" onClick={onList}>К списку</button>}<button className="iconButton" aria-label="Закрыть сообщение карты" onClick={() => setProblem('')}><X size={18} /></button></div>}
{!ready && !problem && <div className="mapLoading" role="status">Загружаем карту…</div>}
</div>;
}

View File

@@ -0,0 +1,57 @@
import { lazy, Suspense, useEffect, useState } from 'react';
import { DataError, loadWalking } from '../lib/api';
import { walkingLink } from '../lib/geo';
import { Dialog } from './Dialog';
import { ArrowUpRight, MapPin, RefreshCw } from './Icons';
import type { City, Coordinates, Stop, WalkingRoute } from '../types';
const StopsMap = lazy(() => import('./StopsMap'));
export function WalkingDirections({ city, stop, theme, online, onClose }: { city: City; stop: Stop & Coordinates; theme: 'light' | 'dark'; online: boolean; onClose: () => void }) {
const [attempt, setAttempt] = useState(0);
const [position, setPosition] = useState<Coordinates | null>(null);
const [route, setRoute] = useState<WalkingRoute | null>(null);
const [status, setStatus] = useState('Определяем ваше местоположение…');
const [error, setError] = useState('');
const [approximate, setApproximate] = useState(false);
useEffect(() => {
const controller = new AbortController();
setRoute(null); setError(''); setPosition(null); setStatus('Определяем ваше местоположение…');
async function build() {
try {
if (!navigator.onLine) throw new DataError('offline');
if (!navigator.geolocation || !window.isSecureContext) throw new DataError('location_unavailable');
const value = await new Promise<GeolocationPosition>((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 10000, maximumAge: 30000 }));
if (controller.signal.aborted) return;
const origin = { latitude: value.coords.latitude, longitude: value.coords.longitude };
setPosition(origin); setApproximate(value.coords.accuracy > 100); setStatus('Строим пеший маршрут…');
const result = await loadWalking(city.id, stop.id, origin, controller.signal);
if (!controller.signal.aborted) { setRoute(result); setStatus(''); }
} catch (error) {
if (controller.signal.aborted) return;
const code = error instanceof DataError ? error.code : typeof error === 'object' && error !== null && 'code' in error ? error.code : '';
setError(code === 1 ? 'Доступ к местоположению закрыт. Разрешите его в браузере или откройте маршрут в картах.'
: code === 'location_unavailable' ? 'Местоположение недоступно в этом браузере. Можно открыть маршрут в картах.'
: code === 'walking_not_found' ? 'Не удалось найти пеший путь к этой остановке. Попробуйте открыть её в картах.'
: code === 'offline' ? 'Для построения пути нужно подключение к интернету.' : 'Не удалось построить пеший маршрут. Попробуйте ещё раз.');
setStatus('');
}
}
// Delay once so React StrictMode does not open a second permission prompt.
const timer = setTimeout(() => void build(), 0);
return () => { clearTimeout(timer); controller.abort(); };
}, [attempt, city.id, stop.id]);
const minutes = route ? Math.max(1, Math.ceil(route.duration / 60)) : 0;
const duration = minutes >= 60 ? `${Math.floor(minutes / 60)} ч${minutes % 60 ? ` ${minutes % 60} мин` : ''}` : `${minutes} мин`;
return <Dialog title="Пешком к остановке" className="walkingDialog" onClose={onClose}>
<p className="walkingDestination"><MapPin size={19} /><span><strong>{stop.name}</strong>{stop.direction && <small>{stop.direction}</small>}</span></p>
{status && <p className="walkingStatus" role="status"><RefreshCw size={19} className="spinning" />{status}</p>}
{error && <div className="walkingError" role="status"><p>{error}</p><div><button className="secondaryButton" disabled={!online} onClick={() => setAttempt(value => value + 1)}>Повторить</button><a className="textButton" href={walkingLink(stop)} target="_blank" rel="noreferrer">Открыть в картах <ArrowUpRight size={17} /></a></div></div>}
{route && <>
<div className="walkingSummary"><strong>≈ {duration}</strong><span>{route.distance < 1000 ? `${Math.round(route.distance)} м` : `${(route.distance / 1000).toFixed(1)} км`} пешком</span></div>
<div className="walkingMap"><Suspense fallback={<div className="mapLoading" role="status">Загружаем карту…</div>}><StopsMap city={city} stops={[stop]} selected={stop} position={position} theme={theme} bottomInset={0} walking={route} onSelect={() => {}} /></Suspense></div>
<p className="walkingHint">От вашего местоположения до выбранной точки посадки. Время в пути приблизительное.{approximate && ' Местоположение определено неточно.'}</p>
</>}
</Dialog>;
}

View File

@@ -1,6 +1,6 @@
import { db } from './storage'; import { db } from './storage';
import { demoData } from './demo'; import { demoData } from './demo';
import type { NearbyStop, Snapshot } from '../types'; import type { Coordinates, NearbyStop, Snapshot, WalkingRoute } from '../types';
export type ResourceOptions = { routeId?: number; at?: string; tripKey?: string }; export type ResourceOptions = { routeId?: number; at?: string; tripKey?: string };
@@ -49,3 +49,12 @@ export async function loadNearby(mode: 'live' | 'demo', cityId: number, lat: num
// Nearby coordinates and results stay in memory, outside persistent snapshots. // Nearby coordinates and results stay in memory, outside persistent snapshots.
return data as NearbyStop[]; return data as NearbyStop[];
} }
export async function loadWalking(cityId: number, stopId: number, origin: Coordinates, signal: AbortSignal): Promise<WalkingRoute> {
const params = new URLSearchParams({ resource: 'walk', cityId: String(cityId), stopId: String(stopId), lat: origin.latitude.toFixed(6), lon: origin.longitude.toFixed(6) });
const response = await fetch(`/api/data?${params}`, { cache: 'no-store', signal: AbortSignal.any([signal, AbortSignal.timeout(20000)]) });
const data = await response.json();
if (!response.ok) throw new DataError(data.error || 'data_unavailable');
// Walking routes contain the user's origin and never enter persistent snapshots.
return data as WalkingRoute;
}

View File

@@ -1,23 +1,24 @@
import type { Board, City, Journey, ServiceAlert, Stop, Timetable } from '../types'; import type { Board, City, Journey, Route, RouteStops, ServiceAlert, Stop, StopDetails, Timetable } from '../types';
import type { ResourceOptions } from './api'; import type { ResourceOptions } from './api';
const cities: City[] = [ const cities: City[] = [
{ id: 1, name: 'Полоцк', slug: 'polotsk' }, { id: 1, name: 'Полоцк', slug: 'polotsk', latitude: 55.4869, longitude: 28.7856 },
{ id: 2, name: 'Новополоцк', slug: 'novopolotsk' }, { id: 2, name: 'Новополоцк', slug: 'novopolotsk', latitude: 55.5318, longitude: 28.5987 },
]; ];
const stops: Stop[] = [ const stops: Stop[] = [
{ id: 101, name: 'Площадь Свободы', direction: 'В сторону автовокзала', routesCount: 4 }, { id: 101, name: 'Площадь Свободы', direction: 'В сторону автовокзала', routesCount: 4, latitude: 55.4849, longitude: 28.7685 },
{ id: 102, name: 'Площадь Свободы', direction: 'В сторону центра', routesCount: 3 }, { id: 102, name: 'Площадь Свободы', direction: 'В сторону центра', routesCount: 3, latitude: 55.4852, longitude: 28.7691 },
{ id: 103, name: 'Автовокзал', direction: 'В сторону микрорайона Аэропорт', routesCount: 5 }, { id: 103, name: 'Автовокзал', direction: 'В сторону микрорайона Аэропорт', routesCount: 5, latitude: 55.4794, longitude: 28.7851 },
{ id: 104, name: 'Больница', direction: 'В сторону центра', routesCount: 2 }, { id: 104, name: 'Больница', direction: 'В сторону центра', routesCount: 2, latitude: 55.5031, longitude: 28.7854 },
{ id: 105, name: 'Железнодорожный вокзал', direction: 'В сторону площади Свободы', routesCount: 3 }, { id: 105, name: 'Железнодорожный вокзал', direction: 'В сторону площади Свободы', routesCount: 3, latitude: 55.4783, longitude: 28.7782 },
{ id: 106, name: 'Университет', direction: 'В сторону автовокзала', routesCount: 2 }, { id: 106, name: 'Университет', direction: 'В сторону автовокзала', routesCount: 2, latitude: 55.4881, longitude: 28.7585 },
]; ];
export function demoData(resource: string, cityId?: number, stopId?: number, date?: string, options: ResourceOptions = {}): City[] | Stop[] | Board | Timetable | ServiceAlert[] | Journey { export function demoData(resource: string, cityId?: number, stopId?: number, date?: string, options: ResourceOptions = {}): City[] | Stop[] | StopDetails | Route[] | RouteStops | Board | Timetable | ServiceAlert[] | Journey {
if (resource === 'cities') return cities; if (resource === 'cities') return cities;
if (resource === 'stops') return cityId === 2 if (resource === 'stops') return cityId === 2
? stops.map(stop => ({ ...stop, id: stop.id + 100, name: stop.id === 101 ? 'Площадь Строителей' : stop.name })) : stops; ? stops.map(stop => ({ ...stop, id: stop.id + 100, name: stop.id === 101 ? 'Площадь Строителей' : stop.name,
latitude: stop.latitude! + .0449, longitude: stop.longitude! - .1869 })) : stops;
const now = Date.now(); const now = Date.now();
const routes = [ const routes = [
{ id: 1, number: '4', name: 'Автовокзал → Больница' }, { id: 1, number: '4', name: 'Автовокзал → Больница' },
@@ -25,6 +26,15 @@ export function demoData(resource: string, cityId?: number, stopId?: number, dat
{ id: 3, number: '12', name: 'Боровуха → Автовокзал' }, { id: 3, number: '12', name: 'Боровуха → Автовокзал' },
{ id: 4, number: '24', name: 'Центр → Университет' }, { id: 4, number: '24', name: 'Центр → Университет' },
]; ];
if (resource === 'routes') return routes;
if (resource === 'routeStops') return {
route: routes.find(route => route.id === options.routeId)!,
stops: (demoData('stops', cityId) as Stop[]).filter(stop => options.routeId !== 4 || stop.id % 2 === 1).map((stop, index) => ({ ...stop, sequence: index + 1 })),
};
if (resource === 'stop') {
const stop = (demoData('stops', cityId) as Stop[]).find(item => item.id === stopId)!;
return { ...stop, routes: routes.slice(0, stop.id % 2 ? 4 : 3) };
}
if (resource === 'alerts') return cityId === 2 ? [] : [{ if (resource === 'alerts') return cityId === 2 ? [] : [{
id: 1, cityId: 1, type: 'detour', title: 'Изменение маршрутов 4 и 12', id: 1, cityId: 1, type: 'detour', title: 'Изменение маршрутов 4 и 12',
message: 'Пример сообщения: автобусы следуют через улицу Гагарина. Остановка «Больница» временно перенесена к соседнему перекрёстку.', message: 'Пример сообщения: автобусы следуют через улицу Гагарина. Остановка «Больница» временно перенесена к соседнему перекрёстку.',

18
src/lib/geo.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Coordinates } from '../types';
export function hasCoordinates(value: Partial<Coordinates>): value is Coordinates {
return typeof value.latitude === 'number' && Number.isFinite(value.latitude) && Math.abs(value.latitude) <= 90
&& typeof value.longitude === 'number' && Number.isFinite(value.longitude) && Math.abs(value.longitude) <= 180;
}
// Straight-line distance, not an estimate of the walking route.
export function distanceMeters(a: Coordinates, b: Coordinates): number {
const rad = Math.PI / 180;
const dLat = (b.latitude - a.latitude) * rad, dLon = (b.longitude - a.longitude) * rad;
const h = Math.sin(dLat / 2) ** 2 + Math.cos(a.latitude * rad) * Math.cos(b.latitude * rad) * Math.sin(dLon / 2) ** 2;
return Math.round(6371000 * 2 * Math.asin(Math.sqrt(Math.min(1, h))));
}
export function walkingLink(stop: Coordinates): string {
return `https://www.google.com/maps/dir/?api=1&destination=${stop.latitude},${stop.longitude}&travelmode=walking`;
}

23
src/lib/routeGroups.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { Route, RouteStops, Stop } from '../types';
export const routeNumberKey = (number: string) => number.trim().replace(/^№\s*/, '').normalize('NFKC').toLocaleUpperCase('ru');
export function groupRoutes(routes: Route[]) {
const groups = new Map<string, { key: string; number: string; routes: Route[] }>();
for (const route of routes) {
const key = routeNumberKey(route.number);
const group = groups.get(key) ?? { key, number: route.number, routes: [] };
if (!group.routes.some(value => value.id === route.id)) group.routes.push(route);
groups.set(key, group);
}
return [...groups.values()].sort((a, b) => a.key.localeCompare(b.key, 'ru', { numeric: true }));
}
export function stopsForRoutes(catalog: Stop[], routes: RouteStops[]): Stop[] {
const stops = new Map<number, Stop>();
const known = new Map(catalog.map(stop => [stop.id, stop]));
for (const route of routes) for (const stop of route.stops) {
if (!stops.has(stop.id)) stops.set(stop.id, { ...stop, ...known.get(stop.id) });
}
return [...stops.values()];
}

41
src/lib/useStopRoutes.ts Normal file
View File

@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import { useQueries, useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { loadResource } from './api';
import { groupRoutes, routeNumberKey, stopsForRoutes } from './routeGroups';
import { positiveParam } from './links';
import type { Route, RouteStops, Stop } from '../types';
export function useStopRoutes(mode: 'live' | 'demo', cityId: number | undefined, stops: Stop[]) {
const [params, setParams] = useSearchParams();
const line = routeNumberKey(params.get('line') ?? '');
const direction = positiveParam(params.get('direction'));
const catalog = useQuery({ queryKey: ['routes', mode, cityId], queryFn: () => loadResource<Route[]>('routes', mode, cityId), enabled: !!cityId, staleTime: 300000 });
const groups = useMemo(() => groupRoutes(catalog.data?.data ?? []), [catalog.data]);
const group = groups.find(group => group.key === line);
const selectedRoutes = group?.routes.filter(route => !direction || route.id === direction) ?? [];
const requests = useQueries({ queries: selectedRoutes.map(route => ({
queryKey: ['routeStops', mode, cityId, route.id],
queryFn: () => loadResource<RouteStops>('routeStops', mode, cityId, undefined, undefined, { routeId: route.id }),
enabled: !!cityId, staleTime: 300000,
})) });
const data = requests.flatMap(query => query.data ? [query.data.data] : []);
const pending = !!line && (catalog.isPending || requests.some(query => query.isPending));
const unknown = !!line && !catalog.isPending && (!group || !selectedRoutes.length);
const failed = !!line && !pending && (unknown || !data.length && requests.some(query => query.isError));
const partial = data.length > 0 && requests.some(query => query.isError);
const filtered = line ? stopsForRoutes(stops, data) : stops;
const order = new Map<number, number>();
if (selectedRoutes.length === 1 && data[0]) for (const stop of data[0].stops) if (!order.has(stop.id)) order.set(stop.id, stop.sequence);
function change(nextLine: string, nextDirection = '') {
const next = new URLSearchParams(params);
next.delete('line'); next.delete('direction');
if (nextLine) next.set('line', nextLine);
if (nextLine && nextDirection) next.set('direction', nextDirection);
setParams(next);
}
function retry() { void catalog.refetch(); requests.forEach(query => { void query.refetch(); }); }
const routeForStop = (id: number) => data.find(route => route.stops.some(stop => stop.id === id))?.route.id;
return { catalog, groups, group, line, direction, filtered, order, pending, failed, partial, change, retry, routeForStop };
}

View File

@@ -5,6 +5,7 @@
@use './departures'; @use './departures';
@use './navigation'; @use './navigation';
@use './discovery'; @use './discovery';
@use './stops';
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; font-family: 'Manrope Variable', 'Manrope', sans-serif; color: var(--ink); background: var(--page); font-size: 16px; line-height: 1.5; font-weight: 500; -webkit-font-smoothing: antialiased; } body { margin: 0; font-family: 'Manrope Variable', 'Manrope', sans-serif; color: var(--ink); background: var(--page); font-size: 16px; line-height: 1.5; font-weight: 500; -webkit-font-smoothing: antialiased; }
@@ -19,6 +20,14 @@ button { color: inherit; }
h1, h2, h3, p { margin: 0; } h1, h2, h3, p { margin: 0; }
svg, .vkuiIcon { flex-shrink: 0; } svg, .vkuiIcon { flex-shrink: 0; }
.app { max-width: 1240px; margin: auto; padding: 0 48px; } .app { max-width: 1240px; margin: auto; padding: 0 48px; }
.scheduleMain { padding-bottom: 40px; }
.horizontalScrollFade { --scroll-fade-start: #000; --scroll-fade-end: #000; }
.horizontalScrollFade[data-fade-start='true'] { --scroll-fade-start: transparent; }
.horizontalScrollFade[data-fade-end='true'] { --scroll-fade-end: transparent; }
.horizontalScrollFade[data-fade-start='true'], .horizontalScrollFade[data-fade-end='true'] {
-webkit-mask-image: linear-gradient(to right, var(--scroll-fade-start), #000 24px, #000 calc(100% - 24px), var(--scroll-fade-end));
mask-image: linear-gradient(to right, var(--scroll-fade-start), #000 24px, #000 calc(100% - 24px), var(--scroll-fade-end));
}
.header { display: flex; align-items: center; justify-content: space-between; height: 112px; gap: 24px; border-bottom: 1px solid var(--line); } .header { display: flex; align-items: center; justify-content: space-between; height: 112px; gap: 24px; border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 10px; } .brand { display: flex; align-items: center; gap: 10px; }
.brand > span:first-of-type { font-size: 28px; letter-spacing: -.5px; font-weight: 800; } .brand > span:first-of-type { font-size: 28px; letter-spacing: -.5px; font-weight: 800; }
@@ -265,4 +274,5 @@ svg, .vkuiIcon { flex-shrink: 0; }
@include departures.components; @include departures.components;
@include navigation.components; @include navigation.components;
@include discovery.components; @include discovery.components;
@include stops.components;
@include motion.components; @include motion.components;

View File

@@ -1,6 +1,10 @@
export type City = { id: number; name: string; slug: string }; export type Coordinates = { latitude: number; longitude: number };
export type Stop = { id: number; name: string; direction: string; routesCount: number }; export type City = { id: number; name: string; slug: string } & Partial<Coordinates>;
export type Stop = { id: number; name: string; direction: string; routesCount: number; address?: string } & Partial<Coordinates>;
export type StopDetails = Stop & { routes: Route[] };
export type Route = { id: number; number: string; name: string }; export type Route = { id: number; number: string; name: string };
export type RouteStops = { route: Route; stops: (Stop & { sequence: number })[] };
export type WalkingRoute = { distance: number; duration: number; geometry: { type: 'LineString'; coordinates: [number, number][] } };
export type Departure = { routeId: number; routeNumber: string; direction: string; scheduledTime: string; scheduledAt: string; note?: string; tripKey?: string }; export type Departure = { routeId: number; routeNumber: string; direction: string; scheduledTime: string; scheduledAt: string; note?: string; tripKey?: string };
export type Board = { departures: Departure[]; routes: { id: number; number: string; name: string }[]; updatedAt: string; partial: boolean; date?: string; isHoliday?: boolean; holidayName?: string | null }; export type Board = { departures: Departure[]; routes: { id: number; number: string; name: string }[]; updatedAt: string; partial: boolean; date?: string; isHoliday?: boolean; holidayName?: string | null };
export type Timetable = Board & { date: string; isHoliday: boolean; holidayName: string | null }; export type Timetable = Board & { date: string; isHoliday: boolean; holidayName: string | null };

58
test/routing.test.mjs Normal file
View File

@@ -0,0 +1,58 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRideBusClient, normalizeRoutes, normalizeRouteStops, parseRequest } from '../server/ridebus.mjs';
import { normalizeWalking } from '../server/walking.mjs';
import { groupRoutes, stopsForRoutes } from '../src/lib/routeGroups.ts';
const geometry = { type: 'LineString', coordinates: [[28.783, 55.486], [28.7855, 55.4865]] };
const foot = { code: 'Ok', routes: [{ distance: 230, duration: 165.4, geometry, legs: [{ private: 'unneeded' }] }] };
test('route and walking parameters require bounded coordinates and fixed identifiers', () => {
assert.deepEqual(parseRequest(new URLSearchParams('resource=routes&cityId=1')), { resource: 'routes', cityId: 1 });
assert.deepEqual(parseRequest(new URLSearchParams('resource=routeStops&cityId=1&routeId=10')), { resource: 'routeStops', cityId: 1, routeId: 10 });
assert.deepEqual(parseRequest(new URLSearchParams('resource=walk&cityId=1&stopId=28&lat=0&lon=0')), { resource: 'walk', cityId: 1, stopId: 28, lat: 0, lon: 0 });
for (const params of ['resource=routeStops&cityId=1&routeId=../10', 'resource=walk&cityId=1&stopId=28&lat=91&lon=28', 'resource=walk&cityId=1&stopId=28&lat=55&lon=-181', 'resource=walk&cityId=1&lat=55&lon=28', 'resource=routes&cityId=-1']) assert.throws(() => parseRequest(new URLSearchParams(params)), error => error.status === 400);
});
test('route numbers group directions but distinct boarding points retain their identities', () => {
const routes = normalizeRoutes([{ id: 10, route_number: '4', name: 'А → Б' }, { id: 11, route_number: '№4', name: 'Б → А' }, { id: 12, route_number: '10', name: 'В → Г' }, { id: 13, route_number: '2', name: 'А → Г', is_active: false }]);
assert.deepEqual(groupRoutes(routes).map(group => [group.key, group.routes.map(route => route.id)]), [['4', [10, 11]], ['10', [12]]]);
const detail = normalizeRouteStops({ id: 10, route_number: '4', name: 'А → Б', stops: [
{ id: 1, name: 'Вокзал', description: 'В центр', sequence: 2, latitude: 55, longitude: 28 },
{ id: 2, name: 'Вокзал', description: 'Обратно', sequence: 1, latitude: 55.001, longitude: 28 },
] });
assert.deepEqual(detail.stops.map(stop => stop.id), [2, 1]);
const filtered = stopsForRoutes([{ ...detail.stops[0], direction: 'Уточнённое направление' }], [detail, detail]);
assert.deepEqual(filtered.map(stop => stop.id), [2, 1]);
assert.equal(filtered[0].direction, 'Уточнённое направление');
assert.equal(filtered[1].latitude, 55);
});
test('walking geometry and metrics are validated without inventing a path on failure', () => {
assert.deepEqual(normalizeWalking(foot), { geometry, distance: 230, duration: 165.4 });
assert.throws(() => normalizeWalking({ code: 'NoRoute' }), error => error.status === 404 && error.code === 'walking_not_found');
for (const route of [{ ...foot.routes[0], distance: null }, { ...foot.routes[0], duration: -1 }, { ...foot.routes[0], geometry: { type: 'LineString', coordinates: [[28, 55], [999, 55]] } }, { ...foot.routes[0], geometry: { type: 'LineString', coordinates: [[28, 55]] } }]) assert.throws(() => normalizeWalking({ code: 'Ok', routes: [route] }), error => error.status === 502);
});
test('walking goes through the verified foot endpoint and never enters origin caches', async () => {
const calls = [];
const client = createRideBusClient({ apiKey: 'private-key', fetchData: async (url, options) => {
calls.push([url, options]);
return { ok: true, json: async () => url.pathname.startsWith('/routing/') ? foot : { id: 28, latitude: 55.4869, longitude: 28.7856 } };
} });
const input = { resource: 'walk', cityId: 1, stopId: 28, lat: 55.486, lon: 28.783 };
await client.get(input); await client.get(input); await client.get({ ...input, lat: 55.487 });
const paths = calls.filter(([url]) => url.pathname.startsWith('/routing/'));
assert.equal(paths.length, 3);
assert.equal(paths[0][0].pathname, '/routing/foot/route/v1/foot/28.783000,55.486000;28.785600,55.486900');
assert.equal(paths[0][0].searchParams.get('geometries'), 'geojson');
assert.equal(paths[0][1].headers['X-City-Id'], '1');
assert.equal(paths[0][1].headers['X-API-Key'], 'private-key');
assert.equal(calls.filter(([url]) => url.pathname.startsWith('/api/v1/stops/')).length, 1);
});
test('a no-segment HTTP response maps to a safe walking error', async () => {
const client = createRideBusClient({ apiKey: 'key', fetchData: async url => url.pathname.startsWith('/routing/')
? { ok: false, status: 400, json: async () => ({ code: 'NoSegment', message: 'internal details' }) }
: { ok: true, json: async () => ({ latitude: 55, longitude: 28 }) } });
await assert.rejects(client.get({ resource: 'walk', cityId: 1, stopId: 28, lat: 55, lon: 28 }), error => error.code === 'walking_not_found' && !error.message.includes('internal'));
});

55
test/stops.test.mjs Normal file
View File

@@ -0,0 +1,55 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { coordinates } from '../server/coordinates.mjs';
import { createRideBusClient, normalizeCities, normalizeStop, normalizeStops, parseRequest } from '../server/ridebus.mjs';
import { distanceMeters, hasCoordinates, walkingLink } from '../src/lib/geo.ts';
test('map coordinates accept numeric strings and preserve missing/invalid pairs as missing', () => {
assert.deepEqual(coordinates('55.517553', 28.776448), { latitude: 55.517553, longitude: 28.776448 });
assert.deepEqual(coordinates(0, 0), { latitude: 0, longitude: 0 });
for (const [lat, lon] of [[null, 28], ['', 28], [55, undefined], [91, 28], [55, -181], [true, 28], ['bad', 28], [NaN, 28]]) {
assert.deepEqual(coordinates(lat, lon), {});
assert.equal(hasCoordinates({ latitude: lat, longitude: lon }), false);
}
assert.equal(hasCoordinates({ latitude: 0, longitude: 0 }), true);
assert.deepEqual(normalizeCities([{ id: 1, name: 'Полоцк', center_lat: '55.4869', center_lon: '28.7856' }])[0].latitude, 55.4869);
});
test('stop catalog retains platform coordinates and detail exposes only active distinct routes', () => {
const raw = { id: 95, name: '11 Июля', latitude: 55.517553, longitude: 28.776448, address: ' улица Ленина ', description: ' на Лозовку ', routes_count: '4' };
assert.equal(normalizeStops([raw])[0].address, 'улица Ленина');
assert.equal(normalizeStops([raw])[0].longitude, 28.776448);
const detail = normalizeStop({ ...raw, secret: 'hidden', routes: [
{ id: 2, route_number: '12', name: 'Центр', is_active: true },
{ id: 1, route_number: '4', name: 'Вокзал' },
{ id: 1, route_number: '4', name: 'Вокзал' },
{ id: 3, route_number: '2', name: 'Больница', is_active: false },
] });
assert.deepEqual(detail.routes.map(route => route.number), ['4', '12']);
assert.equal(detail.routesCount, 2);
assert.equal('secret' in detail, false);
assert.throws(() => normalizeStop({ ...raw, routes: null }), error => error.status === 502);
});
test('stop details validate identifiers, coalesce loads and isolate city caches', async () => {
assert.deepEqual(parseRequest(new URLSearchParams('resource=stop&cityId=1&stopId=95')), { resource: 'stop', cityId: 1, stopId: 95 });
for (const input of ['resource=stop&cityId=1', 'resource=stop&cityId=1&stopId=-1', 'resource=stop&cityId=1&stopId=../1']) assert.throws(() => parseRequest(new URLSearchParams(input)), error => error.status === 400);
const calls = [];
const client = createRideBusClient({ apiKey: 'private-key', fetchData: async (url, options) => {
calls.push([url.pathname, options.headers['X-City-Id']]);
return { ok: true, json: async () => ({ id: 95, name: '11 Июля', routes: [] }) };
} });
const input = { resource: 'stop', cityId: 1, stopId: 95 };
await Promise.all([client.get(input), client.get(input)]); await client.get(input); await client.get({ ...input, cityId: 2 });
assert.deepEqual(calls, [['/api/v1/stops/95', '1'], ['/api/v1/stops/95', '2']]);
});
test('nearby distances use geographic coordinates and walking links use the selected platform', () => {
const a = { latitude: 55.4849, longitude: 28.7685 }, b = { latitude: 55.4852, longitude: 28.7691 };
assert.equal(distanceMeters(a, a), 0);
assert.ok(distanceMeters(a, b) > 40 && distanceMeters(a, b) < 60);
assert.equal(distanceMeters(a, b), distanceMeters(b, a));
const link = new URL(walkingLink(b));
assert.equal(link.searchParams.get('destination'), '55.4852,28.7691');
assert.equal(link.searchParams.get('travelmode'), 'walking');
});

View File

@@ -27,6 +27,8 @@ export default defineConfig({
}, },
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,woff2,png,svg,webmanifest}'], globPatterns: ['**/*.{js,css,html,woff2,png,svg,webmanifest}'],
// The map is online-only and loads on demand; keep the offline shell small.
globIgnores: ['**/StopsMap-*', '**/maplibre-gl-worker-*'],
navigateFallbackDenylist: [/^\/api\//], navigateFallbackDenylist: [/^\/api\//],
// API snapshots are kept in IndexedDB, keyed by city and stop. // API snapshots are kept in IndexedDB, keyed by city and stop.
// The service worker only caches the application shell. // The service worker only caches the application shell.