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.
59
README.md
@@ -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,
|
||||||
|
|||||||
@@ -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('Показаны первые');
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
BIN
qa/routes-filter-live-desktop.png
Normal file
|
After Width: | Height: | Size: 335 KiB |
BIN
qa/routes-filter-live-mobile.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
qa/routes-filter-live-narrow.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
qa/stop-dialog-live-mobile.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
qa/stops-full-pane-live-desktop.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
qa/stops-map-live-dark.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
qa/stops-map-live-desktop.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
qa/stops-map-live-mobile.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
qa/stops-passport-live-mobile.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
qa/walking-foot-live-mobile.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
10
server/coordinates.mjs
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 } };
|
||||||
|
}
|
||||||
35
src/App.tsx
@@ -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>}
|
||||||
|
|||||||
@@ -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
@@ -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; } }
|
||||||
|
}
|
||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
37
src/components/ScrollFade.tsx
Normal 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>;
|
||||||
|
}
|
||||||
154
src/components/StopsExplorer.tsx
Normal 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
@@ -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>;
|
||||||
|
}
|
||||||
57
src/components/WalkingDirections.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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');
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||