feat: add passenger information and settings components

- Introduced `PassengerInfo.tsx` for displaying trip details, including upcoming trips and fare information.
- Added `PassengerSettings.tsx` for managing user-defined important places and fare settings.
- Implemented validation functions for passenger settings in `passenger-settings.mjs`.
- Created utility functions for transliterating stop names in `transliteration.ts`.
- Developed weather hint functionality in `weather-hint.ts` to provide precipitation probabilities.
- Established a mock server for testing passenger-related features in `passenger-server.mjs`.
- Added comprehensive tests for passenger guide logic and transliteration functionality.
This commit is contained in:
2026-10-12 20:30:49 +03:00
parent 823ab14367
commit e239de45dd
34 changed files with 687 additions and 68 deletions

View File

@@ -1,17 +1,16 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { NumericText } from './NumericText';
import { RouteBadge } from './RouteBadge';
import { FittedDestination } from './FittedDestination';
import { localDate } from './lib/timetable.mjs';
import { durationParts } from './lib/presentation.mjs';
import { readability } from './lib/departures';
import { transliterateStopName } from './lib/transliteration';
import type { OnwardJourney } from './lib/journey';
import type { Departure, Settings } from './types';
import type { CSSProperties } from 'react';
// Fixed-size cells move on the compositor. Both text sizes are fitted once,
// so moving to the next stop never resizes a cell or remounts its heading.
const JourneyRail = memo(function JourneyRail({ journey, focus }: { journey: OnwardJourney; focus: number }) {
const JourneyRail = memo(function JourneyRail({ journey, focus, translit }: { journey: OnwardJourney; focus: number; translit: boolean }) {
const rail = useRef<HTMLDivElement>(null);
const [geometry, setGeometry] = useState({ slot: 0, focused: 0, width: 0 });
useLayoutEffect(() => {
@@ -38,27 +37,32 @@ const JourneyRail = memo(function JourneyRail({ journey, focus }: { journey: Onw
'--journey-name-scale': geometry.focused > 0 ? geometry.slot / geometry.focused : .4 } as CSSProperties}>
<span className="journey-line" aria-hidden="true" style={{ transform: `scaleX(${scale})` }} />
<ol className="journey-rail" aria-label="Следующие остановки после посадки">
{journey.stops.map((stop, index) => <li className="journey-station" key={stop.id} data-focused={index === focus} data-before={index < focus} data-after={index > focus} aria-current={index === focus ? 'step' : undefined}>
<span className="journey-duration" aria-label={stop.minutes === null ? 'Время пути неизвестно' : `В пути ${stop.minutes} мин по расписанию`}>
{stop.minutes === null ? '—' : stop.minutes === 0 ? <><span>&lt;1</span><small>мин</small></> : durationParts(stop.minutes).map(part => <span key={part.unit}><NumericText value={part.value} /><small>{part.unit}</small></span>)}
</span>
<div className="journey-station-name">
<div className="journey-name-layer journey-name-context" aria-hidden={index === focus}><FittedDestination text={stop.name} /></div>
<div className="journey-name-layer journey-name-focus" aria-hidden={index !== focus}><FittedDestination text={stop.name} /></div>
</div>
<span className="journey-node" aria-hidden="true" />
</li>)}
{journey.stops.map((stop, index) => {
const latinName = translit ? transliterateStopName(stop.name) : stop.name;
return <li className="journey-station" key={stop.id} data-focused={index === focus} data-before={index < focus} data-after={index > focus} aria-current={index === focus ? 'step' : undefined}>
<span className="journey-duration" aria-label={stop.minutes === null ? 'Время пути неизвестно' : `В пути ${stop.minutes} мин по расписанию`}>
{stop.minutes === null ? '—' : stop.minutes === 0 ? <><span>&lt;1</span><small>мин</small></> : durationParts(stop.minutes).map(part => <span key={part.unit}><NumericText value={part.value} /><small>{part.unit}</small></span>)}
</span>
<div className="journey-station-name">
{(['context', 'focus'] as const).map(size => <div key={size} className={`journey-name-layer journey-name-${size}`} aria-hidden={size === 'focus' ? index !== focus : index === focus}>
{latinName !== stop.name && <span className="journey-transliteration" lang="ru-Latn" aria-hidden="true">{latinName}</span>}
<FittedDestination key={translit ? 'bilingual' : 'native'} text={stop.name} />
</div>)}
</div>
<span className="journey-node" aria-hidden="true" />
</li>;
})}
{journey.more > 0 && <li className="journey-continuation"><span className="journey-continuation-label">Ещё {journey.more} остановок</span><span className="journey-continuation-line" aria-hidden="true" /></li>}
</ol>
</div>;
}, (previous, next) => previous.focus === next.focus && previous.journey.more === next.journey.more
}, (previous, next) => previous.focus === next.focus && previous.translit === next.translit && previous.journey.more === next.journey.more
&& previous.journey.stops.length === next.journey.stops.length && previous.journey.stops.every((stop, index) => {
const other = next.journey.stops[index];
return stop.id === other.id && stop.name === other.name && stop.minutes === other.minutes;
}));
export function NextStops({ departure, journey, loading, now, paused, settings, active, onPages }: {
departure?: Departure; journey: OnwardJourney | null; loading: boolean; now: number; paused: boolean; settings: Settings;
export function NextStops({ departure, journey, loading, paused, settings, active, onPages }: {
departure?: Departure; journey: OnwardJourney | null; loading: boolean; paused: boolean; settings: Settings;
active: boolean; onPages: (pages: number) => void;
}) {
const [page, setPage] = useState(0);
@@ -73,14 +77,12 @@ export function NextStops({ departure, journey, loading, now, paused, settings,
const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 6000 : 4000);
return () => clearInterval(timer);
}, [paused, active, pages, reading.calm]);
const tomorrow = departure && localDate(Date.parse(departure.scheduledAt)) > localDate(now);
return <><h3>Дальше по маршруту</h3>
return <><h3>Дальше по ближайшему рейсу</h3>
{!departure ? <p className="journey-empty">{loading ? 'Получаем расписание' : 'Нет доступного рейса'}</p> : <>
<div className="journey-route"><RouteBadge number={departure.routeNumber} filled /><div className="journey-trip"><div className="journey-route-name"><FittedDestination text={departure.destination} /></div><time dateTime={departure.scheduledAt}><NumericText value={departure.scheduledTime} />{tomorrow && <span className="journey-day">завтра</span>}</time></div></div>
{!journey ? <p className="journey-empty">{loading ? 'Загружаем остановки' : 'Схема маршрута пока недоступна'}</p> : !count ? <p className="journey-empty">Здесь конечная этого рейса</p> : <>
<div className="journey-metro" data-active={active} data-calm={reading.calm} data-loop={page > 0 && focus === 0}>
<div className="journey-tour-heading"><span>{settings.demo ? 'Демонстрация поездки' : 'После посадки'}</span><span className="journey-order">{focus + 1}-я остановка</span></div>
<div className="journey-map-space"><JourneyRail journey={journey} focus={focus} /></div>
<div className="journey-map-space"><JourneyRail journey={journey} focus={focus} translit={settings.journeyTranslitEnabled} /></div>
</div>
<div className="journey-caption"><span className="journey-caption-full">Время пути по расписанию</span><span className="journey-caption-compact">После посадки · по расписанию</span>{pages > 1 && <span className="journey-page" aria-label={`${focus + 1} из ${count} остановок в обзоре`}>{focus + 1} / {count}</span>}</div>
</>}