221 lines
22 KiB
TypeScript
221 lines
22 KiB
TypeScript
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>;
|
||
}
|