diff --git a/README.md b/README.md index 2321cf6..b2602aa 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,48 @@ ## Возможности - Четыре раздела: «Главная», «Остановки», «Избранное», «Настройки». - На главной компактный выбор города и остановки; полный каталог открывается - отдельно или в окне выбора. Общий список и поиск рядом переключаются, + На главной компактный выбор города и остановки; окно выбора содержит + каталог. Общий список и поиск рядом переключаются, чтобы не занимать экран двумя списками. На телефоне выбор остановки закрывает окно и плавно переводит к отправлениям, в том числе для выбранной даты. - Знакомство при первом посещении: пять подсказок с подсветкой настоящих функций главной страницы. Можно пропустить, пройти с клавиатуры или повторить из настроек. Завершение/пропуск сохраняются в этом браузере. - Города из 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/ShareDeparture.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/FeatureDiscovery.tsx`, `src/_discovery.scss` — подсказки первого входа. - `src/_navigation.scss`, `src/_departures.scss` — разделы, компактный выбор и отправления. @@ -226,7 +264,16 @@ docker compose down - `server/features.mjs` — нормализация сообщений и ближайших остановок. Прокси использует `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/routes/:id/schedule/today?date=YYYY-MM-DD`. Экран рейса дополнительно использует `GET /api/v1/routes/:id`; @@ -263,6 +310,12 @@ Playwright проверяет desktop и экран iPhone 13 в Chromium: по выбор/сохранение темы, системные изменения, синхронизацию вкладок, первое отображение до запуска JavaScript-приложения и настройки без сети, четыре раздела, компактность выбора на ширинах 320–1440 px, +выбор точки на карте, отдельные направления карточки остановки, +пешую ссылку, сохранение выбранной стороны в избранном, +полную ширину карточки в списке, мобильный диалог, номера/направления и порядок +остановок маршрута, сохранение фильтра без сети, частичный отказ направления, +пешую геометрию/метрики, отказ в разрешении и отсутствие сохранённого начала/пути, +список/просмотренную карточку остановки без сети и темы карты, выбор остановки с прокруткой и уменьшением движения, переход на нужную дату, один статус при отключении сети и предупреждение после реального старения данных, первый вход с подсветкой, повторное обучение и подсказки на экране 320 × 568, diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 905d13d..d04dfe8 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -5,9 +5,10 @@ test('stop search, route filter and city-scoped favorites survive reload', async page.on('pageerror', error => errors.push(error.message)); await page.goto('/stops?demo=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 page.locator('.stopItem').first().click(); + await page.getByRole('button', { name: 'Открыть расписание', exact: true }).click(); await expect(page.locator('.departureRow')).toHaveCount(8); const directions = page.getByRole('group', { name: 'Направления', exact: true }); 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 }) => { await page.route('**/api/data?**', route => { 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 })); return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }); }); await page.goto('/stops'); + await page.getByRole('button', { name: 'Список', exact: true }).click(); 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 expect(page.locator('.stopItem').last()).toBeVisible(); await expect(page.locator('main')).not.toContainText('Показаны первые'); diff --git a/e2e/features.spec.ts b/e2e/features.spec.ts index 09327b6..6579350 100644 --- a/e2e/features.spec.ts +++ b/e2e/features.spec.ts @@ -8,7 +8,7 @@ async function mockLive(page: Page, extra: (resource: string, params: URLSearchP const params = new URL(route.request().url()).searchParams; const resource = params.get('resource')!; 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) }); }); } @@ -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'); 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); expect(searches).toBe(0); 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 } }); }); 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('combobox', { name: 'Ваш город' }).selectOption('2'); + await page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click(); + await page.getByRole('dialog', { name: 'Выбрать остановку', exact: true }).getByRole('combobox', { name: 'Ваш город' }).selectOption('2'); await expect(page.locator('.nearbyResults')).toHaveCount(0); }); diff --git a/e2e/interaction.spec.ts b/e2e/interaction.spec.ts index 5797bd7..d4dd9ad 100644 --- a/e2e/interaction.spec.ts +++ b/e2e/interaction.spec.ts @@ -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) => { 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="Остановки"]'); await expect(all.locator('.stopItem')).toHaveCount(5); 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); 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('.activeStop')).toHaveText('Площадь Свободы120 м'); expect(await nearby.evaluate(styles)).toEqual(normalStyles); await nearby.getByRole('button', { name: /Больница/ }).click(); - await expect(nearby.locator('.activeStop')).toContainText('Больница'); - await page.getByRole('button', { name: 'Все остановки', exact: true }).click(); + await expect(page.locator('#board-title')).toHaveText('Больница'); + await page.getByRole('button', { name: 'Выбрать остановку', exact: true }).click(); 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' }); }); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index 77ffe45..dd7cbdf 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -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'); }); -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.goto('/stops?demo=1&city=1&view=day&date=2026-10-08'); await page.getByRole('searchbox').fill('Площадь'); 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).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.locator('.hourDeparture')).toHaveCount(32); await expect.poll(() => page.locator('#board-title').evaluate(element => element.getBoundingClientRect().top)).toBeLessThan(180); diff --git a/e2e/routing.spec.ts b/e2e/routing.spec.ts new file mode 100644 index 0000000..ccb9706 --- /dev/null +++ b/e2e/routing.spec.ts @@ -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 = { 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(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(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); +}); diff --git a/e2e/stops.spec.ts b/e2e/stops.spec.ts new file mode 100644 index 0000000..bd3591b --- /dev/null +++ b/e2e/stops.spec.ts @@ -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(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' }); +}); diff --git a/package-lock.json b/package-lock.json index 1379ba0..0d5f426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@vkontakte/icons": "^3.75.0", "dexie": "^4.4.6", "dexie-react-hooks": "^4.4.0", + "maplibre-gl": "^6.13.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-router-dom": "^7.18.4" @@ -2308,6 +2309,92 @@ "@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": { "version": "1.5.1", "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, "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": { "version": "26.6.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", @@ -4133,6 +4226,15 @@ "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": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -4519,6 +4621,12 @@ "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": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -5021,6 +5129,12 @@ "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": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -5711,6 +5825,12 @@ "dev": true, "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": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5747,6 +5867,12 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -6057,6 +6183,39 @@ "@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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6083,6 +6242,15 @@ "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": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -6100,6 +6268,12 @@ "dev": true, "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": { "version": "3.3.20", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", @@ -6251,6 +6425,18 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6339,6 +6525,12 @@ "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": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", @@ -6352,6 +6544,18 @@ "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": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", @@ -6531,7 +6735,6 @@ "version": "2.0.2", "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==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6559,6 +6762,15 @@ "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": { "version": "1.2.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", @@ -7217,6 +7429,12 @@ "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": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 2612b5b..163c8d7 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@vkontakte/icons": "^3.75.0", "dexie": "^4.4.6", "dexie-react-hooks": "^4.4.0", + "maplibre-gl": "^6.13.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-router-dom": "^7.18.4" diff --git a/qa/routes-filter-live-desktop.png b/qa/routes-filter-live-desktop.png new file mode 100644 index 0000000..728fc25 Binary files /dev/null and b/qa/routes-filter-live-desktop.png differ diff --git a/qa/routes-filter-live-mobile.png b/qa/routes-filter-live-mobile.png new file mode 100644 index 0000000..5cc162b Binary files /dev/null and b/qa/routes-filter-live-mobile.png differ diff --git a/qa/routes-filter-live-narrow.png b/qa/routes-filter-live-narrow.png new file mode 100644 index 0000000..5a1d1f5 Binary files /dev/null and b/qa/routes-filter-live-narrow.png differ diff --git a/qa/stop-dialog-live-mobile.png b/qa/stop-dialog-live-mobile.png new file mode 100644 index 0000000..a852f00 Binary files /dev/null and b/qa/stop-dialog-live-mobile.png differ diff --git a/qa/stops-full-pane-live-desktop.png b/qa/stops-full-pane-live-desktop.png new file mode 100644 index 0000000..7e3fc8f Binary files /dev/null and b/qa/stops-full-pane-live-desktop.png differ diff --git a/qa/stops-map-live-dark.png b/qa/stops-map-live-dark.png new file mode 100644 index 0000000..76498eb Binary files /dev/null and b/qa/stops-map-live-dark.png differ diff --git a/qa/stops-map-live-desktop.png b/qa/stops-map-live-desktop.png new file mode 100644 index 0000000..c563601 Binary files /dev/null and b/qa/stops-map-live-desktop.png differ diff --git a/qa/stops-map-live-mobile.png b/qa/stops-map-live-mobile.png new file mode 100644 index 0000000..d4f4083 Binary files /dev/null and b/qa/stops-map-live-mobile.png differ diff --git a/qa/stops-passport-live-mobile.png b/qa/stops-passport-live-mobile.png new file mode 100644 index 0000000..b669fcd Binary files /dev/null and b/qa/stops-passport-live-mobile.png differ diff --git a/qa/walking-foot-live-mobile.png b/qa/walking-foot-live-mobile.png new file mode 100644 index 0000000..2a85dd0 Binary files /dev/null and b/qa/walking-foot-live-mobile.png differ diff --git a/server/coordinates.mjs b/server/coordinates.mjs new file mode 100644 index 0000000..b318a5b --- /dev/null +++ b/server/coordinates.mjs @@ -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 }; +} diff --git a/server/features.mjs b/server/features.mjs index 002224d..5838131 100644 --- a/server/features.mjs +++ b/server/features.mjs @@ -1,4 +1,5 @@ import { ApiError } from './errors.mjs'; +import { coordinates } from './coordinates.mjs'; const routeNumber = value => typeof value === 'string' ? value.replace(/[\s№]/g, '').toLocaleUpperCase('ru') : ''; const positive = value => Number.isSafeInteger(Number(value)) && Number(value) > 0; @@ -32,6 +33,6 @@ export function normalizeNearby(raw) { const distance = Number(stop?.distance_meters); if (!stop || !positive(stop.id) || typeof stop.name !== 'string' || !stop.name.trim() || stop.distance_meters == null || !Number.isFinite(distance) || distance < 0) throw new ApiError(502); return { id: Number(stop.id), name: stop.name.trim(), direction: typeof stop.description === 'string' ? stop.description.trim() : '', - routesCount: Number(stop.routes_count) || 0, distance: Math.round(distance) }; + routesCount: Number(stop.routes_count) || 0, distance: Math.round(distance), ...coordinates(stop.latitude, stop.longitude) }; }).sort((a, b) => a.distance - b.distance); } diff --git a/server/ridebus.mjs b/server/ridebus.mjs index ee2cfea..323a065 100644 --- a/server/ridebus.mjs +++ b/server/ridebus.mjs @@ -1,6 +1,8 @@ import { ApiError } from './errors.mjs'; import { journeyStops, previousDate, routeDepartures, tripIdentity, validDate } from './timetable.mjs'; import { normalizeAlerts, normalizeNearby } from './features.mjs'; +import { coordinates } from './coordinates.mjs'; +import { normalizeWalking } from './walking.mjs'; export { ApiError } from './errors.mjs'; function positiveId(value) { @@ -13,15 +15,18 @@ function positiveId(value) { export function parseRequest(params) { const resource = params.get('resource'); if (resource === 'cities') return { resource }; - if (!['stops', 'departures', 'timetable', 'alerts', 'nearby', 'journey'].includes(resource)) throw new ApiError(400, 'invalid_request'); + if (!['stops', 'stop', 'routes', 'routeStops', 'walk', 'departures', 'timetable', 'alerts', 'nearby', 'journey'].includes(resource)) throw new ApiError(400, 'invalid_request'); const cityId = positiveId(params.get('cityId')); if (resource === 'alerts') return { resource, cityId }; - if (resource === 'nearby') { + if (resource === 'routes') return { resource, cityId }; + if (resource === 'routeStops') return { resource, cityId, routeId: positiveId(params.get('routeId')) }; + if (resource === 'nearby' || resource === 'walk') { const coordinate = (name, limit) => { const value = params.get(name); if (!value || !/^-?\d+(?:\.\d+)?$/.test(value) || Math.abs(Number(value)) > limit) throw new ApiError(400, 'invalid_request'); return Number(value); }; + if (resource === 'walk') return { resource, cityId, stopId: positiveId(params.get('stopId')), lat: coordinate('lat', 90), lon: coordinate('lon', 180) }; const radius = params.has('radius') ? positiveId(params.get('radius')) : 1000; if (radius < 100 || radius > 2000) throw new ApiError(400, 'invalid_request'); return { resource, cityId, lat: coordinate('lat', 90), lon: coordinate('lon', 180), radius }; @@ -65,7 +70,8 @@ const id = value => { export function normalizeCities(raw) { return list(raw).filter(item => item?.is_active !== false).map(item => { const city = record(item); - return { id: id(city.id), name: text(city.name), slug: typeof city.slug === 'string' ? city.slug : '' }; + return { id: id(city.id), name: text(city.name), slug: typeof city.slug === 'string' ? city.slug : '', + ...coordinates(city.center_lat, city.center_lon) }; }); } @@ -76,10 +82,36 @@ export function normalizeStops(raw) { id: id(stop.id), name: text(stop.name), direction: typeof stop.description === 'string' ? stop.description.trim() : '', routesCount: Math.max(0, Number(stop.routes_count) || 0), + address: typeof stop.address === 'string' ? stop.address.trim() : '', + ...coordinates(stop.latitude, stop.longitude), }; }); } +export function normalizeStop(raw) { + const stop = record(raw); + const routes = [...new Map(list(stop.routes).filter(route => route?.is_active !== false).map(route => { + const value = { id: id(route.id), number: text(route.route_number), name: text(route.name) }; + return [value.id, value]; + })).values()].sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true })); + return { ...normalizeStops([{ ...stop, routes_count: routes.length }])[0], routes }; +} + +export function normalizeRoutes(raw) { + return [...new Map(list(raw).filter(route => route?.is_active !== false).map(route => { + const value = { id: id(route.id), number: text(route.route_number), name: text(route.name) }; + return [value.id, value]; + })).values()].sort((a, b) => a.number.localeCompare(b.number, 'ru', { numeric: true }) || a.id - b.id); +} + +export function normalizeRouteStops(raw) { + const route = record(raw); + const stops = list(route.stops).filter(stop => stop?.is_active !== false).map(stop => ({ + ...normalizeStops([stop])[0], sequence: id(stop.sequence), + })).sort((a, b) => a.sequence - b.sequence); + return { route: { id: id(route.id), number: text(route.route_number), name: text(route.name) }, stops }; +} + export function normalizeDepartures(raw, now = Date.now()) { const departures = []; const routes = []; @@ -130,7 +162,7 @@ export function createRideBusClient({ const rawPending = new Map(); const queue = []; let active = 0; - async function upstream(path, cityId, allowMissing = false) { + async function upstream(path, cityId, allowMissing = false, allowRoutingError = false) { if (!apiKey?.trim()) throw new ApiError(503, 'api_not_configured'); if (active >= 6) await new Promise(resolve => queue.push(resolve)); else active++; @@ -141,7 +173,7 @@ export function createRideBusClient({ headers, redirect: 'error', signal: AbortSignal.timeout(12000), }); if (allowMissing && response.status === 404) return null; - if (!response.ok) throw new ApiError(response.status === 404 ? 404 : 502); + if (!response.ok && !(allowRoutingError && response.status === 400)) throw new ApiError(response.status === 404 ? 404 : 502); return await response.json(); } catch (error) { throw error instanceof ApiError ? error : new ApiError(502); } finally { @@ -205,6 +237,16 @@ export function createRideBusClient({ updatedAt: new Date(now()).toISOString(), tripKey: departure.tripKey }; } async function load(input) { + if (input.resource === 'routes') return normalizeRoutes(await cachedUpstream('api/v1/routes', input.cityId)); + if (input.resource === 'routeStops') return normalizeRouteStops(await cachedUpstream(`api/v1/routes/${input.routeId}`, input.cityId)); + if (input.resource === 'walk') { + const stop = record(await cachedUpstream(`api/v1/stops/${input.stopId}`, input.cityId)); + const target = coordinates(stop.latitude, stop.longitude); + if (target.latitude === undefined) throw new ApiError(404, 'walking_not_found'); + const points = `${input.lon.toFixed(6)},${input.lat.toFixed(6)};${target.longitude.toFixed(6)},${target.latitude.toFixed(6)}`; + return normalizeWalking(await upstream(`routing/foot/route/v1/foot/${points}?alternatives=false&steps=false&overview=full&geometries=geojson&skip_waypoints=true`, input.cityId, false, true)); + } + if (input.resource === 'stop') return normalizeStop(await cachedUpstream(`api/v1/stops/${input.stopId}`, input.cityId)); if (input.resource === 'timetable') return timetable(input); if (input.resource === 'journey') return journey(input); if (input.resource === 'alerts') return normalizeAlerts(await upstream('api/v1/alerts/active', input.cityId), input.cityId); @@ -218,14 +260,14 @@ export function createRideBusClient({ return { async get(input) { // Location searches are ephemeral: coordinates and results never enter caches. - if (input.resource === 'nearby') return load(input); + if (['nearby', 'walk'].includes(input.resource)) return load(input); const key = `${input.resource}:${input.cityId ?? ''}:${input.stopId ?? ''}:${input.date ?? ''}:${input.routeId ?? ''}:${input.at ?? ''}:${input.tripKey ?? ''}`; const entry = cache.get(key); if (entry && entry.expires > now()) return entry.data; if (pending.has(key)) return pending.get(key); const request = load(input).then(data => { if (cache.size >= 200) cache.delete(cache.keys().next().value); - const ttl = input.resource === 'cities' ? 3600000 : ['stops', 'timetable', 'journey'].includes(input.resource) ? 300000 : 30000; + const ttl = input.resource === 'cities' ? 3600000 : ['stops', 'stop', 'routes', 'routeStops', 'timetable', 'journey'].includes(input.resource) ? 300000 : 30000; cache.set(key, { data, expires: now() + ttl }); return data; }).finally(() => pending.delete(key)); diff --git a/server/walking.mjs b/server/walking.mjs new file mode 100644 index 0000000..45c9e14 --- /dev/null +++ b/server/walking.mjs @@ -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 } }; +} diff --git a/src/App.tsx b/src/App.tsx index 1d6a3a8..43dfdaa 100644 --- a/src/App.tsx +++ b/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 { useQuery } from '@tanstack/react-query'; 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 type { Board, City, Stop } from './types'; +const StopsExplorer = lazy(() => import('./components/StopsExplorer').then(module => ({ default: module.StopsExplorer }))); + function useOnline() { const [online, setOnline] = useState(navigator.onLine); 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 board = useQuery({ queryKey: ['departures', mode, cityId, stop?.id], - queryFn: () => loadResource('departures', mode, cityId, stop?.id), enabled: !!cityId && !!stop && schedulePage && view === 'next', + queryFn: () => loadResource('departures', mode, cityId, stop?.id), enabled: !!cityId && !!stop && homePage && view === 'next', staleTime: 30000, refetchInterval: online ? 60000 : false, }); const timetable = useQuery({ queryKey: ['timetable', mode, cityId, stop?.id, date], queryFn: () => loadResource('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 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 boardPending = (view === 'day' ? timetable : board).isPending; 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 || (boardTarget.routeId !== undefined && (routeId !== boardTarget.routeId || view !== 'next'))) return; const target = boardRef.current; @@ -113,7 +115,7 @@ export default function App() { target.focus({ preventScroll: true }); target.scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' }); 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) { setCityChoice(value); setStopChoice(''); setSearch(''); setSelectionScroll(null); @@ -125,7 +127,8 @@ export default function App() { setPickerOpen(false); if (fromList && cityId && matchMedia('(max-width: 760px)').matches) setSelectionScroll({ cityId, stopId: 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) { setView(nextView); setDate(nextDate); @@ -134,6 +137,12 @@ export default function App() { if (nextView === 'day') { nextParams.set('view', 'day'); nextParams.set('date', nextDate); } 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) { const nextParams = new URLSearchParams(params); if (route) nextParams.set('route', String(route)); else nextParams.delete('route'); @@ -190,7 +199,7 @@ export default function App() { {navigation(false)}
-
+
{mode === 'demo' &&
Демонстрация · остановки и отправления — примеры
} {!online &&
Нет подключения · загруженное расписание доступно
} {settingsPage ? : routePage ? : aboutPage ? : favoritesPage ? <> @@ -198,23 +207,25 @@ export default function App() { {favorites.length === 0 && trips.length === 0 ?

Здесь будут ваши остановки

Нажмите на закладку рядом с названием остановки
или сохраните поездку с нужным маршрутом.

Найти остановку
:
{[...favorites].sort((a, b) => b.addedAt - a.addedAt).map(item => void db.favorites.delete(item.key).catch(() => setNotice('Не удалось удалить остановку'))} />)}
} + : stopsPage ? <> +

Остановки

Найдите место посадки и нужное направление.

+ Загружаем остановки…}> selectStop(value)} onSchedule={openStopSchedule} onFavorite={() => void toggleFavorite()} onRetry={() => { void cities.refetch(); if (cityId) void stops.refetch(); }} /> : <> {homePage ?

Расписание
в вашем кармане.

Найдите свою остановку и отправление.

:

Остановки

Поиск по названию или рядом с вами.

} {homePage && trips.length > 0 &&
} -
- {stopsPage ?

Найти остановку

{stopCatalog()}
- :

Куда едем?

{cityPicker('home-city')} +
+

Куда едем?

{cityPicker('home-city')}
Все остановки
{cities.isError && !city && {stopMessage()}} -
} +
void toggleFavorite()} online={online} view={view} date={date} onView={selectView} routeId={routeId} onRoute={selectRoute} city={city} mode={mode} onTripSaved={() => setNotice('Поездка сохранена')} onNotice={setNotice} boardRef={boardRef} />
{homePage && trips.length === 0 &&

Мои поездки

Сохраните поездку домой или на работу в карточке отправлений.

} {pickerOpen && setPickerOpen(false)}>{stopCatalog()}} }
-
ridebusРасписание в вашем кармане
Настройки О проекте Сайт RideBus {(import.meta.env.DEV || mode === 'demo') && }
+
ridebusРасписание в вашем кармане
О проекте Сайт RideBus {(import.meta.env.DEV || mode === 'demo') && }
{navigation(true)} {notice &&
{notice}
} diff --git a/src/_navigation.scss b/src/_navigation.scss index c4bd490..fd2cb46 100644 --- a/src/_navigation.scss +++ b/src/_navigation.scss @@ -34,6 +34,7 @@ .desktopNav a { min-width: 44px; min-height: 44px; padding: 10px 12px; } } @media (max-width: 760px) { + .scheduleMain { padding-bottom: 30px; } .homeHero, .catalogHero { padding: 26px 0 22px; } .homeHero h1, .catalogHero h1 { font-size: 30px; } .homeHero p, .catalogHero p { font-size: 15px; } diff --git a/src/_stops.scss b/src/_stops.scss new file mode 100644 index 0000000..88aa440 --- /dev/null +++ b/src/_stops.scss @@ -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; } } +} diff --git a/src/components/Departures.tsx b/src/components/Departures.tsx index 3a87b13..b8125f1 100644 --- a/src/components/Departures.tsx +++ b/src/components/Departures.tsx @@ -11,6 +11,7 @@ import { QuickTripForm } from './QuickTrips'; import { HourlyTimetable } from './HourlyTimetable'; import { Alerts } from './ServiceAlerts'; 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 }: { stop?: Stop; group?: StopGroup; onDirection: (stop: Stop) => void; @@ -37,7 +38,7 @@ export function Departures({ stop, group, onDirection, query, favorite, onFavori {stop && <>
{city && !!board?.routes.length && }{fullDay &&
{ if (event.target.value && event.target.validity.valid) onView('day', event.target.value); }} />
}
{fullDay &&

{formatDate(`${date}T12:00:00+03:00`)}{board ? ` · ${countLabel(departures.length, ['рейс', 'рейса', 'рейсов'])}` : ''}{board?.isHoliday && ` · ${board.holidayName || 'праздничный день'}`}

} - {board && board.routes.length > 0 &&
{board.routes.map(route => )}
} + {board && board.routes.length > 0 && {board.routes.map(route => )}} {board && city && !routeId || route.id === routeId)} />} } {board && (outdated || updateFailed) &&

{outdated ? 'Расписание давно не обновлялось. Проверьте изменения при подключении к сети.' : 'Не удалось обновить расписание. Попробуйте ещё раз.'}

} diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 2216ee3..d9b9297 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -1,19 +1,21 @@ import { useLayoutEffect, useId, useRef, type ReactNode } from 'react'; 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(null); const titleId = useId(); useLayoutEffect(() => { const dialog = ref.current!; const trigger = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const overflow = document.body.style.overflow; dialog.showModal(); - dialog.querySelector('input, select, textarea')?.focus({ preventScroll: true }); - return () => { dialog.close(); if (trigger?.isConnected) trigger.focus({ preventScroll: true }); }; + document.body.style.overflow = 'hidden'; + dialog.querySelector(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 { event.preventDefault(); onClose(); }} onClick={event => { + return { event.preventDefault(); onClose(); }} onClick={event => { if (event.target !== event.currentTarget) return; const rect = event.currentTarget.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose(); - }}>

{title}

{children}
; + }}>
{hideHeading ?

{title}

:

{title}

}{children}
; } diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 7c6d1bc..c478f29 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -4,7 +4,7 @@ import { Icon20GlobeCrossOutline, Icon20RefreshOutline, Icon20ShareOutline, Icon24Bookmark, Icon24BookmarkOutline, Icon24Cancel, Icon24ChevronDown, Icon24LocationOutline, Icon24SearchOutline, - Icon24Add, Icon24HomeOutline, Icon24WorkOutline, Icon24WarningTriangleOutline, + Icon24Add, Icon24MinusOutline, Icon24HomeOutline, Icon24WorkOutline, Icon24WarningTriangleOutline, Icon28SettingsOutline, Icon28MoonOutline, Icon28SunOutline, Icon28ComputerSmartphoneOutline, } from '@vkontakte/icons'; import type { ComponentType } from 'react'; @@ -32,6 +32,7 @@ export const ChevronDown = adapt(Icon24ChevronDown); export const MapPin = adapt(Icon24LocationOutline); export const Search = adapt(Icon24SearchOutline); export const Plus = adapt(Icon24Add); +export const Minus = adapt(Icon24MinusOutline); export const Home = adapt(Icon24HomeOutline); export const Work = adapt(Icon24WorkOutline); export const Warning = adapt(Icon24WarningTriangleOutline); diff --git a/src/components/ScrollFade.tsx b/src/components/ScrollFade.tsx new file mode 100644 index 0000000..0e984ec --- /dev/null +++ b/src/components/ScrollFade.tsx @@ -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) { + const ref = useRef(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
{children}
; +} diff --git a/src/components/StopsExplorer.tsx b/src/components/StopsExplorer.tsx new file mode 100644 index 0000000..1d2f075 --- /dev/null +++ b/src/components/StopsExplorer.tsx @@ -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(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(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('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((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 = <> +

{nearby ? 'Рядом с вами' : routes.group ? `Остановки №${routes.group.number}` : 'Остановки города'}

{countLabel(rows.length, ['остановка', 'остановки', 'остановок'])}
+ {nearby &&

В радиусе 1 км · расстояния по прямой

} + + {loading || routes.pending ?
{routes.pending ? 'Загружаем остановки маршрута…' : 'Загружаем остановки…'}
: routes.failed ?

Остановки маршрута недоступны

Попробуйте обновить данные или выберите все маршруты.

: error && !stops.length ?

Остановки пока недоступны

Попробуйте обновить список.

: !rows.length ?

{query ? 'Ничего не нашлось' : nearby ? 'Рядом нет остановок' : 'Пока нет остановок'}

{query ? 'Попробуйте другое название.' : nearby ? 'Посмотрите все остановки или выберите другой город.' : 'Выберите другой город.'}

{nearby && }
: rows.map(({ group, nearest }) => 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])} />)} +
+ ; + + return
+
+
{cityPicker}
+
{ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setSuggestionsOpen(false); }} onKeyDown={event => { if (event.key === 'Escape' && suggestionsOpen) { event.preventDefault(); setSuggestionsOpen(false); } }}> + + {showingMap && query && suggestionsOpen &&
{list}
} +
+
+
+
+
+
+ {routes.group && routes.group.routes.length > 1 && } + {routes.line && } + {(routes.catalog.isError || routes.partial) &&

{routes.partial ? 'Часть направлений недоступна.' : 'Маршруты пока недоступны.'}

} +
+ {locationMessage &&

{locationMessage}

} +
+ {!showingMap && } +
+ {showingMap ? Загружаем карту…
}> setView('list')} /> : null} + {cardOpen && stop && !mobile ? passport() : !showingMap && rows.length > 0 && !mobile ?

Выберите остановку

Посмотрите направления и маршруты, затем откройте расписание.

: null} + {showingMap && !cardOpen &&

{!rows.length ? nearby ? 'Рядом нет остановок с выбранными фильтрами.' : 'Ничего не нашлось. Попробуйте другое название.' : nearby ? 'Остановки в радиусе 1 км · расстояния по прямой' : 'Нажмите на точку, чтобы выбрать остановку'}

} +
+ + {cardOpen && stop && mobile && setCardOpen(false)}>{passport()}} + {walkingStop && city && setWalkingStop(null)} />} +
; + + function passport() { + if (!stop) return null; + return
+

{group?.name ?? stop.name}

+ {stop.address &&

{stop.address}

} + {group && group.stops.length > 1 ? {group.stops.map((direction, index) => )} : stop.direction &&

{stop.direction}

} + {distance !== undefined &&

{distance < 1000 ? `${distance} м` : `${(distance / 1000).toFixed(1)} км`} от вас по прямой

} +

Маршруты

{details.isPending ?

Загружаем маршруты…

: details.isError && !details.data ?

Маршруты пока недоступны.

: details.data?.data.routes.length ?
{details.data.data.routes.map(route => {route.number})}
:

Маршруты пока не указаны.

}
+
+ {hasCoordinates(stop) && } +
; + } +} diff --git a/src/components/StopsMap.tsx b/src/components/StopsMap.tsx new file mode 100644 index 0000000..472f0de --- /dev/null +++ b/src/components/StopsMap.tsx @@ -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(null); + const mapRef = useRef(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
+
+
+ {problem &&
{problem}{onList && }
} + {!ready && !problem &&
Загружаем карту…
} +
; +} diff --git a/src/components/WalkingDirections.tsx b/src/components/WalkingDirections.tsx new file mode 100644 index 0000000..474508c --- /dev/null +++ b/src/components/WalkingDirections.tsx @@ -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(null); + const [route, setRoute] = useState(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((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 +

{stop.name}{stop.direction && {stop.direction}}

+ {status &&

{status}

} + {error &&

{error}

Открыть в картах
} + {route && <> +
≈ {duration}{route.distance < 1000 ? `${Math.round(route.distance)} м` : `${(route.distance / 1000).toFixed(1)} км`} пешком
+
Загружаем карту…
}> {}} />
+

От вашего местоположения до выбранной точки посадки. Время в пути приблизительное.{approximate && ' Местоположение определено неточно.'}

+ } + ; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 277d20e..13334e7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,6 @@ import { db } from './storage'; 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 }; @@ -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. return data as NearbyStop[]; } + +export async function loadWalking(cityId: number, stopId: number, origin: Coordinates, signal: AbortSignal): Promise { + 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; +} diff --git a/src/lib/demo.ts b/src/lib/demo.ts index 4511b7d..5faad92 100644 --- a/src/lib/demo.ts +++ b/src/lib/demo.ts @@ -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'; const cities: City[] = [ - { id: 1, name: 'Полоцк', slug: 'polotsk' }, - { id: 2, name: 'Новополоцк', slug: 'novopolotsk' }, + { id: 1, name: 'Полоцк', slug: 'polotsk', latitude: 55.4869, longitude: 28.7856 }, + { id: 2, name: 'Новополоцк', slug: 'novopolotsk', latitude: 55.5318, longitude: 28.5987 }, ]; const stops: Stop[] = [ - { id: 101, name: 'Площадь Свободы', direction: 'В сторону автовокзала', routesCount: 4 }, - { id: 102, name: 'Площадь Свободы', direction: 'В сторону центра', routesCount: 3 }, - { id: 103, name: 'Автовокзал', direction: 'В сторону микрорайона Аэропорт', routesCount: 5 }, - { id: 104, name: 'Больница', direction: 'В сторону центра', routesCount: 2 }, - { id: 105, name: 'Железнодорожный вокзал', direction: 'В сторону площади Свободы', routesCount: 3 }, - { id: 106, name: 'Университет', direction: 'В сторону автовокзала', routesCount: 2 }, + { id: 101, name: 'Площадь Свободы', direction: 'В сторону автовокзала', routesCount: 4, latitude: 55.4849, longitude: 28.7685 }, + { id: 102, name: 'Площадь Свободы', direction: 'В сторону центра', routesCount: 3, latitude: 55.4852, longitude: 28.7691 }, + { id: 103, name: 'Автовокзал', direction: 'В сторону микрорайона Аэропорт', routesCount: 5, latitude: 55.4794, longitude: 28.7851 }, + { id: 104, name: 'Больница', direction: 'В сторону центра', routesCount: 2, latitude: 55.5031, longitude: 28.7854 }, + { id: 105, name: 'Железнодорожный вокзал', direction: 'В сторону площади Свободы', routesCount: 3, latitude: 55.4783, longitude: 28.7782 }, + { 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 === '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 routes = [ { 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: 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 ? [] : [{ id: 1, cityId: 1, type: 'detour', title: 'Изменение маршрутов 4 и 12', message: 'Пример сообщения: автобусы следуют через улицу Гагарина. Остановка «Больница» временно перенесена к соседнему перекрёстку.', diff --git a/src/lib/geo.ts b/src/lib/geo.ts new file mode 100644 index 0000000..a1e13f7 --- /dev/null +++ b/src/lib/geo.ts @@ -0,0 +1,18 @@ +import type { Coordinates } from '../types'; + +export function hasCoordinates(value: Partial): 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`; +} diff --git a/src/lib/routeGroups.ts b/src/lib/routeGroups.ts new file mode 100644 index 0000000..3bb70ae --- /dev/null +++ b/src/lib/routeGroups.ts @@ -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(); + 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(); + 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()]; +} diff --git a/src/lib/useStopRoutes.ts b/src/lib/useStopRoutes.ts new file mode 100644 index 0000000..45439e7 --- /dev/null +++ b/src/lib/useStopRoutes.ts @@ -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('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', 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(); + 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 }; +} diff --git a/src/styles.scss b/src/styles.scss index 663f7bb..4b5a3f4 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -5,6 +5,7 @@ @use './departures'; @use './navigation'; @use './discovery'; +@use './stops'; * { 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; } @@ -19,6 +20,14 @@ button { color: inherit; } h1, h2, h3, p { margin: 0; } svg, .vkuiIcon { flex-shrink: 0; } .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); } .brand { display: flex; align-items: center; gap: 10px; } .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 navigation.components; @include discovery.components; +@include stops.components; @include motion.components; diff --git a/src/types.ts b/src/types.ts index 42e8038..d57f6bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,10 @@ -export type City = { id: number; name: string; slug: string }; -export type Stop = { id: number; name: string; direction: string; routesCount: number }; +export type Coordinates = { latitude: number; longitude: number }; +export type City = { id: number; name: string; slug: string } & Partial; +export type Stop = { id: number; name: string; direction: string; routesCount: number; address?: string } & Partial; +export type StopDetails = Stop & { routes: Route[] }; 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 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 }; diff --git a/test/routing.test.mjs b/test/routing.test.mjs new file mode 100644 index 0000000..2c635e9 --- /dev/null +++ b/test/routing.test.mjs @@ -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')); +}); diff --git a/test/stops.test.mjs b/test/stops.test.mjs new file mode 100644 index 0000000..2605c5f --- /dev/null +++ b/test/stops.test.mjs @@ -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'); +}); diff --git a/vite.config.ts b/vite.config.ts index 4028dcf..acfa3f6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,6 +27,8 @@ export default defineConfig({ }, workbox: { 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\//], // API snapshots are kept in IndexedDB, keyed by city and stop. // The service worker only caches the application shell.