Files
ridebus-board/src/NextStops.tsx
Zakhar Timoshenko e239de45dd 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.
2026-10-12 20:30:49 +03:00

92 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>&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.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>
</>}
</>}
</>;
}