feat: add StopsMap and WalkingDirections components for enhanced map functionality
- Implemented StopsMap component to display bus stops on a map using MapLibre GL. - Integrated walking directions feature in WalkingDirections component to calculate routes from user location to selected bus stop. - Added geo utility functions for coordinate validation and distance calculation. - Created route grouping and stop filtering logic in routeGroups module. - Developed custom hooks for managing stop routes with React Query. - Added tests for routing and stop functionalities to ensure reliability.
This commit is contained in:
35
src/App.tsx
35
src/App.tsx
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { 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<Board>('departures', mode, cityId, stop?.id), enabled: !!cityId && !!stop && schedulePage && view === 'next',
|
||||
queryFn: () => loadResource<Board>('departures', mode, cityId, stop?.id), enabled: !!cityId && !!stop && homePage && view === 'next',
|
||||
staleTime: 30000, refetchInterval: online ? 60000 : false,
|
||||
});
|
||||
const timetable = useQuery({
|
||||
queryKey: ['timetable', mode, cityId, stop?.id, date],
|
||||
queryFn: () => loadResource<Board>('timetable', mode, cityId, stop?.id, date),
|
||||
enabled: !!cityId && !!stop && schedulePage && view === 'day', staleTime: 300000,
|
||||
enabled: !!cityId && !!stop && homePage && view === 'day', staleTime: 300000,
|
||||
});
|
||||
const key = cityId && stop ? stopKey(mode, cityId, stop.id) : '';
|
||||
const 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)}
|
||||
<div className="headerActions"><PwaControls /></div>
|
||||
</header>
|
||||
<main id="main">
|
||||
<main id="main" className={schedulePage ? 'scheduleMain' : undefined}>
|
||||
{mode === 'demo' && <div className="demoBanner" role="status"><span>Демонстрация · остановки и отправления — примеры</span><button onClick={() => switchMode(false)}>К реальному расписанию <ArrowRight size={15} /></button></div>}
|
||||
{!online && <div className="offlineBanner" role="status"><WifiOff size={17} /><span>Нет подключения · загруженное расписание доступно</span></div>}
|
||||
{settingsPage ? <Settings choice={theme.choice} resolved={theme.resolved} saved={theme.saved} onChange={theme.changeChoice} scheduleLink={`/${location.search}`} onDiscovery={restartDiscovery} /> : routePage ? <RoutePage online={online} onNotice={setNotice} /> : aboutPage ? <About urlSuffix={urlSuffix} /> : favoritesPage ? <>
|
||||
@@ -198,23 +207,25 @@ export default function App() {
|
||||
<QuickTrips trips={trips} online={online} onNotice={setNotice} />
|
||||
{favorites.length === 0 && trips.length === 0 ? <section className="favoritesEmpty"><span className="emptyIcon"><Bookmark size={32} /></span><h2>Здесь будут ваши остановки</h2><p>Нажмите на закладку рядом с названием остановки<br />или сохраните поездку с нужным маршрутом.</p><NavLink className="primaryButton" to={`/${urlSuffix}`}>Найти остановку <ArrowRight size={18} /></NavLink></section>
|
||||
: <div className="favoritesGrid">{[...favorites].sort((a, b) => b.addedAt - a.addedAt).map(item => <FavoriteCard key={item.key} stop={item} online={online} onRemove={() => void db.favorites.delete(item.key).catch(() => setNotice('Не удалось удалить остановку'))} />)}</div>}
|
||||
</> : stopsPage ? <>
|
||||
<section className="catalogHero explorerHero"><h1>Остановки</h1><p>Найдите место посадки и нужное направление.</p></section>
|
||||
<Suspense fallback={<div className="listMessage" role="status">Загружаем остановки…</div>}><StopsExplorer key={`${mode}:${cityId ?? ''}`} city={city} cityPicker={cityPicker('explorer-city')} stops={allStops} selected={stop} favorites={favorites} loading={cities.isPending || (!!cityId && stops.isPending)} error={cities.isError || stops.isError} online={online} mode={mode} theme={theme.resolved} onSelect={value => selectStop(value)} onSchedule={openStopSchedule} onFavorite={() => void toggleFavorite()} onRetry={() => { void cities.refetch(); if (cityId) void stops.refetch(); }} /></Suspense>
|
||||
</> : <>
|
||||
{homePage ? <section className="homeHero"><h1>Расписание<br /><span>в вашем кармане.</span></h1><p>Найдите свою остановку и отправление.</p></section> : <section className="catalogHero"><h1>Остановки</h1><p>Поиск по названию или рядом с вами.</p></section>}
|
||||
{homePage && trips.length > 0 && <div data-discovery="quick-trips"><QuickTrips trips={trips} online={online} onNotice={setNotice} /></div>}
|
||||
<div className={`workspace ${homePage ? 'homeWorkspace' : 'stopsWorkspace'}`}>
|
||||
{stopsPage ? <section className="stopsCard catalogCard" aria-labelledby="stops-title"><div className="sectionHeading"><h2 id="stops-title">Найти остановку</h2><MapPin size={22} /></div>{stopCatalog()}</section>
|
||||
: <section className="stopsCard compactPicker" aria-labelledby="stops-title"><div className="sectionHeading"><h2 id="stops-title">Куда едем?</h2></div>{cityPicker('home-city')}
|
||||
<div className="workspace homeWorkspace">
|
||||
<section className="stopsCard compactPicker" aria-labelledby="stops-title"><div className="sectionHeading"><h2 id="stops-title">Куда едем?</h2></div>{cityPicker('home-city')}
|
||||
<button className="stopPickerButton" data-discovery="stop-picker" aria-label="Выбрать остановку" onClick={() => openPicker()}><Search size={20} /><span>{selectedGroup?.name ?? stop?.name ?? 'Выбрать остановку'}</span><ChevronDown size={18} /></button>
|
||||
<div className="pickerShortcuts"><NavLink to={`/stops${location.search}`}>Все остановки <ArrowRight size={17} /></NavLink><button className="textButton" aria-label="Остановки рядом" disabled={!online || !cityId} onClick={() => openPicker(true)}><MapPin size={17} />Рядом</button></div>
|
||||
{cities.isError && !city && <StopList label="Остановки">{stopMessage()}</StopList>}
|
||||
</section>}
|
||||
</section>
|
||||
<Departures stop={stop} group={selectedGroup} onDirection={selectStop} query={view === 'day' ? timetable : board} favorite={isFavorite} onFavorite={() => void toggleFavorite()} online={online} view={view} date={date} onView={selectView} routeId={routeId} onRoute={selectRoute} city={city} mode={mode} onTripSaved={() => setNotice('Поездка сохранена')} onNotice={setNotice} boardRef={boardRef} />
|
||||
</div>
|
||||
{homePage && trips.length === 0 && <section className="emptyTrips"><Home size={25} /><div data-discovery="quick-trips"><h2>Мои поездки</h2><p>Сохраните поездку домой или на работу в карточке отправлений.</p></div></section>}
|
||||
{pickerOpen && <Dialog title="Выбрать остановку" onClose={() => setPickerOpen(false)}>{stopCatalog()}</Dialog>}
|
||||
</>}
|
||||
</main>
|
||||
<footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><NavLink to={`/settings${location.search}`}>Настройки <SettingsIcon size={18} /></NavLink><NavLink to={`/about${urlSuffix}`}>О проекте <ArrowRight size={18} /></NavLink><a href="https://ridebus.by" target="_blank" rel="noreferrer">Сайт RideBus <ArrowUpRight size={20} /></a>{(import.meta.env.DEV || mode === 'demo') && <button onClick={() => switchMode(mode !== 'demo')}>{mode === 'demo' ? 'Реальное расписание' : 'Посмотреть пример'}</button>}</div></footer>
|
||||
<footer className="footer"><span>ridebus<small>Расписание в вашем кармане</small></span><div><NavLink to={`/about${urlSuffix}`}>О проекте <ArrowRight size={18} /></NavLink><a href="https://ridebus.by" target="_blank" rel="noreferrer">Сайт RideBus <ArrowUpRight size={20} /></a>{(import.meta.env.DEV || mode === 'demo') && <button onClick={() => switchMode(mode !== 'demo')}>{mode === 'demo' ? 'Реальное расписание' : 'Посмотреть пример'}</button>}</div></footer>
|
||||
{navigation(true)}
|
||||
<FeatureDiscovery home={homePage} restartKey={discoveryRestart} />
|
||||
{notice && <div className="toast" role="status">{notice}<button aria-label="Закрыть уведомление" onClick={() => setNotice('')}><X size={16} /></button></div>}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
.desktopNav a { min-width: 44px; min-height: 44px; padding: 10px 12px; }
|
||||
}
|
||||
@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; }
|
||||
|
||||
153
src/_stops.scss
Normal file
153
src/_stops.scss
Normal file
@@ -0,0 +1,153 @@
|
||||
@mixin components {
|
||||
.explorerHero { padding-top: 28px; padding-bottom: 24px; }
|
||||
.stopsExplorer { border: 1px solid var(--line); background: var(--lavender); border-radius: var(--card-radius); overflow: hidden; color: var(--blue); }
|
||||
.explorerToolbar { display: grid; grid-template-columns: 180px minmax(170px, 1fr) auto; gap: 12px; padding: 18px; align-items: center; border-bottom: 1px solid var(--subtle-line); }
|
||||
.explorerTools { display: flex; gap: 10px; align-items: center; }
|
||||
.explorerSearchWrap { position: relative; min-width: 0; }
|
||||
.explorerSearch { width: 100%; }
|
||||
.explorerSuggestions { position: absolute; z-index: 15; top: calc(100% + 8px); left: 0; right: 0; padding: 14px; border: 1px solid var(--control-border); border-radius: 20px; background: var(--surface); box-shadow: 0 12px 32px #172d6024; }
|
||||
.explorerSuggestions .stopsList { max-height: min(280px, 40dvh); margin: 10px -8px 0; }
|
||||
.explorerSuggestions .stopItem { padding: 12px; }
|
||||
.explorerRouteFilters { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 12px 18px; border-bottom: 1px solid var(--subtle-line); }
|
||||
.explorerRouteSelect, .explorerRouteDirection { display: flex; align-items: center; gap: 8px; padding: 0 12px; background: var(--surface); border-radius: 13px; min-height: 44px; min-width: 0; }
|
||||
.explorerRouteSelect { width: 190px; }
|
||||
.explorerRouteDirection { flex: 1; max-width: 530px; }
|
||||
.explorerRouteFilters select { appearance: none; width: 100%; min-width: 0; border: 0; background: transparent; color: var(--blue); font-size: 15px; font-weight: 700; padding: 8px 0; }
|
||||
.explorerRouteFilters label:focus-within { box-shadow: 0 0 0 2px var(--focus-soft); }
|
||||
.explorerRouteFilters select:focus-visible { outline: 0; }
|
||||
.routeFilterError { display: flex; gap: 5px; align-items: center; font-size: 14px; color: var(--muted); }
|
||||
.explorerNearby { min-height: 46px; gap: 7px; padding: 10px 14px; font-size: 14px; white-space: nowrap; }
|
||||
.explorerNearby[aria-pressed='true'] { background: var(--green); color: var(--green-ink); }
|
||||
.explorerViews { display: flex; padding: 4px; background: var(--blue-soft); border-radius: 14px; }
|
||||
.explorerViews button { min-height: 42px; padding: 8px 14px; border: 0; border-radius: 11px; background: transparent; color: var(--blue); font-weight: 750; font-size: 14px; }
|
||||
.explorerViews button[aria-pressed='true'] { background: var(--surface); }
|
||||
.explorerLocationMessage { padding: 12px 20px; font-size: 14px; line-height: 1.65; background: var(--warning-bg); color: var(--warning-ink); }
|
||||
.explorerBody { display: grid; grid-template-columns: 320px minmax(0, 1fr); min-height: 600px; }
|
||||
.explorerMapMode .explorerBody { grid-template-columns: minmax(0, 1fr); }
|
||||
.explorerCatalog { padding: 22px 16px 12px; border-right: 1px solid var(--subtle-line); min-width: 0; }
|
||||
.explorerListHeading { padding: 0 6px; }
|
||||
.explorerListHeading h2 { font-size: 18px; font-weight: 800; }
|
||||
.explorerListHeading > span { display: block; margin-top: 5px; font-size: 14px; color: var(--blue-muted); }
|
||||
.explorerHint { padding: 8px 6px 0; color: var(--blue-muted); font-size: 14px; }
|
||||
.explorerCatalog .stopsList { max-height: 512px; margin-top: 14px; margin-bottom: 0; }
|
||||
.explorerCatalog .stopItem { padding: 13px 12px; }
|
||||
.explorerMapArea { position: relative; min-width: 0; min-height: 600px; }
|
||||
.stopsMapFrame, .stopsMapFrame .stopsMapCanvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.stopsMapCanvas .maplibregl-canvas { outline-offset: -4px; }
|
||||
.stopsMapCanvas .maplibregl-ctrl-attrib { font-family: inherit; font-size: 11px; color: #242424; }
|
||||
.stopsMapCanvas .maplibregl-ctrl-bottom-right { bottom: auto; top: 0; right: 0; }
|
||||
.mapZoom { position: absolute; top: 52px; right: 16px; display: grid; border-radius: 16px; padding: 4px; background: var(--surface); box-shadow: 0 3px 20px #00000016; }
|
||||
.mapZoom button { display: grid; place-items: center; height: 44px; width: 44px; border: 0; background: transparent; color: var(--blue); border-radius: 12px; }
|
||||
.mapZoom button:hover { background: var(--lavender); }
|
||||
.mapInstruction { position: absolute; bottom: 20px; left: 20px; right: 20px; width: fit-content; max-width: calc(100% - 40px); padding: 12px 16px; border-radius: 14px; background: var(--nav-glass); font-size: 14px; pointer-events: none; }
|
||||
.mapLoading { position: absolute; inset: 0; display: grid; place-items: center; background: var(--lavender); font-size: 16px; }
|
||||
.mapProblem { position: absolute; top: 12px; left: 12px; right: 80px; display: flex; gap: 8px; align-items: center; padding: 12px; border-radius: 15px; background: var(--surface); box-shadow: 0 3px 16px #00000016; font-size: 14px; line-height: 1.55; }
|
||||
.mapProblem .textButton { white-space: nowrap; min-height: 44px; }
|
||||
.mapProblem .iconButton { margin-left: auto; padding: 5px; }
|
||||
.userLocationDot { width: 18px; height: 18px; background: #528cff; border: 3px solid #ffffff; border-radius: 50%; box-shadow: 0 0 0 8px #528cff30; }
|
||||
.stopPassport { outline: 0; padding: 22px; background: var(--surface); border: 1px solid var(--control-border); border-radius: 24px; scroll-margin-top: 24px; scroll-margin-bottom: 100px; }
|
||||
.explorerMapMode .explorerMapArea > .stopPassport { position: absolute; bottom: 20px; left: 20px; right: 20px; max-height: calc(100% - 180px); width: min(400px, calc(100% - 40px)); overflow-y: auto; box-shadow: 0 10px 32px #172d6024; animation: passportEnter .24s ease both; }
|
||||
.stopPassportHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||
.stopPassportHeading > div { min-width: 0; }
|
||||
.stopPassportHeading .eyebrow { font-size: 12px; letter-spacing: .5px; color: var(--muted); margin-bottom: 5px; }
|
||||
.stopPassportHeading h2 { font-size: 23px; line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.stopPassportHeading .iconButton { margin: -7px -9px 0 0; }
|
||||
.stopAddress, .stopDirection { margin-top: 10px; color: var(--blue-muted); font-size: 15px; }
|
||||
.passportDirections { display: flex; gap: 7px; flex-wrap: wrap; margin-top: 14px; }
|
||||
.passportDirections .routeChip { border: 0; border-radius: 13px; background: var(--lavender); color: var(--blue-muted); font-weight: 750; font-size: 14px; padding: 10px 13px; min-height: 44px; text-align: left; line-height: 1.4; white-space: normal; }
|
||||
.passportDirections .routeChip[aria-pressed='true'] { background: var(--blue-soft); color: var(--blue); }
|
||||
.stopDistanceHint { margin-top: 10px; font-size: 14px; color: var(--muted); }
|
||||
.passportRoutes { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; margin: 17px 0; }
|
||||
.passportRoutes h3 { font-size: 14px; font-weight: 650; color: var(--muted); }
|
||||
.passportRoutes p { font-size: 14px; }
|
||||
.passportRouteChips { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.passportRoute { padding: 5px 12px; border-radius: 10px; background: var(--lavender); font-size: 15px; font-weight: 800; }
|
||||
.passportActions { display: flex; gap: 10px; align-items: center; }
|
||||
.passportActions .primaryButton { flex: 1; justify-content: space-between; gap: 10px; padding: 12px 16px; min-height: 48px; font-size: 15px; border-radius: 14px; }
|
||||
.passportFavorite { border-radius: 14px; background: var(--lavender); width: 48px; height: 48px; }
|
||||
.passportFavorite.isFavorite { background: var(--green); color: var(--green-ink); }
|
||||
.passportWalking { display: inline-flex; align-items: center; gap: 7px; font-size: 14px; padding: 10px 0 0; min-height: 44px; }
|
||||
.passportEmpty { padding: 50px 35px; text-align: center; color: var(--blue-muted); }
|
||||
.passportEmpty > .vkuiIcon { margin: 0 auto 16px; }
|
||||
.passportEmpty h2 { font-size: 23px; }
|
||||
.passportEmpty p { margin-top: 10px; font-size: 16px; line-height: 1.7; }
|
||||
.explorerListMode .explorerMapArea { display: flex; flex-direction: column; padding: 0; }
|
||||
.explorerListMode .stopPassport { width: 100%; max-width: none; flex: 1; margin: 0; padding: 30px; border: 0; border-radius: 0; }
|
||||
.stopPassportDialog.appDialog { width: min(520px, calc(100% - 24px)); }
|
||||
.stopPassportDialog .dialogBody { padding: 0; }
|
||||
.stopPassportDialog .stopPassport { position: static; width: 100%; max-height: none; border: 0; box-shadow: none; margin: 0; padding: 22px; border-radius: 0; animation: none; }
|
||||
.walkingDialog.appDialog { width: min(700px, calc(100% - 24px)); }
|
||||
.walkingDestination { display: flex; gap: 9px; align-items: flex-start; color: var(--blue); }
|
||||
.walkingDestination strong { font-size: 17px; }
|
||||
.walkingDestination small { display: block; color: var(--muted); font-size: 14px; margin-top: 5px; }
|
||||
.walkingStatus { display: flex; gap: 9px; align-items: center; margin: 24px 0 12px; font-size: 15px; color: var(--muted); }
|
||||
.walkingError { margin-top: 20px; font-size: 15px; line-height: 1.7; }
|
||||
.walkingError > div { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
|
||||
.walkingSummary { display: flex; align-items: center; gap: 15px; margin: 20px 0 16px; padding: 12px 16px; border-radius: 16px; background: var(--green); color: var(--green-ink); }
|
||||
.walkingSummary strong { font-size: 23px; }
|
||||
.walkingSummary span { font-size: 15px; }
|
||||
.walkingMap { position: relative; height: 330px; border-radius: 20px; overflow: hidden; }
|
||||
.walkingHint { font-size: 14px; color: var(--muted); line-height: 1.65; margin-top: 14px; }
|
||||
@keyframes passportEnter { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@media (max-width: 1120px) {
|
||||
.explorerToolbar { grid-template-columns: 180px minmax(0, 1fr); }
|
||||
.explorerTools { grid-column: 1 / -1; justify-content: space-between; }
|
||||
.explorerBody { grid-template-columns: 280px minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.explorerHero { padding: 18px 0 14px; }
|
||||
.explorerHero h1 { font-size: 28px; }
|
||||
.explorerHero p { display: none; }
|
||||
.stopsExplorer { border-radius: 24px; }
|
||||
.explorerToolbar { position: relative; grid-template-columns: minmax(0, 1fr) auto; padding: 14px; gap: 10px; }
|
||||
.explorerTools { display: contents; }
|
||||
.explorerViews { grid-column: 2; grid-row: 1; }
|
||||
.explorerSearchWrap { grid-column: 1 / -1; grid-row: 2; }
|
||||
.explorerSearch { padding-right: 58px; }
|
||||
.explorerNearby { position: absolute; bottom: 17px; right: 17px; padding: 0; min-height: 44px; width: 44px; border-radius: 11px; }
|
||||
.explorerNearby > span { display: none; }
|
||||
.explorerViews button { padding: 8px 12px; font-size: 13px; }
|
||||
.explorerLocationMessage { padding: 10px 14px; }
|
||||
.explorerRouteFilters { gap: 8px; padding: 10px 14px; flex-wrap: nowrap; }
|
||||
.explorerRouteSelect { flex: 1; width: auto; }
|
||||
.explorerRouteFilters:has(.explorerRouteDirection) .explorerRouteSelect { flex: 0 0 78px; padding: 0 10px; }
|
||||
.explorerRouteFilters:has(.explorerRouteDirection) .explorerRouteSelect > .vkuiIcon { display: none; }
|
||||
.explorerRouteDirection { flex: 1; min-width: 0; padding: 0 10px; }
|
||||
.explorerRouteFilters select { font-size: 14px; }
|
||||
.routeFilterError { width: 100%; }
|
||||
.explorerRouteFilters:has(.routeFilterError) { flex-wrap: wrap; }
|
||||
.explorerRouteFilters:has(.explorerRouteDirection) { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.explorerRouteFilters:has(.explorerRouteDirection) .explorerRouteSelect { grid-column: 1; width: auto; }
|
||||
.explorerRouteFilters:has(.explorerRouteDirection) .explorerRouteDirection { grid-column: 1 / -1; grid-row: 2; }
|
||||
.explorerRouteFilters:has(.explorerRouteDirection) > .iconButton { grid-column: 2; grid-row: 1; }
|
||||
.explorerRouteFilters:has(.explorerRouteDirection) .routeFilterError { grid-column: 1 / -1; }
|
||||
.explorerCity .citySelect > .vkuiIcon:first-child { display: none; }
|
||||
.explorerCity .citySelect { padding: 0 9px; gap: 5px; }
|
||||
.explorerBody { display: block; min-height: 0; }
|
||||
.explorerCatalog { padding: 18px 14px 12px; border-right: 0; }
|
||||
.explorerCatalog .stopsList { max-height: 350px; }
|
||||
.explorerMapArea { min-height: 0; }
|
||||
.explorerMapMode .explorerMapArea { height: clamp(380px, calc(100dvh - 440px), 660px); }
|
||||
.stopsExplorer:has(.explorerRouteDirection).explorerMapMode .explorerMapArea { height: clamp(320px, calc(100dvh - 490px), 660px); }
|
||||
.explorerListMode .explorerMapArea { padding: 0 14px 14px; }
|
||||
.explorerMapMode .explorerMapArea > .stopPassport { left: 12px; right: 12px; bottom: 12px; width: auto; max-height: calc(100% - 120px); }
|
||||
.explorerListMode .explorerMapArea:empty { display: none; }
|
||||
.stopPassport { padding: 17px; border-radius: 21px; }
|
||||
.stopPassportHeading h2 { font-size: 21px; }
|
||||
.passportDirections { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: thin; }
|
||||
.passportDirections .routeChip { flex-shrink: 0; max-width: 260px; }
|
||||
.passportRoutes { margin: 14px 0; }
|
||||
.passportActions .primaryButton { font-size: 14px; padding: 12px; }
|
||||
.passportEmpty { padding: 25px 15px; }
|
||||
.mapProblem { right: 74px; flex-wrap: wrap; padding: 10px; }
|
||||
.mapProblem > span { flex: 1; min-width: 100px; }
|
||||
.mapProblem .iconButton { position: absolute; top: 0; right: -2px; }
|
||||
.mapProblem > span { padding-right: 24px; }
|
||||
.mapProblem > .vkuiIcon { display: none; }
|
||||
.mapZoom { display: flex; right: 12px; top: 44px; }
|
||||
.walkingDialog .dialogBody { padding: 20px; }
|
||||
.walkingDialog .dialogHeading h2 { font-size: 22px; }
|
||||
.walkingMap { height: min(300px, 42dvh); min-height: 180px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .stopPassport { animation: none !important; } }
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { QuickTripForm } from './QuickTrips';
|
||||
import { HourlyTimetable } from './HourlyTimetable';
|
||||
import { 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 && <>
|
||||
<div className="scheduleControls"><div className="scheduleViews" role="group" aria-label="Режим расписания"><button className={!fullDay ? 'selected' : ''} aria-pressed={!fullDay} onClick={() => onView('next')}>Ближайшие</button><button className={fullDay ? 'selected' : ''} aria-pressed={fullDay} onClick={() => onView('day')}>На дату</button></div>{city && !!board?.routes.length && <button className="iconButton saveTripButton" data-discovery="save-trip" title="Сохранить поездку" aria-label="Сохранить поездку" onClick={() => setSaveTrip(true)}><Plus size={24} /></button>}{fullDay && <div className="scheduleDate"><label htmlFor="schedule-date">Дата поездки</label><input id="schedule-date" type="date" value={date} min="2000-01-02" max="2100-12-31" onChange={event => { if (event.target.value && event.target.validity.valid) onView('day', event.target.value); }} /><button className="textButton" onClick={() => onView('day', localDate(now))}>Сегодня</button></div>}</div>
|
||||
{fullDay && <p className="timetableSummary">{formatDate(`${date}T12:00:00+03:00`)}{board ? ` · ${countLabel(departures.length, ['рейс', 'рейса', 'рейсов'])}` : ''}{board?.isHoliday && ` · ${board.holidayName || 'праздничный день'}`}</p>}
|
||||
{board && board.routes.length > 0 && <div className="routeFilters appScrollArea" role="group" aria-label="Фильтр по маршруту"><button className={!routeId ? 'selected' : ''} onClick={() => onRoute(null)} aria-pressed={!routeId}>Все маршруты</button>{board.routes.map(route => <button key={route.id} title={route.name} className={routeId === route.id ? 'selected' : ''} aria-label={`Маршрут ${route.number}`} aria-pressed={routeId === route.id} onClick={() => onRoute(route.id)}>{route.number}</button>)}</div>}
|
||||
{board && board.routes.length > 0 && <ScrollFade key={`${city?.id}:${stop.id}`} className="routeFilters appScrollArea" role="group" aria-label="Фильтр по маршруту"><button className={!routeId ? 'selected' : ''} onClick={() => onRoute(null)} aria-pressed={!routeId}>Все маршруты</button>{board.routes.map(route => <button key={route.id} title={route.name} className={routeId === route.id ? 'selected' : ''} aria-label={`Маршрут ${route.number}`} aria-pressed={routeId === route.id} onClick={() => onRoute(route.id)}>{route.number}</button>)}</ScrollFade>}
|
||||
{board && city && <Alerts mode={mode} cityId={city.id} online={online} routes={board.routes.filter(route => !routeId || route.id === routeId)} />}
|
||||
</>}
|
||||
{board && (outdated || updateFailed) && <p className="savedNotice" role="status">{outdated ? 'Расписание давно не обновлялось. Проверьте изменения при подключении к сети.' : 'Не удалось обновить расписание. Попробуйте ещё раз.'}</p>}
|
||||
|
||||
@@ -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<HTMLDialogElement>(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<HTMLElement>('input, select, textarea')?.focus({ preventScroll: true });
|
||||
return () => { dialog.close(); if (trigger?.isConnected) trigger.focus({ preventScroll: true }); };
|
||||
document.body.style.overflow = 'hidden';
|
||||
dialog.querySelector<HTMLElement>(focusContent ? '[tabindex="-1"]' : 'input, select, textarea')?.focus({ preventScroll: true });
|
||||
return () => { document.body.style.overflow = overflow; dialog.close(); if (trigger?.isConnected) trigger.focus({ preventScroll: true }); };
|
||||
}, []);
|
||||
return <dialog ref={ref} className="appDialog" aria-labelledby={titleId} onCancel={event => { event.preventDefault(); onClose(); }} onClick={event => {
|
||||
return <dialog ref={ref} className={`appDialog ${className}`} aria-labelledby={titleId} onCancel={event => { event.preventDefault(); onClose(); }} onClick={event => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) onClose();
|
||||
}}><div className="dialogBody"><div className="dialogHeading"><h2 id={titleId}>{title}</h2><button className="iconButton" aria-label="Закрыть окно" onClick={onClose}><X size={22} /></button></div>{children}</div></dialog>;
|
||||
}}><div className="dialogBody">{hideHeading ? <h2 id={titleId} className="visuallyHidden">{title}</h2> : <div className="dialogHeading"><h2 id={titleId}>{title}</h2><button className="iconButton" aria-label="Закрыть окно" onClick={onClose}><X size={22} /></button></div>}{children}</div></dialog>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Icon20GlobeCrossOutline, Icon20RefreshOutline, Icon20ShareOutline,
|
||||
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);
|
||||
|
||||
37
src/components/ScrollFade.tsx
Normal file
37
src/components/ScrollFade.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useLayoutEffect, useRef, type HTMLAttributes } from 'react';
|
||||
|
||||
// Fades follow the actual scroll position, including resized or changed lists.
|
||||
export function ScrollFade({ className = '', children, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current!;
|
||||
let active = true;
|
||||
const update = () => {
|
||||
if (!active) return;
|
||||
element.dataset.fadeStart = String(element.scrollLeft > 2);
|
||||
element.dataset.fadeEnd = String(element.scrollWidth - element.clientWidth - element.scrollLeft > 2);
|
||||
};
|
||||
const resize = new ResizeObserver(update);
|
||||
const observe = () => {
|
||||
resize.disconnect(); resize.observe(element);
|
||||
for (const child of element.children) resize.observe(child);
|
||||
update();
|
||||
};
|
||||
const mutation = new MutationObserver(observe);
|
||||
mutation.observe(element, { childList: true, subtree: true, characterData: true });
|
||||
const focus = (event: FocusEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement) || !target.matches(':focus-visible')) return;
|
||||
const outer = element.getBoundingClientRect(), inner = target.getBoundingClientRect();
|
||||
const padding = 26;
|
||||
if (inner.left < outer.left + padding && element.scrollLeft > 2) element.scrollLeft += inner.left - outer.left - padding;
|
||||
else if (inner.right > outer.right - padding) element.scrollLeft += inner.right - outer.right + padding;
|
||||
update();
|
||||
};
|
||||
element.addEventListener('scroll', update, { passive: true });
|
||||
element.addEventListener('focusin', focus);
|
||||
observe();
|
||||
return () => { active = false; resize.disconnect(); mutation.disconnect(); element.removeEventListener('scroll', update); element.removeEventListener('focusin', focus); };
|
||||
}, []);
|
||||
return <div {...props} ref={ref} className={`horizontalScrollFade ${className}`}>{children}</div>;
|
||||
}
|
||||
154
src/components/StopsExplorer.tsx
Normal file
154
src/components/StopsExplorer.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { lazy, Suspense, useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { loadResource } from '../lib/api';
|
||||
import { countLabel, directionLabel, groupStops } from '../lib/stopGroups';
|
||||
import { distanceMeters, hasCoordinates } from '../lib/geo';
|
||||
import { useStopRoutes } from '../lib/useStopRoutes';
|
||||
import { stopKey } from '../lib/storage';
|
||||
import { ArrowRight, Bookmark, BusFront, ChevronDown, MapPin, RefreshCw, Search, X } from './Icons';
|
||||
import { StopList, StopListItem } from './StopList';
|
||||
import { Dialog } from './Dialog';
|
||||
import { WalkingDirections } from './WalkingDirections';
|
||||
import { ScrollFade } from './ScrollFade';
|
||||
import type { City, Coordinates, SavedStop, Stop, StopDetails } from '../types';
|
||||
|
||||
const StopsMap = lazy(() => import('./StopsMap'));
|
||||
type Props = {
|
||||
city?: City; cityPicker: ReactNode; stops: Stop[]; selected?: Stop; favorites: SavedStop[];
|
||||
loading: boolean; error: boolean; online: boolean; mode: 'live' | 'demo'; theme: 'light' | 'dark';
|
||||
onSelect: (stop: Stop) => void; onSchedule: (stop: Stop, routeId?: number) => void; onFavorite: () => void; onRetry: () => void;
|
||||
};
|
||||
|
||||
export function StopsExplorer({ city, cityPicker, stops, selected, favorites, loading, error, online, mode, theme, onSelect, onSchedule, onFavorite, onRetry }: Props) {
|
||||
const [view, setView] = useState<'map' | 'list'>('map');
|
||||
const [search, setSearch] = useState('');
|
||||
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
|
||||
const suggestionsId = useId();
|
||||
const [cardOpen, setCardOpen] = useState(() => !!selected && !matchMedia('(max-width: 760px)').matches);
|
||||
const [position, setPosition] = useState<Coordinates | null>(null);
|
||||
const [nearby, setNearby] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [locationMessage, setLocationMessage] = useState('');
|
||||
const [cardInset, setCardInset] = useState(0);
|
||||
const [mobile, setMobile] = useState(() => matchMedia('(max-width: 760px)').matches);
|
||||
const [walkingStop, setWalkingStop] = useState<(Stop & Coordinates) | null>(null);
|
||||
const request = useRef(0);
|
||||
const cardRef = useRef<HTMLElement>(null);
|
||||
const focusCard = useRef(false);
|
||||
useEffect(() => () => { request.current++; }, []);
|
||||
useEffect(() => {
|
||||
const media = matchMedia('(max-width: 760px)');
|
||||
const update = () => setMobile(media.matches);
|
||||
media.addEventListener('change', update); return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
useEffect(() => { if (!mobile) setCardOpen(!!selected); }, [selected?.id, mobile]);
|
||||
const details = useQuery({ queryKey: ['stop', mode, city?.id, selected?.id], queryFn: () => loadResource<StopDetails>('stop', mode, city?.id, selected?.id), enabled: !!city && !!selected && cardOpen, staleTime: 300000 });
|
||||
const stop = details.data && details.data.data.id === selected?.id ? details.data.data : selected;
|
||||
const routes = useStopRoutes(mode, city?.id, stops);
|
||||
const groups = useMemo(() => groupStops(routes.filtered), [routes.filtered]);
|
||||
const group = groups.find(group => group.stops.some(stop => stop.id === selected?.id));
|
||||
const query = search.trim().toLocaleLowerCase('ru');
|
||||
const rows = useMemo(() => groups.map(group => {
|
||||
const nearest = position ? group.stops.filter((stop): stop is Stop & Coordinates => hasCoordinates(stop))
|
||||
.map(stop => ({ stop, distance: distanceMeters(position, stop) })).sort((a, b) => a.distance - b.distance)[0] : undefined;
|
||||
return { group, nearest };
|
||||
}).filter(({ group, nearest }) => `${group.name} ${group.stops.map(stop => stop.direction).join(' ')}`.toLocaleLowerCase('ru').includes(query)
|
||||
&& (!nearby || (nearest && nearest.distance <= 1000)))
|
||||
.sort((a, b) => nearby ? (a.nearest?.distance ?? Infinity) - (b.nearest?.distance ?? Infinity)
|
||||
: routes.order.size ? Math.min(...a.group.stops.map(stop => routes.order.get(stop.id) ?? Infinity)) - Math.min(...b.group.stops.map(stop => routes.order.get(stop.id) ?? Infinity))
|
||||
: a.group.name.localeCompare(b.group.name, 'ru', { numeric: true })), [groups, position, nearby, query, routes.order]);
|
||||
const points = useMemo(() => rows.flatMap(({ group }) => group.stops), [rows]);
|
||||
const mapped = routes.filtered.some(hasCoordinates);
|
||||
const showingMap = view === 'map' && online && !!city && mapped && !loading && !routes.pending;
|
||||
const favorite = !!city && !!selected && favorites.some(item => item.key === stopKey(mode, city.id, selected.id));
|
||||
const distance = position && stop && hasCoordinates(stop) ? distanceMeters(position, stop) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
if (!cardOpen || !card || mobile) { setCardInset(0); return; }
|
||||
let active = true;
|
||||
const measure = () => { if (active && card.isConnected) setCardInset(Math.ceil(card.getBoundingClientRect().height) + 32); };
|
||||
const observer = new ResizeObserver(measure); observer.observe(card); measure();
|
||||
return () => { active = false; observer.disconnect(); };
|
||||
}, [cardOpen, selected?.id, showingMap, mobile]);
|
||||
|
||||
function choose(stop: Stop) {
|
||||
focusCard.current = true;
|
||||
setSuggestionsOpen(false); setCardOpen(true); onSelect(stop);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!cardOpen || !focusCard.current || !cardRef.current) return;
|
||||
focusCard.current = false;
|
||||
cardRef.current.focus({ preventScroll: true });
|
||||
}, [cardOpen, selected?.id, showingMap, mobile]);
|
||||
|
||||
function filterRoute(line: string, direction = '') { setCardOpen(false); setSuggestionsOpen(false); routes.change(line, direction); }
|
||||
|
||||
async function locate() {
|
||||
if (!online || locating) return;
|
||||
if (nearby) { setNearby(false); return; }
|
||||
if (!navigator.geolocation || !window.isSecureContext) { setLocationMessage('Местоположение недоступно в этом браузере. Найдите остановку по названию.'); return; }
|
||||
const current = ++request.current;
|
||||
setLocating(true); setLocationMessage('');
|
||||
try {
|
||||
const result = await new Promise<GeolocationPosition>((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 }));
|
||||
if (current !== request.current) return;
|
||||
setPosition({ latitude: result.coords.latitude, longitude: result.coords.longitude }); setNearby(true); setCardOpen(false);
|
||||
if (result.coords.accuracy > 300) setLocationMessage('Местоположение приблизительное. Расстояния указаны по прямой.');
|
||||
} catch (error) {
|
||||
if (current !== request.current) return;
|
||||
const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
|
||||
setLocationMessage(code === 1 ? 'Доступ к местоположению закрыт. Можно найти остановку по названию.' : 'Не удалось определить местоположение. Попробуйте ещё раз или найдите остановку по названию.');
|
||||
} finally { if (current === request.current) setLocating(false); }
|
||||
}
|
||||
|
||||
const list = <>
|
||||
<div className="explorerListHeading"><h2>{nearby ? 'Рядом с вами' : routes.group ? `Остановки №${routes.group.number}` : 'Остановки города'}</h2><span>{countLabel(rows.length, ['остановка', 'остановки', 'остановок'])}</span></div>
|
||||
{nearby && <p className="explorerHint">В радиусе 1 км · расстояния по прямой</p>}
|
||||
<StopList label="Остановки" busy={loading || routes.pending}>
|
||||
{loading || routes.pending ? <div className="listMessage" role="status">{routes.pending ? 'Загружаем остановки маршрута…' : 'Загружаем остановки…'}</div> : routes.failed ? <div className="listMessage"><h3>Остановки маршрута недоступны</h3><p>Попробуйте обновить данные или выберите все маршруты.</p><button className="secondaryButton" onClick={routes.retry}>Повторить</button><button className="textButton" onClick={() => filterRoute('')}>Все маршруты</button></div> : error && !stops.length ? <div className="listMessage"><h3>Остановки пока недоступны</h3><p>Попробуйте обновить список.</p><button className="secondaryButton" onClick={onRetry}>Повторить</button></div> : !rows.length ? <div className="listMessage" role="status"><Search size={25} /><h3>{query ? 'Ничего не нашлось' : nearby ? 'Рядом нет остановок' : 'Пока нет остановок'}</h3><p>{query ? 'Попробуйте другое название.' : nearby ? 'Посмотрите все остановки или выберите другой город.' : 'Выберите другой город.'}</p>{nearby && <button className="textButton" onClick={() => setNearby(false)}>Все остановки <ArrowRight size={17} /></button>}</div> : rows.map(({ group, nearest }) => <StopListItem key={group.key} name={group.name} selected={cardOpen && group.stops.some(stop => stop.id === selected?.id)} favorite={group.stops.some(stop => city && favorites.some(favorite => favorite.key === stopKey(mode, city.id, stop.id)))} distance={nearby ? nearest?.distance : undefined} onSelect={() => choose((nearby ? nearest?.stop : group.stops.find(stop => stop.id === selected?.id)) ?? group.stops.find(stop => stop.routesCount > 0) ?? group.stops[0])} />)}
|
||||
</StopList>
|
||||
</>;
|
||||
|
||||
return <section className={`stopsExplorer ${showingMap ? 'explorerMapMode' : 'explorerListMode'} ${cardOpen && stop ? 'hasStopCard' : ''}`} aria-label="Поиск остановок">
|
||||
<div className="explorerToolbar">
|
||||
<div className="explorerCity">{cityPicker}</div>
|
||||
<div className="explorerSearchWrap" onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setSuggestionsOpen(false); }} onKeyDown={event => { if (event.key === 'Escape' && suggestionsOpen) { event.preventDefault(); setSuggestionsOpen(false); } }}>
|
||||
<label className="searchField explorerSearch"><Search size={20} /><input aria-label="Поиск остановки" aria-controls={showingMap && query && suggestionsOpen ? suggestionsId : undefined} type="search" placeholder="Поиск остановки" value={search} onFocus={() => setSuggestionsOpen(true)} onChange={event => { setSearch(event.target.value); setSuggestionsOpen(true); setCardOpen(false); }} autoComplete="off" /></label>
|
||||
{showingMap && query && suggestionsOpen && <div className="explorerSuggestions" id={suggestionsId} role="region" aria-label="Результаты поиска остановок">{list}</div>}
|
||||
</div>
|
||||
<div className="explorerTools"><button className="secondaryButton explorerNearby" aria-label={nearby ? 'Все остановки' : 'Рядом со мной'} title={nearby ? 'Все остановки' : 'Рядом со мной'} aria-pressed={nearby} disabled={!online || !city || locating || !stops.some(hasCoordinates)} onClick={() => void locate()}>{locating ? <RefreshCw size={18} className="spinning" /> : <MapPin size={18} />}<span>{locating ? 'Ищем…' : nearby ? 'Все остановки' : 'Рядом со мной'}</span></button>
|
||||
<div className="explorerViews" role="group" aria-label="Вид остановок"><button aria-pressed={showingMap} disabled={!online || !mapped || loading} title={!online ? 'Карта доступна при подключении' : undefined} onClick={() => setView('map')}>Карта</button><button aria-pressed={!showingMap} onClick={() => setView('list')}>Список</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="explorerRouteFilters" aria-label="Фильтр по маршруту"><label className="explorerRouteSelect"><BusFront size={18} /><select aria-label="Маршрут" value={routes.line} disabled={routes.catalog.isPending || !routes.groups.length} onChange={event => filterRoute(event.target.value)}><option value="">{routes.catalog.isPending ? 'Загружаем маршруты…' : 'Все маршруты'}</option>{routes.line && !routes.group && <option value={routes.line}>Маршрут {routes.line}</option>}{routes.groups.map(group => <option key={group.key} value={group.key}>№{group.number}</option>)}</select><ChevronDown size={16} /></label>
|
||||
{routes.group && routes.group.routes.length > 1 && <label className="explorerRouteDirection"><select aria-label="Направление маршрута" value={routes.direction ?? ''} onChange={event => filterRoute(routes.line, event.target.value)}><option value="">Все направления</option>{routes.group.routes.map(route => <option key={route.id} value={route.id}>{route.name}</option>)}</select><ChevronDown size={16} /></label>}
|
||||
{routes.line && <button className="iconButton" aria-label="Сбросить фильтр маршрута" onClick={() => filterRoute('')}><X size={18} /></button>}
|
||||
{(routes.catalog.isError || routes.partial) && <p className="routeFilterError" role="status">{routes.partial ? 'Часть направлений недоступна.' : 'Маршруты пока недоступны.'}<button className="textButton" onClick={routes.retry}>Повторить</button></p>}
|
||||
</div>
|
||||
{locationMessage && <p className="explorerLocationMessage" role="status">{locationMessage}</p>}
|
||||
<div className="explorerBody">
|
||||
{!showingMap && <aside className="explorerCatalog">{list}</aside>}
|
||||
<div className="explorerMapArea">
|
||||
{showingMap ? <Suspense fallback={<div className="mapLoading" role="status">Загружаем карту…</div>}><StopsMap city={city!} stops={points} selected={cardOpen ? stop : undefined} position={position} theme={theme} bottomInset={cardInset} fitStopsKey={routes.line ? `${routes.line}:${routes.direction ?? ''}` : undefined} onSelect={choose} onList={() => setView('list')} /></Suspense> : null}
|
||||
{cardOpen && stop && !mobile ? passport() : !showingMap && rows.length > 0 && !mobile ? <div className="passportEmpty"><MapPin size={32} /><h2>Выберите остановку</h2><p>Посмотрите направления и маршруты, затем откройте расписание.</p></div> : null}
|
||||
{showingMap && !cardOpen && <p className="mapInstruction">{!rows.length ? nearby ? 'Рядом нет остановок с выбранными фильтрами.' : 'Ничего не нашлось. Попробуйте другое название.' : nearby ? 'Остановки в радиусе 1 км · расстояния по прямой' : 'Нажмите на точку, чтобы выбрать остановку'}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{cardOpen && stop && mobile && <Dialog title={group?.name ?? stop.name} className="stopPassportDialog" hideHeading focusContent onClose={() => setCardOpen(false)}>{passport()}</Dialog>}
|
||||
{walkingStop && city && <WalkingDirections city={city} stop={walkingStop} online={online} theme={theme} onClose={() => setWalkingStop(null)} />}
|
||||
</section>;
|
||||
|
||||
function passport() {
|
||||
if (!stop) return null;
|
||||
return <section className="stopPassport appScrollArea" aria-labelledby="stop-passport-title" ref={cardRef} tabIndex={-1}>
|
||||
<div className="stopPassportHeading"><div><h2 id="stop-passport-title">{group?.name ?? stop.name}</h2></div><button className="iconButton" aria-label="Закрыть карточку остановки" onClick={() => setCardOpen(false)}><X size={20} /></button></div>
|
||||
{stop.address && <p className="stopAddress">{stop.address}</p>}
|
||||
{group && group.stops.length > 1 ? <ScrollFade className="passportDirections appScrollArea" role="group" aria-label="Направления остановки">{group.stops.map((direction, index) => <button key={direction.id} className="routeChip" aria-pressed={direction.id === selected?.id} onClick={() => choose(direction)}>{directionLabel(group, direction, index)}</button>)}</ScrollFade> : stop.direction && <p className="stopDirection">{stop.direction}</p>}
|
||||
{distance !== undefined && <p className="stopDistanceHint">{distance < 1000 ? `${distance} м` : `${(distance / 1000).toFixed(1)} км`} от вас по прямой</p>}
|
||||
<div className="passportRoutes"><h3>Маршруты</h3>{details.isPending ? <p role="status">Загружаем маршруты…</p> : details.isError && !details.data ? <p className="passportError">Маршруты пока недоступны. <button className="textButton" onClick={() => void details.refetch()}>Повторить</button></p> : details.data?.data.routes.length ? <div className="passportRouteChips">{details.data.data.routes.map(route => <span className="passportRoute" key={route.id} title={route.name}>{route.number}</span>)}</div> : <p>Маршруты пока не указаны.</p>}</div>
|
||||
<div className="passportActions"><button className="primaryButton" onClick={() => onSchedule(stop, routes.line ? routes.routeForStop(stop.id) : undefined)}>Открыть расписание <ArrowRight size={18} /></button><button className={`passportFavorite iconButton ${favorite ? 'isFavorite' : ''}`} aria-label={favorite ? 'Удалить остановку из избранного' : 'Добавить остановку в избранное'} aria-pressed={favorite} onClick={onFavorite}><Bookmark size={23} fill={favorite ? 'currentColor' : undefined} /></button></div>
|
||||
{hasCoordinates(stop) && <button className="textButton passportWalking" disabled={!online || mode === 'demo'} title={mode === 'demo' ? 'Пеший путь доступен для настоящих остановок' : undefined} onClick={() => { if (hasCoordinates(stop)) { setCardOpen(false); setWalkingStop(stop); } }}><MapPin size={17} />Как добраться пешком</button>}
|
||||
</section>;
|
||||
}
|
||||
}
|
||||
138
src/components/StopsMap.tsx
Normal file
138
src/components/StopsMap.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Map, Marker, setWorkerUrl, type GeoJSONSource, type StyleSpecification } from 'maplibre-gl';
|
||||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { hasCoordinates } from '../lib/geo';
|
||||
import { Minus, Plus, MapPin, X } from './Icons';
|
||||
import type { City, Coordinates, Stop, WalkingRoute } from '../types';
|
||||
|
||||
setWorkerUrl(workerUrl);
|
||||
|
||||
type Props = { city: City; stops: Stop[]; selected?: Stop; position: Coordinates | null; theme: 'light' | 'dark'; bottomInset: number; walking?: WalkingRoute; fitStopsKey?: string; onSelect: (stop: Stop) => void; onList?: () => void };
|
||||
|
||||
export default function StopsMap({ city, stops, selected, position, theme, bottomInset, walking, fitStopsKey, onSelect, onList }: Props) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<Map | null>(null);
|
||||
const latest = useRef({ stops, selected, onSelect, theme, walking });
|
||||
latest.current = { stops, selected, onSelect, theme, walking };
|
||||
const [ready, setReady] = useState(false);
|
||||
const [problem, setProblem] = useState('');
|
||||
|
||||
function updatePoints() {
|
||||
const map = mapRef.current;
|
||||
const source = map?.getSource('ridebus-stops') as GeoJSONSource | undefined;
|
||||
source?.setData({ type: 'FeatureCollection', features: latest.current.stops.filter(hasCoordinates).map(stop => ({
|
||||
type: 'Feature', id: stop.id, geometry: { type: 'Point', coordinates: [stop.longitude, stop.latitude] },
|
||||
properties: { id: stop.id, name: stop.name },
|
||||
})) });
|
||||
if (map?.getLayer('selected-stop')) map.setFilter('selected-stop', ['==', ['get', 'id'], latest.current.selected?.id ?? -1]);
|
||||
const foot = map?.getSource('ridebus-walking') as GeoJSONSource | undefined;
|
||||
foot?.setData({ type: 'FeatureCollection', features: latest.current.walking ? [{ type: 'Feature', properties: {}, geometry: latest.current.walking.geometry }] : [] });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!container.current) return;
|
||||
let map: Map;
|
||||
try {
|
||||
const first = stops.find(hasCoordinates);
|
||||
map = new Map({ container: container.current, style: { version: 8, sources: {}, layers: [{ id: 'background', type: 'background', paint: { 'background-color': theme === 'dark' ? '#202735' : '#f3f3fa' } }] },
|
||||
center: hasCoordinates(city) ? [city.longitude, city.latitude] : first ? [first.longitude!, first.latitude!] : [0, 0],
|
||||
zoom: 13, minZoom: 2, maxZoom: 19, pitchWithRotate: false, dragRotate: false,
|
||||
attributionControl: { compact: true }, maplibreLogo: false });
|
||||
} catch { setProblem('Карта недоступна в этом браузере. Выберите остановку из списка.'); return; }
|
||||
mapRef.current = map;
|
||||
map.on('idle', () => container.current?.setAttribute('data-ready', 'true'));
|
||||
map.getCanvas().setAttribute('aria-label', 'Карта остановок. Для выбора с клавиатуры используйте список.');
|
||||
map.touchZoomRotate.disableRotation();
|
||||
map.on('style.load', () => {
|
||||
if (!map.getSource('ridebus-stops')) {
|
||||
map.addSource('ridebus-walking', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||
map.addLayer({ id: 'walking-outline', type: 'line', source: 'ridebus-walking', layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#ffffff', 'line-width': 7 } });
|
||||
map.addLayer({ id: 'walking-path', type: 'line', source: 'ridebus-walking', layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#528cff', 'line-width': 4, 'line-dasharray': [2, 1] } });
|
||||
map.addSource('ridebus-stops', { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, cluster: true, clusterRadius: 44, clusterMaxZoom: 13 });
|
||||
map.addLayer({ id: 'stop-clusters', type: 'circle', source: 'ridebus-stops', filter: ['has', 'point_count'], paint: { 'circle-radius': 20, 'circle-color': '#2c4678', 'circle-stroke-width': 3, 'circle-stroke-color': '#d8e2ff' } });
|
||||
if (map.getStyle().glyphs) map.addLayer({ id: 'cluster-count', type: 'symbol', source: 'ridebus-stops', filter: ['has', 'point_count'], layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-font': ['Noto Sans Regular'], 'text-size': 13 }, paint: { 'text-color': '#ffffff' } });
|
||||
map.addLayer({ id: 'stop-points', type: 'circle', source: 'ridebus-stops', filter: ['!', ['has', 'point_count']], paint: { 'circle-radius': 9, 'circle-color': '#2c4678', 'circle-stroke-width': 3, 'circle-stroke-color': '#ffffff' } });
|
||||
map.addLayer({ id: 'selected-stop', type: 'circle', source: 'ridebus-stops', filter: ['==', ['get', 'id'], -1], paint: { 'circle-radius': 14, 'circle-color': '#ddf9d0', 'circle-stroke-width': 4, 'circle-stroke-color': '#254817' } });
|
||||
if (map.getStyle().glyphs) map.addLayer({ id: 'stop-names', type: 'symbol', source: 'ridebus-stops', minzoom: 15, filter: ['!', ['has', 'point_count']], layout: { 'text-field': ['get', 'name'], 'text-font': ['Noto Sans Regular'], 'text-size': 13, 'text-anchor': 'top', 'text-offset': [0, 1.4] }, paint: { 'text-color': latest.current.theme === 'dark' ? '#f0f1f5' : '#2c4678', 'text-halo-color': latest.current.theme === 'dark' ? '#202735' : '#ffffff', 'text-halo-width': 2 } });
|
||||
}
|
||||
updatePoints(); setReady(true);
|
||||
});
|
||||
map.on('click', async event => {
|
||||
const layers = ['stop-points', 'stop-clusters'].filter(id => map.getLayer(id));
|
||||
if (!layers.length) return;
|
||||
const hits = map.queryRenderedFeatures([[event.point.x - 16, event.point.y - 16], [event.point.x + 16, event.point.y + 16]], { layers });
|
||||
const hit = hits.sort((a, b) => {
|
||||
const distance = (feature: typeof a) => {
|
||||
if (feature.geometry.type !== 'Point') return Infinity;
|
||||
const p = map.project(feature.geometry.coordinates as [number, number]);
|
||||
return Math.hypot(p.x - event.point.x, p.y - event.point.y);
|
||||
};
|
||||
return distance(a) - distance(b);
|
||||
})[0];
|
||||
if (!hit || hit.geometry.type !== 'Point') return;
|
||||
if (hit.properties.cluster) {
|
||||
try {
|
||||
const zoom = await (map.getSource('ridebus-stops') as GeoJSONSource).getClusterExpansionZoom(hit.properties.cluster_id);
|
||||
if (mapRef.current === map) map.easeTo({ center: hit.geometry.coordinates as [number, number], zoom, duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 350 });
|
||||
} catch { /* The map may be removed or its style replaced during a click. */ }
|
||||
} else {
|
||||
const stop = latest.current.stops.find(stop => stop.id === Number(hit.properties.id));
|
||||
if (stop) latest.current.onSelect(stop);
|
||||
}
|
||||
});
|
||||
map.on('mousemove', event => {
|
||||
const layers = ['stop-points', 'stop-clusters'].filter(id => map.getLayer(id));
|
||||
map.getCanvas().style.cursor = layers.length && map.queryRenderedFeatures(event.point, { layers }).length ? 'pointer' : '';
|
||||
});
|
||||
map.on('error', () => setProblem('Не удалось загрузить подложку карты. Можно выбрать остановку из списка.'));
|
||||
const resize = new ResizeObserver(() => { if (mapRef.current === map) map.resize(); });
|
||||
resize.observe(container.current);
|
||||
return () => { resize.disconnect(); mapRef.current = null; map.remove(); };
|
||||
}, [city.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const controller = new AbortController();
|
||||
setProblem('');
|
||||
// Load the basemap independently of RideBus points; retain points if the provider fails.
|
||||
fetch(`https://tiles.openfreemap.org/styles/${theme === 'dark' ? 'dark' : 'positron'}`, { signal: controller.signal }).then(async response => {
|
||||
if (!response.ok) throw new Error();
|
||||
const style = await response.json() as StyleSpecification;
|
||||
if (!controller.signal.aborted) map.setStyle(style);
|
||||
}).catch(() => { if (!controller.signal.aborted) setProblem('Не удалось загрузить подложку карты. Можно выбрать остановку из списка.'); });
|
||||
return () => controller.abort();
|
||||
}, [city.id, theme]);
|
||||
|
||||
useEffect(() => { if (ready) updatePoints(); }, [stops, selected?.id, ready, walking]);
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !selected || !hasCoordinates(selected)) return;
|
||||
map.easeTo({ center: [selected.longitude, selected.latitude], zoom: Math.max(map.getZoom(), 16), padding: { top: 24, left: 24, right: 24, bottom: Math.min(bottomInset, Math.max(0, map.getContainer().clientHeight - 110)) }, duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 420 });
|
||||
}, [selected?.id, selected?.latitude, selected?.longitude, city.id, bottomInset]);
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !position) return;
|
||||
const element = document.createElement('span'); element.className = 'userLocationDot'; element.setAttribute('aria-label', 'Ваше местоположение');
|
||||
const marker = new Marker({ element }).setLngLat([position.longitude, position.latitude]).addTo(map);
|
||||
map.easeTo({ center: [position.longitude, position.latitude], zoom: 15, duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 420 });
|
||||
return () => { marker.remove(); };
|
||||
}, [position, city.id]);
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !ready || (!walking && !fitStopsKey)) return;
|
||||
const coordinates = walking ? [...walking.geometry.coordinates, ...(position ? [[position.longitude, position.latitude] as [number, number]] : []), ...(selected && hasCoordinates(selected) ? [[selected.longitude, selected.latitude] as [number, number]] : [])]
|
||||
: stops.filter(hasCoordinates).map(stop => [stop.longitude!, stop.latitude!] as [number, number]);
|
||||
if (!coordinates.length) return;
|
||||
const bounds = coordinates.reduce((extent, point) => [[Math.min(extent[0][0], point[0]), Math.min(extent[0][1], point[1])], [Math.max(extent[1][0], point[0]), Math.max(extent[1][1], point[1])]] as [[number, number], [number, number]], [coordinates[0], coordinates[0]] as [[number, number], [number, number]]);
|
||||
map.fitBounds(bounds, { padding: 48, maxZoom: walking ? 17 : 14, duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 420 });
|
||||
}, [fitStopsKey, walking, ready, city.id]);
|
||||
|
||||
return <div className="stopsMapFrame">
|
||||
<div className="stopsMapCanvas" ref={container} />
|
||||
<div className="mapZoom"><button aria-label="Приблизить карту" onClick={() => mapRef.current?.zoomIn()}><Plus size={22} /></button><button aria-label="Отдалить карту" onClick={() => mapRef.current?.zoomOut()}><Minus size={22} /></button></div>
|
||||
{problem && <div className="mapProblem" role="status"><MapPin size={18} /><span>{problem}</span>{onList && <button className="textButton" onClick={onList}>К списку</button>}<button className="iconButton" aria-label="Закрыть сообщение карты" onClick={() => setProblem('')}><X size={18} /></button></div>}
|
||||
{!ready && !problem && <div className="mapLoading" role="status">Загружаем карту…</div>}
|
||||
</div>;
|
||||
}
|
||||
57
src/components/WalkingDirections.tsx
Normal file
57
src/components/WalkingDirections.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||
import { DataError, loadWalking } from '../lib/api';
|
||||
import { walkingLink } from '../lib/geo';
|
||||
import { Dialog } from './Dialog';
|
||||
import { ArrowUpRight, MapPin, RefreshCw } from './Icons';
|
||||
import type { City, Coordinates, Stop, WalkingRoute } from '../types';
|
||||
|
||||
const StopsMap = lazy(() => import('./StopsMap'));
|
||||
|
||||
export function WalkingDirections({ city, stop, theme, online, onClose }: { city: City; stop: Stop & Coordinates; theme: 'light' | 'dark'; online: boolean; onClose: () => void }) {
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [position, setPosition] = useState<Coordinates | null>(null);
|
||||
const [route, setRoute] = useState<WalkingRoute | null>(null);
|
||||
const [status, setStatus] = useState('Определяем ваше местоположение…');
|
||||
const [error, setError] = useState('');
|
||||
const [approximate, setApproximate] = useState(false);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setRoute(null); setError(''); setPosition(null); setStatus('Определяем ваше местоположение…');
|
||||
async function build() {
|
||||
try {
|
||||
if (!navigator.onLine) throw new DataError('offline');
|
||||
if (!navigator.geolocation || !window.isSecureContext) throw new DataError('location_unavailable');
|
||||
const value = await new Promise<GeolocationPosition>((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 10000, maximumAge: 30000 }));
|
||||
if (controller.signal.aborted) return;
|
||||
const origin = { latitude: value.coords.latitude, longitude: value.coords.longitude };
|
||||
setPosition(origin); setApproximate(value.coords.accuracy > 100); setStatus('Строим пеший маршрут…');
|
||||
const result = await loadWalking(city.id, stop.id, origin, controller.signal);
|
||||
if (!controller.signal.aborted) { setRoute(result); setStatus(''); }
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
const code = error instanceof DataError ? error.code : typeof error === 'object' && error !== null && 'code' in error ? error.code : '';
|
||||
setError(code === 1 ? 'Доступ к местоположению закрыт. Разрешите его в браузере или откройте маршрут в картах.'
|
||||
: code === 'location_unavailable' ? 'Местоположение недоступно в этом браузере. Можно открыть маршрут в картах.'
|
||||
: code === 'walking_not_found' ? 'Не удалось найти пеший путь к этой остановке. Попробуйте открыть её в картах.'
|
||||
: code === 'offline' ? 'Для построения пути нужно подключение к интернету.' : 'Не удалось построить пеший маршрут. Попробуйте ещё раз.');
|
||||
setStatus('');
|
||||
}
|
||||
}
|
||||
// Delay once so React StrictMode does not open a second permission prompt.
|
||||
const timer = setTimeout(() => void build(), 0);
|
||||
return () => { clearTimeout(timer); controller.abort(); };
|
||||
}, [attempt, city.id, stop.id]);
|
||||
|
||||
const minutes = route ? Math.max(1, Math.ceil(route.duration / 60)) : 0;
|
||||
const duration = minutes >= 60 ? `${Math.floor(minutes / 60)} ч${minutes % 60 ? ` ${minutes % 60} мин` : ''}` : `${minutes} мин`;
|
||||
return <Dialog title="Пешком к остановке" className="walkingDialog" onClose={onClose}>
|
||||
<p className="walkingDestination"><MapPin size={19} /><span><strong>{stop.name}</strong>{stop.direction && <small>{stop.direction}</small>}</span></p>
|
||||
{status && <p className="walkingStatus" role="status"><RefreshCw size={19} className="spinning" />{status}</p>}
|
||||
{error && <div className="walkingError" role="status"><p>{error}</p><div><button className="secondaryButton" disabled={!online} onClick={() => setAttempt(value => value + 1)}>Повторить</button><a className="textButton" href={walkingLink(stop)} target="_blank" rel="noreferrer">Открыть в картах <ArrowUpRight size={17} /></a></div></div>}
|
||||
{route && <>
|
||||
<div className="walkingSummary"><strong>≈ {duration}</strong><span>{route.distance < 1000 ? `${Math.round(route.distance)} м` : `${(route.distance / 1000).toFixed(1)} км`} пешком</span></div>
|
||||
<div className="walkingMap"><Suspense fallback={<div className="mapLoading" role="status">Загружаем карту…</div>}><StopsMap city={city} stops={[stop]} selected={stop} position={position} theme={theme} bottomInset={0} walking={route} onSelect={() => {}} /></Suspense></div>
|
||||
<p className="walkingHint">От вашего местоположения до выбранной точки посадки. Время в пути приблизительное.{approximate && ' Местоположение определено неточно.'}</p>
|
||||
</>}
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from './storage';
|
||||
import { 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<WalkingRoute> {
|
||||
const params = new URLSearchParams({ resource: 'walk', cityId: String(cityId), stopId: String(stopId), lat: origin.latitude.toFixed(6), lon: origin.longitude.toFixed(6) });
|
||||
const response = await fetch(`/api/data?${params}`, { cache: 'no-store', signal: AbortSignal.any([signal, AbortSignal.timeout(20000)]) });
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new DataError(data.error || 'data_unavailable');
|
||||
// Walking routes contain the user's origin and never enter persistent snapshots.
|
||||
return data as WalkingRoute;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import type { Board, City, Journey, ServiceAlert, Stop, Timetable } from '../types';
|
||||
import type { Board, City, Journey, Route, RouteStops, ServiceAlert, Stop, StopDetails, Timetable } from '../types';
|
||||
import type { ResourceOptions } from './api';
|
||||
|
||||
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: 'Пример сообщения: автобусы следуют через улицу Гагарина. Остановка «Больница» временно перенесена к соседнему перекрёстку.',
|
||||
|
||||
18
src/lib/geo.ts
Normal file
18
src/lib/geo.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Coordinates } from '../types';
|
||||
|
||||
export function hasCoordinates(value: Partial<Coordinates>): value is Coordinates {
|
||||
return typeof value.latitude === 'number' && Number.isFinite(value.latitude) && Math.abs(value.latitude) <= 90
|
||||
&& typeof value.longitude === 'number' && Number.isFinite(value.longitude) && Math.abs(value.longitude) <= 180;
|
||||
}
|
||||
|
||||
// Straight-line distance, not an estimate of the walking route.
|
||||
export function distanceMeters(a: Coordinates, b: Coordinates): number {
|
||||
const rad = Math.PI / 180;
|
||||
const dLat = (b.latitude - a.latitude) * rad, dLon = (b.longitude - a.longitude) * rad;
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(a.latitude * rad) * Math.cos(b.latitude * rad) * Math.sin(dLon / 2) ** 2;
|
||||
return Math.round(6371000 * 2 * Math.asin(Math.sqrt(Math.min(1, h))));
|
||||
}
|
||||
|
||||
export function walkingLink(stop: Coordinates): string {
|
||||
return `https://www.google.com/maps/dir/?api=1&destination=${stop.latitude},${stop.longitude}&travelmode=walking`;
|
||||
}
|
||||
23
src/lib/routeGroups.ts
Normal file
23
src/lib/routeGroups.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Route, RouteStops, Stop } from '../types';
|
||||
|
||||
export const routeNumberKey = (number: string) => number.trim().replace(/^№\s*/, '').normalize('NFKC').toLocaleUpperCase('ru');
|
||||
|
||||
export function groupRoutes(routes: Route[]) {
|
||||
const groups = new Map<string, { key: string; number: string; routes: Route[] }>();
|
||||
for (const route of routes) {
|
||||
const key = routeNumberKey(route.number);
|
||||
const group = groups.get(key) ?? { key, number: route.number, routes: [] };
|
||||
if (!group.routes.some(value => value.id === route.id)) group.routes.push(route);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => a.key.localeCompare(b.key, 'ru', { numeric: true }));
|
||||
}
|
||||
|
||||
export function stopsForRoutes(catalog: Stop[], routes: RouteStops[]): Stop[] {
|
||||
const stops = new Map<number, Stop>();
|
||||
const known = new Map(catalog.map(stop => [stop.id, stop]));
|
||||
for (const route of routes) for (const stop of route.stops) {
|
||||
if (!stops.has(stop.id)) stops.set(stop.id, { ...stop, ...known.get(stop.id) });
|
||||
}
|
||||
return [...stops.values()];
|
||||
}
|
||||
41
src/lib/useStopRoutes.ts
Normal file
41
src/lib/useStopRoutes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { loadResource } from './api';
|
||||
import { groupRoutes, routeNumberKey, stopsForRoutes } from './routeGroups';
|
||||
import { positiveParam } from './links';
|
||||
import type { Route, RouteStops, Stop } from '../types';
|
||||
|
||||
export function useStopRoutes(mode: 'live' | 'demo', cityId: number | undefined, stops: Stop[]) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const line = routeNumberKey(params.get('line') ?? '');
|
||||
const direction = positiveParam(params.get('direction'));
|
||||
const catalog = useQuery({ queryKey: ['routes', mode, cityId], queryFn: () => loadResource<Route[]>('routes', mode, cityId), enabled: !!cityId, staleTime: 300000 });
|
||||
const groups = useMemo(() => groupRoutes(catalog.data?.data ?? []), [catalog.data]);
|
||||
const group = groups.find(group => group.key === line);
|
||||
const selectedRoutes = group?.routes.filter(route => !direction || route.id === direction) ?? [];
|
||||
const requests = useQueries({ queries: selectedRoutes.map(route => ({
|
||||
queryKey: ['routeStops', mode, cityId, route.id],
|
||||
queryFn: () => loadResource<RouteStops>('routeStops', mode, cityId, undefined, undefined, { routeId: route.id }),
|
||||
enabled: !!cityId, staleTime: 300000,
|
||||
})) });
|
||||
const data = requests.flatMap(query => query.data ? [query.data.data] : []);
|
||||
const pending = !!line && (catalog.isPending || requests.some(query => query.isPending));
|
||||
const unknown = !!line && !catalog.isPending && (!group || !selectedRoutes.length);
|
||||
const failed = !!line && !pending && (unknown || !data.length && requests.some(query => query.isError));
|
||||
const partial = data.length > 0 && requests.some(query => query.isError);
|
||||
const filtered = line ? stopsForRoutes(stops, data) : stops;
|
||||
const order = new Map<number, number>();
|
||||
if (selectedRoutes.length === 1 && data[0]) for (const stop of data[0].stops) if (!order.has(stop.id)) order.set(stop.id, stop.sequence);
|
||||
|
||||
function change(nextLine: string, nextDirection = '') {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete('line'); next.delete('direction');
|
||||
if (nextLine) next.set('line', nextLine);
|
||||
if (nextLine && nextDirection) next.set('direction', nextDirection);
|
||||
setParams(next);
|
||||
}
|
||||
function retry() { void catalog.refetch(); requests.forEach(query => { void query.refetch(); }); }
|
||||
const routeForStop = (id: number) => data.find(route => route.stops.some(stop => stop.id === id))?.route.id;
|
||||
return { catalog, groups, group, line, direction, filtered, order, pending, failed, partial, change, retry, routeForStop };
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
@use './departures';
|
||||
@use './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;
|
||||
|
||||
@@ -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<Coordinates>;
|
||||
export type Stop = { id: number; name: string; direction: string; routesCount: number; address?: string } & Partial<Coordinates>;
|
||||
export type StopDetails = Stop & { routes: Route[] };
|
||||
export type Route = { id: number; number: string; name: string };
|
||||
export type 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 };
|
||||
|
||||
Reference in New Issue
Block a user