From 2298b5ea135dafc2a67f11766fa9508e1e3ad7b5 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Sun, 25 Oct 2026 20:42:13 +0300 Subject: [PATCH] feat: enhance info screen layout and improve fare card display with route information --- src/App.tsx | 56 ++++++++++++++++++++++++++++---------- src/NextStops.tsx | 10 +++---- src/PassengerInfo.tsx | 4 +-- src/lib/passenger-guide.ts | 6 ++-- src/styles.css | 11 ++++++-- 5 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7c89bde..6c8ff1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -116,22 +116,42 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo ; } -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; now: number; settings: Settings; journey: Omit, 'active' | 'onPages'>; onJourneyPages: (pages: number) => void; +type InfoBlock = ReturnType[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; now: number; settings: Settings; journey: Omit, 'active'>; guide: PassengerGuide; onPlacePages: (pages: number) => void }) { const panel = useRef(null); const grid = useRef(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
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) &&
{alerts.length > 0 &&

Изменения движения

} {!alerts.length && blocks.length > 1 && }
} {alerts.length ?
{alerts.map(alert => )}
:
{blocks.map(block =>
- {block.id === 'service' && } + {block.id === 'service' && } {block.id === 'weather' && } {block.id === 'qr' && } {block.id === 'places' && } - {block.id === 'fare' && guide.fare && } + {block.id === 'fare' && guide.fare && }
)}
} {!alerts.length && (data?.alertsAvailable === false || !fresh) &&
Предупреждения сейчас недоступны
} @@ -180,7 +200,7 @@ function BoardApplication({ management }: { management?: { state: ManagedState; const controlsVisible = useIdleControls(settingsOpen); const [scene, setScene] = useState('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;
{night.active && night.first && } {!night.active && scene === 'departures' && } - {!night.active && scene === 'info' && { setInfoPage(value); setStartedAt(Date.now()); }} config={config} weather={weather} now={now} settings={settings} onJourneyPages={setJourneyPages} onPlacePages={setPlacePages} guide={guide} + {!night.active && scene === 'info' && { 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' &&
РЕКЛАМА · ПРЕДПРОСМОТР

{settings.adTitle}

{settings.adText}

Время для нового.
}
diff --git a/src/NextStops.tsx b/src/NextStops.tsx index 3b24c98..a959a33 100644 --- a/src/NextStops.tsx +++ b/src/NextStops.tsx @@ -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 <>

Дальше по ближайшему рейсу

+ // The persistent bar names the nearest trip too, but the card must not rely on it. + return <>

Дальше по ближайшему рейсу{departure && }

{!departure ?

{loading ? 'Получаем расписание' : 'Нет доступного рейса'}

: <> {!journey ?

{loading ? 'Загружаем остановки' : 'Схема маршрута пока недоступна'}

: !count ?

Здесь конечная этого рейса

: <>
0 && focus === 0}> diff --git a/src/PassengerInfo.tsx b/src/PassengerInfo.tsx index 0e68e1c..2b6958e 100644 --- a/src/PassengerInfo.tsx +++ b/src/PassengerInfo.tsx @@ -49,6 +49,6 @@ export function PlacesCard({ trips, now, paused, active, settings, onPages }: { )}
Прямые рейсы · по расписанию{pages > 1 && / {pages}}
; } -export function FareCard({ fare }: { fare: FareTerms }) { - return <>

Оплата поездки

Для ближайшего маршрута
Данные на {checked.format(Date.parse(`${fare.checkedAt}T12:00:00+03:00`))}
; +export function FareCard({ fare, route }: { fare: FareTerms; route: { number: string; type: string } | null }) { + return <>

Оплата поездки{route && }

{route ? route.type === 'minibus' ? 'Маршрутное такси' : 'Автобус' : 'Для ближайшего маршрута'}
Данные на {checked.format(Date.parse(`${fare.checkedAt}T12:00:00+03:00`))}
; } diff --git a/src/lib/passenger-guide.ts b/src/lib/passenger-guide.ts index 282e55c..04a3016 100644 --- a/src/lib/passenger-guide.ts +++ b/src/lib/passenger-guide.ts @@ -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 => { diff --git a/src/styles.css b/src/styles.css index 7368fea..508dc22 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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) {