Files
ridebus-board/src/App.tsx
2026-10-08 22:56:41 +03:00

221 lines
22 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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Check, Clock, Fullscreen, Info, Pause, Play, Refresh, SettingsIcon, Shield, WifiOff } from './Icons';
import { NumericText } from './NumericText';
import { RouteBadge } from './RouteBadge';
import { FittedDestination } from './FittedDestination';
import { useIdleControls } from './useIdleControls';
import { PassengerQr } from './PassengerQr';
import { WeatherCard, WeatherSummary } from './WeatherBlock';
import { localDate, timetableAvailable, timetableDepartures } from './lib/timetable.mjs';
import logo from './assets/ridebus-logo.svg?raw';
import { colorScheme, departurePage, stopDirection } from './lib/presentation.mjs';
import type { CSSProperties } from 'react';
import type { Alert, BoardData, Config, Departure, Scene, Settings } from './types';
import { activeAlerts, isFresh, minutesUntil, shouldHoldScene, upcomingDepartures } from './lib/board.mjs';
import { hasSavedSettings, readSettings, saveSettings } from './lib/storage';
import { useBoard, useClock, useTimetable, useWeather, fallbackConfig } from './hooks';
import { SettingsPanel } from './SettingsPanel';
const clockFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit', second: '2-digit' });
const dateFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', weekday: 'long', day: 'numeric', month: 'long' });
const timeFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' });
const sceneNames: Record<Scene, string> = { departures: 'Ближайшие рейсы', info: 'Информация', ad: 'Реклама' };
function Countdown({ departure, now, large = false }: { departure: Departure; now: number; large?: boolean }) {
const minutes = minutesUntil(departure, now);
if (localDate(Date.parse(departure.scheduledAt)) > localDate(now)) return <div className={`countdown countdown-future ${large ? 'countdown-large' : ''}`} aria-label={`Завтра в ${departure.scheduledTime} по расписанию`}><small>завтра</small><span><NumericText value={departure.scheduledTime} /></span></div>;
return <div className={`countdown ${large ? 'countdown-large' : ''}`} aria-label={minutes > 0 ? `Через ${minutes} минут по расписанию` : 'Сейчас по расписанию'}>
<span className={minutes === 0 ? 'countdown-now' : ''}>{minutes > 0 ? <NumericText value={minutes} /> : 'сейчас'}</span>{minutes > 0 && <small>мин</small>}
</div>;
}
function AlertCard({ alert, compact = false }: { alert: Alert; compact?: boolean }) {
return <article className={`notice ${alert.type !== 'info' ? 'notice-warning' : ''} ${compact ? 'notice-compact' : ''}`}>
<div className="notice-title"><Info size={21} /><span>{alert.type === 'cancellation' ? 'Отмена рейсов' : 'Изменения движения'}</span></div>
{!!alert.routeNumbers.length && <div className="notice-routes">{alert.routeNumbers.map(number => `№${number}`).join(' · ')}</div>}
<h3>{alert.title}</h3><p>{alert.message}</p>
</article>;
}
function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused }: {
data: BoardData | null; departures: Departure[]; now: number; alerts: Alert[]; loading: boolean; rowCount: number; fresh: boolean; error: string; paused: boolean;
}) {
const first = departures[0];
const table = useRef<HTMLDivElement>(null);
const [tableHeight, setTableHeight] = useState(360);
const [page, setPage] = useState(0);
const capacity = Math.max(2, Math.floor(tableHeight / 50));
const visible: { items: Departure[]; start: number; count: number; pages: number; size: number } = departurePage(departures.slice(1), rowCount, capacity, page);
const rows = Math.max(1, Math.min(rowCount, visible.size));
useLayoutEffect(() => {
if (!table.current) return;
const observer = new ResizeObserver(entries => setTableHeight(entries[0].contentRect.height));
observer.observe(table.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (paused || visible.pages < 2) return;
const timer = setInterval(() => setPage(value => value + 1), 7000);
return () => clearInterval(timer);
}, [paused, visible.pages]);
return <div className="departure-layout">
<section className="departure-content" aria-label="Ближайшие рейсы">
<div className="section-heading"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span><span className="source-label"><Clock /> По расписанию{data?.mode === 'demo' ? ' · демо' : ''}</span></div>
{first ? <div className="hero-departure">
<RouteBadge number={first.routeNumber} large />
<div className="hero-destination"><div className="transport-kind"><BusFront size={19} /> {first.type === 'minibus' ? 'Маршрутное такси' : 'Автобус'}</div><h2>{first.destination}</h2>
<div className="hero-details">{first.note || first.routeName}<time className="schedule-time" dateTime={first.scheduledAt}><NumericText value={first.scheduledTime} /></time></div></div>
<Countdown departure={first} now={now} large />
</div> : <div className="empty-departures"><BusFront size={46} /><h2>{loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}</h2>
<p>{loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}</p></div>}
<div className="next-heading"><span className="eyebrow">СЛЕДОМ ПРИБУДУТ</span>{visible.pages > 1 ? <span className="page-indicator">{visible.start + 1}–{visible.start + visible.items.length} из {visible.count}</span> : <ArrowDown size={22} />}</div>
<div ref={table} className="departure-table" data-rows={rowCount} data-paged={visible.pages > 1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}>
{visible.items.map((departure, index) => <article className="departure-row" key={`position-${index}`}>
<RouteBadge number={departure.routeNumber} />
<div className="row-destination"><FittedDestination text={departure.destination} rowHeight={tableHeight / rows} /><p>{departure.note || (departure.type === 'minibus' ? 'Маршрутное такси' : 'Автобус')}</p></div>
<time dateTime={departure.scheduledAt} className="row-time"><NumericText value={departure.scheduledTime} /></time>
<Countdown departure={departure} now={now} />
</article>)}
{first && departures.length === 1 && <p className="empty-following">Это последний доступный рейс</p>}
{!first && <div className="quiet-placeholder"><span /><span /><span /></div>}
</div>
</section>
{alerts[0] && <div className="transport-notice"><Info /><span>{alerts[0].routeNumbers.map(number => `№${number}`).join(' · ')} {alerts[0].title}</span></div>}
</div>;
}
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, timetable, weather, now }: { data: BoardData | null; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
config: Config; timetable: ReturnType<typeof useTimetable>; weather: ReturnType<typeof useWeather>; now: number }) {
const panel = useRef<HTMLElement>(null);
useEffect(() => {
const element = panel.current;
if (!element || paused || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let timer: ReturnType<typeof setInterval>;
const start = setTimeout(() => {
const overflow = element.scrollHeight - element.clientHeight;
if (overflow > 0) timer = setInterval(() => { element.scrollTop += overflow / (Math.max(1, seconds - 7) * 10); }, 100);
}, 3500);
return () => { clearTimeout(start); clearInterval(timer); };
}, [seconds, paused, page]);
return <section ref={panel} className="info-screen" aria-label="Информация для пассажиров">
<div className="info-intro"><h2>{alerts.length ? 'Изменения движения' : 'Пока вы ждёте.'}</h2>
{!alerts.length && <nav className="information-tabs" aria-label="Информация на вертикальном экране">{['Маршруты', 'Погода', 'RideBus'].map((name, index) => <button key={name} aria-current={page % 3 === index ? 'page' : undefined} onClick={() => onPage(index)}>{name}</button>)}</nav>}</div>
{alerts.length ? <div className="info-notices">{alerts.map(alert => <AlertCard key={alert.id} alert={alert} />)}</div> : <div className="info-grid">
<section className="info-card routes-info" data-active={page % 3 === 0}><div className="card-icon"><BusFront /></div><h3>Маршруты остановки</h3><div className="route-cloud">{[...new Set(data?.routes.map(route => route.number))].map(number => <span key={number}>{number}</span>)}</div><div className="cache-caption"><Check />{timetableAvailable(timetable.data, now) && timetable.saved ? `Полное расписание сохранено · маршрутов: ${timetable.data?.routes.length}` : timetable.loading ? 'Сохраняем полное расписание…' : timetable.saved === false ? 'Не удалось сохранить полный кеш' : 'Полный кеш сейчас недоступен'}</div><p className="schedule-explanation">Время по расписанию. Фактическое прибытие зависит от дорожной обстановки.</p></section>
<section className="info-card weather-info" data-active={page % 3 === 1}><WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} /></section>
<section className="info-card qr-info" data-active={page % 3 === 2}><PassengerQr config={config} stop={data?.stop} /></section>
</div>}
{!alerts.length && <div className="info-status"><Shield /><span>{data?.alertsAvailable === false || !fresh ? 'Актуальные предупреждения сейчас недоступны' : 'Нет активных предупреждений для этой остановки'}</span></div>}
</section>;
}
export default function App() {
const [settings, setSettings] = useState<Settings>(() => {
const saved = readSettings();
if (new URLSearchParams(location.search).get('demo') === '1') saved.demo = true;
return saved;
});
const [config, setConfig] = useState(fallbackConfig);
const [settingsOpen, setSettingsOpen] = useState(false);
const controlsVisible = useIdleControls(settingsOpen);
const [scene, setScene] = useState<Scene>('departures');
const [infoPage, setInfoPage] = useState(0);
const [paused, setPaused] = useState(false);
const [startedAt, setStartedAt] = useState(Date.now());
const [notice, setNotice] = useState('');
const [fullscreen, setFullscreen] = useState(false);
const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const { data: liveData, failed, loading, offset, error, offlineSaved, refresh } = useBoard(settings, config);
const timetable = useTimetable(settings, config), weather = useWeather(settings, config);
const now = useClock(offset);
const date = localDate(now);
const fullDepartures = useMemo(() => timetableDepartures(timetable.data, date) as Departure[], [timetable.data, date]);
const fullAvailable = !settings.demo && timetableAvailable(timetable.data, now);
const data: BoardData | null = liveData || (fullAvailable && timetable.data ? { ...timetable.data, departures: fullDepartures, alerts: [], alertsAvailable: false, partial: false, serviceDate: date, mode: 'live' } : null);
const departures: Departure[] = fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now && (!settings.routeFilter || String(item.routeId) === settings.routeFilter)) : upcomingDepartures(data, now, settings.routeFilter);
const alerts: Alert[] = activeAlerts(data?.alerts || [], now, settings.routeFilter, data?.routes || []);
const fresh = settings.demo || isFresh(data, now, failed);
const held = shouldHoldScene(settings, departures[0], now);
const urgent = alerts.some(alert => alert.type === 'cancellation' || alert.type === 'detour');
const scenes: Scene[] = settings.adsEnabled && !urgent ? ['departures', 'info', 'ad'] : ['departures', 'info'];
const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? Math.max(settings.infoSeconds, Math.min(60, alerts.reduce((sum, alert) => sum + alert.message.length, 0) / 18)) : settings.adSeconds;
const elapsed = Date.now() - startedAt;
const progress = Math.min(1, elapsed / (seconds * 1000));
const sceneState = useRef({ scenes, scene, held, settingsOpen, paused });
sceneState.current = { scenes, scene, held, settingsOpen, paused };
const showNotice = useCallback((text: string) => {
setNotice(text); if (noticeTimer.current) clearTimeout(noticeTimer.current);
noticeTimer.current = setTimeout(() => setNotice(''), 4000);
}, []);
const goTo = useCallback((next: Scene) => {
if (sceneState.current.held && next !== 'departures') { showNotice('Главный экран удерживается: до ближайшего рейса не больше 2 минут'); return; }
const change = () => { if (sceneState.current.scene === 'info' && next !== 'info') setInfoPage(value => value + 1); setScene(next); setStartedAt(Date.now()); };
if (document.startViewTransition && !matchMedia('(prefers-reduced-motion: reduce)').matches) document.startViewTransition(() => flushSync(change));
else change();
}, [showNotice]);
const nextScene = useCallback(() => {
const state = sceneState.current;
goTo(state.scenes[(state.scenes.indexOf(state.scene) + 1) % state.scenes.length]);
}, [goTo]);
const toggleFullscreen = useCallback(async () => {
try { if (document.fullscreenElement) await document.exitFullscreen(); else await document.documentElement.requestFullscreen(); }
catch { showNotice('Полный экран недоступен в этом браузере'); }
}, [showNotice]);
useEffect(() => {
const controller = new AbortController();
fetch('/api/config', { signal: controller.signal }).then(response => { if (!response.ok) throw new Error(); return response.json(); }).then((value: Config) => {
setConfig(value);
if (!hasSavedSettings() && new URLSearchParams(location.search).get('demo') !== '1') setSettings(previous => ({ ...previous, stopId: value.defaultStopId, demo: !value.configured }));
}).catch(() => {});
return () => controller.abort();
}, []);
useEffect(() => { document.title = `${data?.stop.name || config.stopName} · RideBus Board`; }, [data?.stop.name, config.stopName]);
useEffect(() => {
if (held) { setScene('departures'); setStartedAt(Date.now()); }
else setStartedAt(Date.now());
}, [held]);
useEffect(() => { if (urgent && scene === 'ad') { setScene('info'); setStartedAt(Date.now()); } }, [urgent, scene]);
useEffect(() => {
if (paused || settingsOpen) { setStartedAt(Date.now()); return; }
if (!held && elapsed >= seconds * 1000) nextScene();
}, [now, held, paused, settingsOpen, elapsed, seconds, nextScene]);
useEffect(() => {
function keydown(event: KeyboardEvent) {
if (event.target instanceof HTMLElement && (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(event.target.tagName) || event.target.isContentEditable)) return;
if (event.code === 'KeyS') { event.preventDefault(); setSettingsOpen(value => !value); }
if (settingsOpen) return;
if (event.code === 'KeyF') { event.preventDefault(); void toggleFullscreen(); }
if (event.code === 'Space') { event.preventDefault(); setPaused(value => !value); setStartedAt(Date.now()); }
if (event.code === 'ArrowRight') { event.preventDefault(); nextScene(); }
}
const changed = () => setFullscreen(Boolean(document.fullscreenElement));
document.addEventListener('keydown', keydown); document.addEventListener('fullscreenchange', changed);
return () => { document.removeEventListener('keydown', keydown); document.removeEventListener('fullscreenchange', changed); };
}, [settingsOpen, toggleFullscreen, nextScene]);
useEffect(() => () => { if (noticeTimer.current) clearTimeout(noticeTimer.current); }, []);
const timeParts = clockFormat.format(now).split(':');
const updateLabel = fullAvailable && timetable.data ? timeFormat.format(Date.parse(timetable.data.updatedAt)) : data ? timeFormat.format(Date.parse(data.updatedAt)) : '—';
const direction = stopDirection(data?.stop);
return <div className={`board ${scene !== 'departures' ? 'board-secondary' : ''} ${controlsVisible ? '' : 'controls-idle'}`} data-row-count={settings.rowCount} style={{ '--accent': colorScheme(settings.colorScheme).color } as CSSProperties}>
<header className="board-header"><div className="stop-heading"><h1>{data?.stop.name || config.stopName}</h1>
<div className="stop-meta">{direction && <div className="stop-direction"><ArrowRight /><span>{direction}</span></div>}<WeatherSummary data={weather.data} now={now} failed={weather.failed} /></div></div>
<div className="header-right"><div className="brand" aria-label="RideBus"><span className="official-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: logo }} /><span className="brand-word">ridebus</span></div><div className="clock" aria-label={`Текущее время ${clockFormat.format(now)}`}><NumericText value={`${timeParts[0]}:${timeParts[1]}`} /><small><NumericText value={timeParts[2]} /></small></div><div className="today">{dateFormat.format(now)}</div></div>
</header>
{!fresh && !settings.demo && <div className="offline-banner" role="status"><WifiOff /><span>{data ? `Нет свежих данных. Сохранённое расписание · обновлено в ${updateLabel}` : loading ? 'Подключаемся к RideBus…' : 'Не удалось загрузить расписание. Проверьте подключение.'}</span><button onClick={refresh} aria-label="Повторить подключение"><Refresh /></button></div>}
<main className="scene-container"><div className="scene" key={scene} data-scene={scene}>
{scene === 'departures' && <DeparturesScreen data={data} departures={departures} now={now} alerts={alerts} loading={loading} rowCount={settings.rowCount} fresh={fresh} error={error} paused={paused || settingsOpen} />}
{scene === 'info' && <InfoScreen data={data} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} timetable={timetable} weather={weather} now={now} />}
{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>
{scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{departures[0] ? <><RouteBadge number={departures[0].routeNumber} /><strong>{departures[0].destination}</strong><Countdown departure={departures[0]} now={now} /><span className="source-label">По расписанию</span></> : <span>Нет доступных рейсов</span>}</div>}
<footer className="board-footer"><div className="cycle-progress"><span style={{ transform: `scaleX(${held || paused || settingsOpen ? 0 : progress})` }} /></div>
<div className="footer-main"><nav className="scene-navigation" aria-label="Экраны табло">{scenes.map((item, index) => <button key={item} onClick={() => goTo(item)} className={scene === item ? 'active' : ''} aria-label={`0${index + 1} ${sceneNames[item]}`} title={sceneNames[item]} aria-current={scene === item ? 'page' : undefined}><span className="scene-index">0{index + 1}</span><span>{sceneNames[item]}</span></button>)}</nav>
<div className="footer-status">{!fresh && !settings.demo && <WifiOff />}<span>{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}</span></div>
<div className="board-controls"><button className="icon-button" onClick={() => { setPaused(value => !value); setStartedAt(Date.now()); }} aria-label={paused ? 'Продолжить смену экранов' : 'Приостановить смену экранов'} title="Пауза · пробел">{paused ? <Play /> : <Pause />}</button><button className="icon-button" onClick={nextScene} aria-label="Следующий экран" title="Следующий экран · →"><ArrowRight /></button><button className="icon-button" onClick={() => void toggleFullscreen()} aria-label={fullscreen ? 'Выйти из полного экрана' : 'На весь экран'} title="Полный экран · F"><Fullscreen /></button><button className="icon-button" onClick={() => setSettingsOpen(true)} aria-label="Настройки табло" title="Настройки · S"><SettingsIcon /></button></div></div>
</footer>
{notice && <div className="toast" role="status">{notice}</div>}
{settingsOpen && <SettingsPanel settings={settings} config={config} data={data} onClose={() => { setSettingsOpen(false); setStartedAt(Date.now()); }} onFullscreen={() => void toggleFullscreen()} onSave={value => { setSettings(value); saveSettings(value); setScene('departures'); setStartedAt(Date.now()); setSettingsOpen(false); showNotice('Настройки сохранены'); }} />}
</div>;
}