feat: enhance info screen layout and improve fare card display with route information

This commit is contained in:
2026-10-25 20:42:13 +03:00
parent 6deac42033
commit 2298b5ea13
5 changed files with 61 additions and 26 deletions

View File

@@ -116,22 +116,42 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo
</div>;
}
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now, settings, journey, onJourneyPages, guide, onPlacePages }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
config: Config; weather: ReturnType<typeof useWeather>; now: number; settings: Settings; journey: Omit<ComponentProps<typeof NextStops>, 'active' | 'onPages'>; onJourneyPages: (pages: number) => void;
type InfoBlock = ReturnType<typeof informationBlocks>[number];
// Below this card size the grid would shrink the journey rail and QR code past
// readability; the screen then shows one card at a time instead.
const INFO_GAP = 24, INFO_CARD_MIN_HEIGHT = 300;
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, onAdvance, onCompact, blocks, dwell, config, weather, now, settings, journey, guide, onPlacePages }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
onAdvance: () => void; onCompact: (compact: boolean) => void; blocks: readonly InfoBlock[]; dwell: (id: InfoBlock['id']) => number;
config: Config; weather: ReturnType<typeof useWeather>; now: number; settings: Settings; journey: Omit<ComponentProps<typeof NextStops>, 'active'>;
guide: PassengerGuide; onPlacePages: (pages: number) => void }) {
const panel = useRef<HTMLElement>(null);
const grid = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(false);
const blocks = informationBlocks(settings, { places: !!guide.places.length, fare: !!guide.fare });
// Cards shown during this visit; the scene remounts on every visit.
const [shown, setShown] = useState(1);
const activeBlock = blocks[page % blocks.length]?.id;
useEffect(() => { if (alerts.length || !settings.infoServiceEnabled || compact && activeBlock !== 'service') onJourneyPages(1); }, [alerts.length, settings.infoServiceEnabled, compact, activeBlock, onJourneyPages]);
useEffect(() => { if (alerts.length || !settings.infoPlacesEnabled || compact && activeBlock !== 'places') onPlacePages(1); }, [alerts.length, settings.infoPlacesEnabled, compact, activeBlock, onPlacePages]);
// Four or five cards form two rows; up to three share one.
const rows = blocks.length > 3 ? 2 : 1;
const columns = Math.ceil(blocks.length / rows);
useEffect(() => { if (alerts.length || !settings.infoPlacesEnabled) onPlacePages(1); }, [alerts.length, settings.infoPlacesEnabled, onPlacePages]);
useEffect(() => { onCompact(compact); }, [compact, onCompact]);
useLayoutEffect(() => {
if (!grid.current) return;
const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(blocks.length > 3 || width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330 || settings.displayProfile === 'street' || settings.viewingDistance === 'far'); });
const observer = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
setCompact(width < 1000 || innerHeight > innerWidth || innerHeight < 650 || (rows > 1 ? (height - INFO_GAP) / rows < INFO_CARD_MIN_HEIGHT : height < 330)
|| settings.displayProfile === 'street' || settings.viewingDistance === 'far');
});
observer.observe(grid.current);
return () => observer.disconnect();
}, [alerts.length, blocks.length, settings.displayProfile, settings.viewingDistance]);
}, [alerts.length, rows, settings.displayProfile, settings.viewingDistance]);
// One card at a time: show every card within this visit, each for its own time.
const activeDwell = activeBlock ? dwell(activeBlock) : 0;
useEffect(() => {
if (!compact || alerts.length || paused || shown >= blocks.length) return;
const timer = setTimeout(() => { setShown(value => value + 1); onAdvance(); }, activeDwell * 1000);
return () => clearTimeout(timer);
}, [compact, alerts.length, paused, shown, blocks.length, page, activeDwell, onAdvance]);
useEffect(() => {
const element = panel.current;
if (!element || paused || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
@@ -143,17 +163,17 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config
return () => { clearTimeout(start); clearInterval(timer); };
}, [seconds, paused, page]);
return <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} data-blocks={blocks.length} data-many-blocks={blocks.length > 3}
style={{ '--info-columns': blocks.length, '--info-tabs-width': `${blocks.length > 1 ? blocks.length * 100 : 0}px` } as CSSProperties} aria-label="Информация для пассажиров">
style={{ '--info-columns': columns, '--info-rows': rows, '--info-tabs-width': `${blocks.length > 1 ? blocks.length * 100 : 0}px` } as CSSProperties} aria-label="Информация для пассажиров">
{(alerts.length > 0 || blocks.length > 1) && <div className="info-intro" data-tabs-only={!alerts.length}>
{alerts.length > 0 && <h2>Изменения движения</h2>}
{!alerts.length && blocks.length > 1 && <nav className="information-tabs" aria-label="Блоки информации">{blocks.map((block, index) => <button key={block.id} aria-current={activeBlock === block.id ? 'page' : undefined} onClick={() => onPage(index)}>{block.name}</button>)}</nav>}</div>}
{alerts.length ? <div className="info-notices">{alerts.map(alert => <AlertCard key={alert.id} alert={alert} />)}</div> : <div ref={grid} className="info-grid">
{blocks.map(block => <section key={block.id} className={`info-card ${block.id}-info`} data-active={activeBlock === block.id}>
{block.id === 'service' && <NextStops key={journey.departure?.id || 'none'} {...journey} active={!compact || activeBlock === 'service'} onPages={onJourneyPages} />}
{block.id === 'service' && <NextStops key={journey.departure?.id || 'none'} {...journey} active={!compact || activeBlock === 'service'} />}
{block.id === 'weather' && <WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} hintEnabled={settings.weatherHintEnabled} />}
{block.id === 'qr' && <PassengerQr config={config} stop={data?.stop} />}
{block.id === 'places' && <PlacesCard trips={guide.places} now={now} paused={paused} settings={settings} active={!compact || activeBlock === 'places'} onPages={onPlacePages} />}
{block.id === 'fare' && guide.fare && <FareCard fare={guide.fare} />}
{block.id === 'fare' && guide.fare && <FareCard fare={guide.fare} route={guide.fareRoute} />}
</section>)}
</div>}
{!alerts.length && (data?.alertsAvailable === false || !fresh) && <div className="info-status"><Shield /><span>Предупреждения сейчас недоступны</span></div>}
@@ -180,7 +200,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
const controlsVisible = useIdleControls(settingsOpen);
const [scene, setScene] = useState<Scene>('departures');
const [infoPage, setInfoPage] = useState(0);
const [journeyPages, setJourneyPages] = useState(1);
const [infoCompact, setInfoCompact] = useState(false);
const [paused, setPaused] = useState(false);
const [startedAt, setStartedAt] = useState(Date.now());
const [notice, setNotice] = useState('');
@@ -233,8 +253,15 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour');
const hasAlerts = alerts.length > 0;
const scenes = useMemo(() => night.active ? ['departures'] as Scene[] : boardScenes(settings, hasAlerts, urgent, { places: availablePlaces, fare: availableFare }), [settings, hasAlerts, urgent, night.active, availablePlaces, availableFare]);
const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? Math.max(settings.infoSeconds, !hasAlerts && settings.infoServiceEnabled ? journeyPages * (reading.calm ? 6 : 4) : 0,
!hasAlerts && availablePlaces ? placePages * (reading.calm ? 10 : 7) : 0, Math.min(60, alerts.reduce((sum, alert) => sum + alert.message.length, 0) / 18)) : settings.adSeconds;
const infoBlocks = useMemo(() => informationBlocks(settings, { places: availablePlaces, fare: availableFare }), [settings, availablePlaces, availableFare]);
// Each card needs time for its own pages: every onward stop, every page of places.
const journeyPages = Math.max(1, journey?.stops.length || 0);
const dwell = useCallback((id: string) => id === 'service' ? Math.max(settings.infoSeconds, journeyPages * (reading.calm ? 6 : 4))
: id === 'places' ? Math.max(settings.infoSeconds, placePages * (reading.calm ? 10 : 7)) : settings.infoSeconds, [settings.infoSeconds, journeyPages, placePages, reading.calm]);
const infoSeconds = hasAlerts ? Math.max(settings.infoSeconds, Math.min(60, alerts.reduce((sum, alert) => sum + alert.message.length, 0) / 18))
: infoCompact && infoBlocks.length > 1 ? infoBlocks.reduce((sum, block) => sum + dwell(block.id), 0) : Math.max(settings.infoSeconds, ...infoBlocks.map(block => dwell(block.id)));
const advanceInfo = useCallback(() => setInfoPage(value => value + 1), []);
const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? infoSeconds : settings.adSeconds;
const elapsed = Date.now() - startedAt;
const progress = Math.min(1, elapsed / (seconds * 1000));
const sceneState = useRef({ scenes, scene, held, settingsOpen, paused, calm: reading.calm || night.active });
@@ -321,7 +348,8 @@ function BoardApplication({ management }: { management?: { state: ManagedState;
<main className="scene-container"><div className="scene" key={scene} data-scene={scene}>
{night.active && night.first && <NightScreen first={night.first} completedToday={night.completedToday} now={now} routeFiltered={Boolean(settings.routeFilter)} alerts={alerts} paused={paused || settingsOpen} />}
{!night.active && scene === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} settings={settings} lastTripId={guide.lastTripId} />}
{!night.active && scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={value => { setInfoPage(value); setStartedAt(Date.now()); }} config={config} weather={weather} now={now} settings={settings} onJourneyPages={setJourneyPages} onPlacePages={setPlacePages} guide={guide}
{!night.active && scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={value => { setInfoPage(value); setStartedAt(Date.now()); }} onAdvance={advanceInfo} onCompact={setInfoCompact} blocks={infoBlocks} dwell={dwell}
config={config} weather={weather} now={now} settings={settings} onPlacePages={setPlacePages} guide={guide}
journey={{ departure: first, journey, loading: timetable.loading || loading, paused: paused || settingsOpen, settings }} />}
{!night.active && scene === 'ad' && <section className="ad-screen"><span className="eyebrow">РЕКЛАМА · ПРЕДПРОСМОТР</span><h2>{settings.adTitle}</h2><p>{settings.adText}</p><div className="ad-signature">Время для нового.<ArrowUpRight /></div></section>}
</div></main>

View File

@@ -1,6 +1,7 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { NumericText } from './NumericText';
import { FittedDestination } from './FittedDestination';
import { RouteBadge } from './RouteBadge';
import { durationParts } from './lib/presentation.mjs';
import { readability } from './lib/departures';
import { transliterateStopName } from './lib/transliteration';
@@ -61,9 +62,8 @@ const JourneyRail = memo(function JourneyRail({ journey, focus, translit }: { jo
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;
export function NextStops({ departure, journey, loading, paused, settings, active }: {
departure?: Departure; journey: OnwardJourney | null; loading: boolean; paused: boolean; settings: Settings; active: boolean;
}) {
const [page, setPage] = useState(0);
const reading = readability(settings);
@@ -71,13 +71,13 @@ export function NextStops({ departure, journey, loading, paused, settings, activ
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>
// The persistent bar names the nearest trip too, but the card must not rely on it.
return <><h3 className="card-heading"><span>Дальше по ближайшему рейсу</span>{departure && <RouteBadge number={departure.routeNumber} filled />}</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}>

View File

@@ -49,6 +49,6 @@ export function PlacesCard({ trips, now, paused, active, settings, onPages }: {
</article>)}
</div><div className="passenger-caption"><span>Прямые рейсы · по расписанию</span>{pages > 1 && <span><NumericText value={`${page % pages + 1}`} /> / {pages}</span>}</div></>;
}
export function FareCard({ fare }: { fare: FareTerms }) {
return <><h3>Оплата поездки</h3><div className="fare-body"><span className="fare-label">Для ближайшего маршрута</span><div className="fare-price"><FittedDestination text={fare.price} as="h2" /></div><div className="fare-payment"><FittedDestination text={fare.payment} /></div></div><div className="passenger-caption">Данные на {checked.format(Date.parse(`${fare.checkedAt}T12:00:00+03:00`))}</div></>;
export function FareCard({ fare, route }: { fare: FareTerms; route: { number: string; type: string } | null }) {
return <><h3 className="card-heading"><span>Оплата поездки</span>{route && <RouteBadge number={route.number} filled />}</h3><div className="fare-body"><span className="fare-label">{route ? route.type === 'minibus' ? 'Маршрутное такси' : 'Автобус' : 'Для ближайшего маршрута'}</span><div className="fare-price"><FittedDestination text={fare.price} as="h2" /></div><div className="fare-payment"><FittedDestination text={fare.payment} /></div></div><div className="passenger-caption">Данные на {checked.format(Date.parse(`${fare.checkedAt}T12:00:00+03:00`))}</div></>;
}

View File

@@ -27,10 +27,10 @@ function segment(bundle: Timetable, trip: Departure, stopId: number): Segment |
return { stops, current, end, direction: Math.sign(end - start) };
}
export type PlaceTrip = { place: Place; departure: DisplayDeparture | null; minutes: number | null; arrivalAt: string | null };
export type PassengerGuide = { places: PlaceTrip[]; fare: FareTerms | null; lastTripId: string | null };
export type PassengerGuide = { places: PlaceTrip[]; fare: FareTerms | null; fareRoute: { number: string; type: string } | null; lastTripId: string | null };
export function passengerGuide(settings: Settings, bundle: Timetable | null, data: BoardData | null, now: number, cityId: number, reliableAlerts: boolean): PassengerGuide {
const result: PassengerGuide = { places: [], fare: null, lastTripId: null };
const result: PassengerGuide = { places: [], fare: null, fareRoute: null, lastTripId: null };
if (data && data.mode !== 'demo' && (data.cityId !== cityId || data.stop.id !== settings.stopId)) data = null;
const available = Boolean(bundle && bundle.cityId === cityId && bundle.stop.id === settings.stopId && timetableAvailable(bundle, now));
const date = localDate(now);
@@ -42,6 +42,8 @@ export function passengerGuide(settings: Settings, bundle: Timetable | null, dat
// A future verification date must not publish a tariff early.
result.fare = settings.infoFareEnabled && first ? settings.fareRules.find(rule => rule.routeId === first.routeId && rule.checkedAt <= date)
|| settings.fareByType.find(rule => rule.cityId === cityId && rule.type === first.type && rule.checkedAt <= date) || null : null;
// The fare applies to this trip's route; the card names it.
result.fareRoute = result.fare && first ? { number: first.routeNumber, type: first.type } : null;
if (!available || !bundle) return result;
if (settings.infoPlacesEnabled && settings.places.length && bundle.routes.some(route => route.stops?.length)) {
result.places = settings.places.filter(place => place.stopId !== settings.stopId).map(place => {

View File

@@ -91,10 +91,14 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.information-tabs { display: none; gap: 8px; }
.information-tabs button { font-size: 14px; border: 1px solid var(--outline); background: transparent; padding: 8px 12px; border-radius: 10px; color: var(--secondary); }
.information-tabs button[aria-current='page'] { color: var(--accent-ink); border-color: var(--accent); background: var(--accent); }
.info-grid { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: repeat(var(--info-columns, 3), minmax(0, 1fr)); gap: 24px; }
.info-grid { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: repeat(var(--info-columns, 3), minmax(0, 1fr)); grid-template-rows: repeat(var(--info-rows, 1), minmax(0, 1fr)); gap: 24px; }
/* Five cards: the journey rail takes both rows of the first column instead of leaving a hole. */
.info-screen[data-blocks='5']:not([data-compact='true']) .service-info { grid-row: span 2; }
.info-card { min-width: 0; min-height: 0; border: 1px solid var(--line); border-radius: 24px; padding: clamp(18px, 1.7vw, 32px); display: flex; flex-direction: column; overflow: auto; container-type: size; }
.info-card > * { flex-shrink: 0; }
.info-card h3 { font-size: clamp(23px, 2vw, 38px); line-height: 1.15; font-weight: 600; letter-spacing: -.03em; margin: 0 0 18px; }
.info-card h3.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.card-heading .route-badge { width: auto; min-width: 2.3em; height: 1.5em; padding: 0 .4em; font-size: .85em; border-radius: .34em; }
.card-icon { color: var(--accent); margin-bottom: 16px; }
.card-icon svg { width: 32px; height: 32px; }
.info-card p { color: var(--secondary); font-size: clamp(18px, 1.75vw, 32px); line-height: 1.4; margin: 0 0 16px; }
@@ -238,7 +242,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.qr-info .qr-description p, .passenger-link { font-size: 16px; }
.passenger-link svg { width: 18px; height: 18px; }
}
.info-screen[data-compact='true'] .info-grid { grid-template-columns: minmax(0, 1fr); }
.info-screen[data-compact='true'] .info-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
.info-screen[data-compact='true'] .information-tabs { display: flex; }
.info-screen[data-compact='true'] .info-card:not([data-active='true']) { display: none; }
.info-screen[data-compact='true'] .card-icon { display: none; }
@@ -445,7 +449,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
.today { font-size: clamp(12px, 2vw, 20px); }
.brand-word { display: none; }
.info-intro { flex-direction: column; align-items: flex-start; gap: 10px; }
.info-grid { grid-template-columns: minmax(0, 1fr); }
.info-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
.info-card:not([data-active='true']) { display: none; }
.information-tabs { display: flex; }
.persistent-next > .eyebrow { display: none; }
@@ -545,6 +549,7 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
additional tabs never consume the name's width on a short TV viewport. */
.info-screen[data-compact=true][data-many-blocks=true][data-cards=true] .info-intro { position: static; margin: 0 0 10px; flex-shrink: 0; }
.info-screen[data-compact=true][data-many-blocks=true][data-cards=true] .info-card > h3 { padding-right: 0; min-height: 0; display: block; }
.info-screen[data-compact=true][data-many-blocks=true][data-cards=true] .info-card > h3.card-heading { display: flex; }
.info-screen[data-many-blocks=true] .information-tabs { flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.info-screen[data-many-blocks=true] .information-tabs button { padding: 7px 10px; font-size: 13px; }
@media (max-width: 440px) {