- 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.
92 lines
6.7 KiB
TypeScript
92 lines
6.7 KiB
TypeScript
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||
import { NumericText } from './NumericText';
|
||
import { FittedDestination } from './FittedDestination';
|
||
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, translit }: { journey: OnwardJourney; focus: number; translit: boolean }) {
|
||
const rail = useRef<HTMLDivElement>(null);
|
||
const [geometry, setGeometry] = useState({ slot: 0, focused: 0, width: 0 });
|
||
useLayoutEffect(() => {
|
||
const element = rail.current;
|
||
if (!element) return;
|
||
const measure = () => {
|
||
const cell = element.querySelector<HTMLElement>('.journey-station');
|
||
const heading = cell?.querySelector<HTMLElement>('.journey-name-focus');
|
||
const next = { slot: cell?.getBoundingClientRect().width || 0,
|
||
focused: heading ? parseFloat(getComputedStyle(heading).width) : 0, width: element.getBoundingClientRect().width };
|
||
setGeometry(previous => previous.slot === next.slot && previous.focused === next.focused && previous.width === next.width ? previous : next);
|
||
};
|
||
const observer = new ResizeObserver(measure);
|
||
observer.observe(element); measure();
|
||
return () => observer.disconnect();
|
||
}, [journey.stops.length]);
|
||
// With no continuation the line ends at the last real stop, including a
|
||
// single-stop journey. Otherwise it meets the moving dashed continuation.
|
||
const remaining = journey.stops.length - 1 - focus;
|
||
const lineEnd = !journey.more && !remaining ? 0
|
||
: (remaining + (journey.more ? 1 : 0)) * geometry.slot + geometry.focused - geometry.slot;
|
||
const scale = geometry.width > 24 && geometry.slot > 0 ? Math.max(0, lineEnd / (geometry.width - 24)) : 1;
|
||
return <div ref={rail} className="journey-rail-window" style={{ '--journey-focus': focus,
|
||
'--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) => {
|
||
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><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.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, 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);
|
||
const reading = readability(settings);
|
||
const count = journey?.stops.length || 0;
|
||
const pages = Math.max(1, count);
|
||
const focus = page % pages;
|
||
useEffect(() => { setPage(0); }, [departure?.id]);
|
||
useEffect(() => { if (active) onPages(pages); }, [active, pages, onPages]);
|
||
useEffect(() => {
|
||
if (paused || !active || pages < 2) return;
|
||
const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 6000 : 4000);
|
||
return () => clearInterval(timer);
|
||
}, [paused, active, pages, reading.calm]);
|
||
return <><h3>Дальше по ближайшему рейсу</h3>
|
||
{!departure ? <p className="journey-empty">{loading ? 'Получаем расписание' : 'Нет доступного рейса'}</p> : <>
|
||
{!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} 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>
|
||
</>}
|
||
</>}
|
||
</>;
|
||
}
|