diff --git a/README.md b/README.md index 589a3ec..d6aa62e 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,13 @@ npm start - Автоматический ночной режим включается после завершения движения по полному расписанию, если весь ночной перерыв до первого следующего рейса длиннее двух часов. На чёрном фоне — первый рейс, направление, часы без секунд и актуальные уведомления. Информация и реклама не сменяют этот экран. При оставшихся 60 минутах возвращается обычное табло; в настройках → «Экраны» можно выбрать 45 минут или выключить режим. Дневная тема и палитра восстанавливаются автоматически. По умолчанию режим автоматический, возврат за 60 минут. - Настройки → «Оформление» → «Читаемость»: профили «Телевизор» / «Уличное табло» и расстояние «Близко» / «Средне» / «Далеко». Для улицы и дальнего просмотра текст крупнее, одновременно видимых строк меньше. Уличный профиль использует спокойное короткое появление вместо вертикальной прокрутки текста. Информационные карточки при дальнем просмотре показываются по одной. - В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки. -- Информационный экран: «Дальше по маршруту», погода, QR-код и предупреждения. Схема показывает до четырёх следующих остановок ближайшего доступного рейса и время пути от текущей остановки по расписанию. Учитывает выбранный маршрут, отмены, укороченные и обратные рейсы; на конечной не придумывает продолжение. Неизвестное время отмечено «—». Горизонтальная линия плавно сдвигается влево, выделенное название остановки увеличивается, соседние остаются компактными — по мотивам [инфосистемы «Квант»](https://www.artlebedev.ru/kvant/moscow/). Это обзор поездки после посадки, а не положение автобуса: остановки сменяются каждые 4 секунды (6 для уличного профиля), сцена автоматически удлиняется до полного обзора. В узкой карточке название и схема подстраиваются под доступное место. Часы и ближайший рейс остаются видимыми. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Маршрут», «Погода», «RideBus». -- В настройках → «Экраны» → «Блоки информации» каждый из трёх блоков можно включить или отключить. Оставшиеся карточки занимают доступную ширину, отключённые пропускаются в компактном цикле. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает все блоки. +- Информационный экран: «Дальше по ближайшему рейсу», погода, QR-код и предупреждения. Схема показывает до четырёх следующих остановок ближайшего доступного рейса и время пути от текущей остановки по расписанию. Учитывает выбранный маршрут, отмены, укороченные и обратные рейсы; на конечной не придумывает продолжение. Неизвестное время отмечено «—». Горизонтальная линия плавно сдвигается влево, выделенное название остановки увеличивается, соседние остаются компактными — по мотивам [инфосистемы «Квант»](https://www.artlebedev.ru/kvant/moscow/). Это обзор поездки после посадки, а не положение автобуса: остановки сменяются каждые 4 секунды (6 для уличного профиля), сцена автоматически удлиняется до полного обзора. В узкой карточке название и схема подстраиваются под доступное место. Часы остаются в шапке, ближайший рейс показан в нижней панели; карточка не дублирует номер, направление и время отправления. В вертикальной и короткой компоновке карточки сменяются при следующих посещениях экрана; их также можно выбрать кнопками «Маршрут», «Погода», «RideBus». +- Настройки → «Экраны» → «Транслит названий остановок»: дополнительная строка латиницей над основным названием в схеме ближайшего рейса. Например, «Пушкинская» → «Pushkinskaya». По умолчанию выключен; сохраняется после перезагрузки. Это транслитерация русских и белорусских названий, без перевода. Работает в обеих темах и профилях экрана. +- «До важных мест»: до трёх заданных остановок назначения. Для каждой — ближайший прямой рейс, отправление, время в пути и прибытие по полному расписанию. Учитываются направление, конечная конкретного рейса, отмены и переход через полночь. При неизвестных временных смещениях показывается только отправление; без актуального полного расписания с порядком остановок карточка скрыта. Реальные места по умолчанию не выбраны. Их можно задать в настройках → «Экраны» или в админке устройства. На коротком экране места сменяются страницами каждые 7 секунд (10 для улицы); сцена удлиняется, чтобы показать все страницы. +- Напоминание о последнем рейсе появляется за 30 минут до последнего отправления данного маршрута в данном направлении перед ночным перерывом. Расчёт использует ту же политику ночного перерыва, включая рейсы после полуночи, и полный действующий календарь. Это расписание, а не обещание фактического движения. Напоминание видно и на главном экране, и в нижней панели информации; его можно отключить. +- Погодная подсказка — вероятность осадков в ближайший час из почасового прогноза Open-Meteo. Нулевая вероятность сохраняется; при отсутствии ближайшего часа или устаревшем прогнозе подсказка скрыта. Переключатель — «Вероятность осадков» в настройках экранов. +- «Оплата поездки»: крупная цена и способы оплаты для ближайшего доступного маршрута, с датой подтверждения. По данным пользователя от 11 октября 2026 года для Полоцка: автобус **1,05 BYN**, маршрутка **2,10 BYN**, оплата **QR-кодом или наличными**. Основные тарифы привязаны к городу и типу транспорта; для других городов не подставляются. Можно задать исключения для конкретных маршрутов. Явно удалённые тарифы остаются удалёнными; без подходящего подтверждённого тарифа карточка скрыта. QR-код RideBus открывает расписание; платёжный код не генерируется. +- В настройках → «Экраны» → «Блоки информации» каждый блок можно включить или отключить. Отключённые и незаполненные карточки пропускаются; при четырёх и более доступных блоках они показываются по одной, чтобы сохранить читаемость. При отключении всех блоков информационный экран исключается из обычного цикла; активные предупреждения о движении всё равно показываются. Выбор сохраняется в браузере; «Сбросить цикл» включает три базовых блока, сохраняя пассажирские настройки. - Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности. - Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла. - Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят. @@ -107,8 +112,12 @@ docker compose up --build -d Если API записывает ранний рейс 00:xx в новый календарный день, ночная логика дополнительно проверяет короткое продолжение предыдущего блока движения и первый длинный перерыв после него. Это позволяет включить режим после реального рейса 00:40 перед первым утренним в 05:44, не скрывая непрерывное ночное движение или дневные паузы. Даты рейсов и условия календаря при этом не изменяются. -Схема маршрута использует порядок и временные смещения остановок из полного кеша (`routes[].stops`) и конечную конкретного рейса (`endStopId`). Метаданные загружаются вместе с расписаниями, дополнительных запросов при смене рейса нет. Старые кеши без этих полей остаются пригодны для табло, но схема появляется после обновления полного расписания. Переключатель «Дальше по маршруту» сохраняет прежний ключ `infoServiceEnabled`, поэтому существующие настройки локального и удалённого управления совместимы; миграция БД не требуется. +Схема маршрута использует порядок и временные смещения остановок из полного кеша (`routes[].stops`) и конечную конкретного рейса (`endStopId`). Метаданные загружаются вместе с расписаниями, дополнительных запросов при смене рейса нет. Старые кеши без этих полей остаются пригодны для табло, но схема появляется после обновления полного расписания. Переключатель «Дальше по ближайшему рейсу» сохраняет прежний ключ `infoServiceEnabled`, поэтому существующие настройки локального и удалённого управления совместимы; миграция БД не требуется. Транслит управляется флагом `journeyTranslitEnabled`; в старой конфигурации он считается выключенным. Для удалённого переключения обновите табло, backend и frontend `polotsk-transit-api`: опция находится в настройках устройства, в блоке информации. В обоих профилях схема анимирует только `transform` и `opacity`: ячейки имеют постоянную ширину, крупный и компактный варианты названия подбираются заранее и не пересоздаются при каждом переходе. В уличном профиле движение спокойнее. Схема не перерисовывается из-за тиков часов, если её данные не изменились; на конечной линия заканчивается у последнего настоящего маркера. Для локального сравнения работы подбора шрифтов можно запустить `JOURNEY_PERF=1 node test/fixtures/night-server.mjs`. Параметр `BOARD_FIXTURE_DIST` позволяет использовать сохранённую старую сборку. Наблюдатели пишут `JOURNEY_PERF` в консоль: изменения размеров текстовых блоков, записи размера шрифта и замены заголовков за 1200 мс после смены остановки. Это диагностический стенд, а не замер FPS устройства; он не включён в рабочее табло и Docker runtime. + +Пассажирские настройки устройства: `infoPlacesEnabled`, `infoFareEnabled`, `lastTripNoticeEnabled`, `weatherHintEnabled` (по умолчанию `true`); `places: [{stopId, name}]` (до 3); `fareByType: [{cityId, type, price, payment, checkedAt}]` (до 2, `type: bus | minibus`); `fareRules: [{routeId, price, payment, checkedAt}]` (до 32). Дата `checkedAt` — действительная дата `YYYY-MM-DD`, будущая дата не публикует тариф раньше срока. Сервер проверяет принадлежность остановок городу, маршрутов — площадке, основных тарифов — городу устройства. Старые конфигурации получают совместимые значения; миграция SQL не нужна. Для удалённого управления обновите backend/frontend `polotsk-transit-api` и Board вместе. + +Локальный стенд для этих блоков: `node test/fixtures/passenger-server.mjs`, затем `http://127.0.0.1:5194/__fixture`. Кнопки выбирают ближайший тип транспорта и открывают табло с явными тестовыми остановками, расписанием и погодой. Стенд использует отдельный origin, не обращается к рабочей БД и не изменяет настройки основного табло. diff --git a/artifacts/journey-translit-960x540.png b/artifacts/journey-translit-960x540.png new file mode 100644 index 0000000..72a61ea Binary files /dev/null and b/artifacts/journey-translit-960x540.png differ diff --git a/artifacts/journey-translit-street-light-960x540.png b/artifacts/journey-translit-street-light-960x540.png new file mode 100644 index 0000000..c9e2e3e Binary files /dev/null and b/artifacts/journey-translit-street-light-960x540.png differ diff --git a/artifacts/passenger-admin-fields.png b/artifacts/passenger-admin-fields.png new file mode 100644 index 0000000..f849247 Binary files /dev/null and b/artifacts/passenger-admin-fields.png differ diff --git a/artifacts/passenger-fare-minibus-960x540.jpg b/artifacts/passenger-fare-minibus-960x540.jpg new file mode 100644 index 0000000..25e5ad2 Binary files /dev/null and b/artifacts/passenger-fare-minibus-960x540.jpg differ diff --git a/artifacts/passenger-places-390x844.png b/artifacts/passenger-places-390x844.png new file mode 100644 index 0000000..9b0feea Binary files /dev/null and b/artifacts/passenger-places-390x844.png differ diff --git a/artifacts/passenger-places-960x540.png b/artifacts/passenger-places-960x540.png new file mode 100644 index 0000000..88a0d81 Binary files /dev/null and b/artifacts/passenger-places-960x540.png differ diff --git a/artifacts/passenger-weather-960x540.png b/artifacts/passenger-weather-960x540.png new file mode 100644 index 0000000..f17dba8 Binary files /dev/null and b/artifacts/passenger-weather-960x540.png differ diff --git a/artifacts/passenger-weather-street-light-960x540.png b/artifacts/passenger-weather-street-light-960x540.png new file mode 100644 index 0000000..cd5aa29 Binary files /dev/null and b/artifacts/passenger-weather-street-light-960x540.png differ diff --git a/artifacts/verification-journey-transliteration.md b/artifacts/verification-journey-transliteration.md new file mode 100644 index 0000000..8e1365f --- /dev/null +++ b/artifacts/verification-journey-transliteration.md @@ -0,0 +1,30 @@ +# Схема ближайшего рейса и транслит + +Проверено 11 октября 2026 года. + +## Изменения + +- Убраны повторные номер, направление и время отправления из карточки схемы. Ближайший рейс остаётся в постоянной нижней панели. +- Карточка и её переключатель называются «Дальше по ближайшему рейсу». +- Убран заголовок «Информация» и занимаемый им отступ. Вкладки компактной компоновки и заголовок уведомлений сохранены. +- Дополнительная строка латиницей появляется над основным названием при включённом `journeyTranslitEnabled`. Основное название сохраняется, уже латинские названия не дублируются. Это читаемая транслитерация, без перевода. +- Настройка есть локально в «Экраны» и в админке устройств `polotsk-transit-api`. Старые настройки без нового поля используют `false`; миграция SQL не нужна. + +## Проверки + +- `ridebus-board`: TypeScript + Vite build; 84 теста пройдены, включая транслитерацию, сохранение настроек и совместимость удалённых конфигураций. +- `polotsk-transit-api`: frontend build; 8 тестов сервиса устройств и 1 тест HTTP API пройдены. Новый флаг сохраняется при удалённом обновлении и возвращается в конфигурации устройства. Повреждённые значения отклоняются. +- Браузер: 960×540, 879×593, 855×723, 390×844, 1280×800. На узкой и широкой карточке нет вертикального переполнения; латинская строка находится выше основного названия. На широкой компоновке нет пустого места от скрытого общего заголовка. +- Обе темы и оба профиля проверены на 960×540. Включение и отключение флага сохраняются после перезагрузки. +- Тестовые счётчики на переходах 1→2, 2→3, 3→4 с транслитом: в обоих профилях ноль изменений размеров блока названия и ноль записей размера шрифта за 1200 мс. Два заранее подготовленных слоя текста продолжают двигаться через transform/opacity. Это проверка работы DOM, а не измерение FPS на телевизоре. + +## Ограничения + +Локальный API стенда `127.0.0.1:5193` в проверочном браузере возвращал `ERR_BLOCKED_BY_CLIENT`. Сервер отдельно вернул валидный ответ и полное расписание с HTTP 200; визуальные проверки сделаны на встроенных демонстрационных данных. Xiaomi Mi Box 4 в этом запуске не проверялся. + +## Примеры + +- `journey-translit-960x540.png`: тёмная тема, ТВ-профиль, демонстрационные данные. +- `journey-translit-street-light-960x540.png`: светлая тема, уличный профиль, демонстрационные данные. + +Для удалённого переключения нужно обновить табло, backend и frontend админки. diff --git a/artifacts/verification-passenger-info.md b/artifacts/verification-passenger-info.md new file mode 100644 index 0000000..dfec859 --- /dev/null +++ b/artifacts/verification-passenger-info.md @@ -0,0 +1,28 @@ +# Пассажирские блоки · 11 октября 2026 + +Реализованы «До важных мест», напоминание о последнем рейсе перед ночным перерывом, вероятность осадков в ближайший час и оплата ближайшего доступного маршрута. Изменены Board и backend/frontend `/Users/xtimms/Projects/polotsk-transit-api`. Места в рабочей конфигурации по умолчанию пусты. Пользователь подтвердил тарифы для Полоцка: автобус 1,05 BYN, маршрутка 2,10 BYN, QR-кодом или наличными; дата подтверждения 2026-10-11 фиксирована. + +## Проверки кода + +- `npm test`: 96/96, включая короткие/обратные рейсы, подтверждённые отмены, фильтр маршрута, неизвестные временные смещения, прибытие на следующий день, ночные продолжения в обоих форматах дня обслуживания, дневные перерывы, выбор тарифа и изоляцию городов. +- `npm run build` Board: TypeScript + Vite успешно. +- `node --test test/boardDevices.test.js test/boardDevicesApi.test.js` в backend: 11/11. Проверены ограничения полей, даты, чужой город/маршрут, нормализация старых настроек, сохранение пустых массивов и авторизация существующего протокола. +- `npm run build` frontend админки: успешно; прежние предупреждения о Browserslist/размере bundle остаются. +- `git diff --check` обоих проектов: без ошибок. + +## Проверки браузера + +Использован production build на явном локальном стенде `test/fixtures/passenger-server.mjs`, отдельный origin 5194, фиктивные остановки/расписание/погода. Настройки основного origin 5180 и рабочая БД не изменялись. + +- Подтверждён действительный DOM viewport 960×540, 879×593, 855×723, 390×844 и 1440×900. Нет переполнения страницы и видимых новых карточек. На 1440×900 три карточки проверены одновременно; строки важных мест также не переполняются. +- На 960×540 название места имеет размер около 43 px, тариф — около 82 px. Места разбиваются на три страницы; наблюдался автоматический переход к следующему месту. Число страниц удлиняет сцену до полного обзора. На высоком узком экране все три коротких тестовых места помещаются сразу. +- Ближайший автобус показывает 1,05 BYN; после выбора маршрутки через видимый интерфейс стенда — 2,10 BYN. Видны способы оплаты и фиксированная дата подтверждения. +- Погода показывает 80% в ближайший час. После локального выключения подсказка исчезает, после повторного включения появляется. Светлая тема и профиль «Уличное табло» проверены на 960×540. +- Выключение важных мест сохраняется после перезагрузки и убирает вкладку. Данные мест и тарифов доступны в меню «Экраны». Кнопка «Ниже» прокрутила расширенное меню (DOM scrollTop 1099). +- На стенде `test/fixtures/device-flow-server.mjs` устройство подключено кодом 0246; сохранены места, оба тарифа и переключатели. Реальный локальный HTTP-протокол передал цену устройству. Админка показала «Настройки применены» для v1, v2 (оплата выключена) и v3 (восстановлена); выключенная вкладка исчезла с устройства. Используется репозиторий в памяти, без PostgreSQL. +- Устройство без полного расписания не показывает выбранное важное место, поскольку маршрут до него не подтверждён. Карточка оплаты продолжает работать по ближайшему доступному рейсу. +- В консоли тестового приложения ошибок/предупреждений не обнаружено. + +Скриншоты: `passenger-places-960x540.png`, `passenger-weather-960x540.png`, `passenger-places-390x844.png`, `passenger-fare-minibus-960x540.jpg`, `passenger-weather-street-light-960x540.png`. Данные и время на них тестовые. Захват in-app browser через fullPage иногда пропускает неизменившиеся compositor layers; снимок не заменяет DOM-проверку заполнения/границ. + +Физический Mi Box / Hi Browser, FPS устройства, PostgreSQL integration test и развёртывание на рабочем сервере в этой проверке не выполнялись. Настройки хранятся в существующем JSON, новая миграция SQL не требуется; для удалённого управления обновляются оба проекта. diff --git a/src/App.tsx b/src/App.tsx index a0227c6..3b6e060 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,10 @@ import type { ManagedState } from './useDeviceControl'; import { DeviceSetup, ManagedPanel } from './DeviceSetup'; import { nightWindow } from './lib/night'; import { NightScreen } from './NightScreen'; +import { PlacesCard, FareCard, LastTripNotice } from './PassengerInfo'; +import { passengerGuide } from './lib/passenger-guide'; +import type { PassengerGuide } from './lib/passenger-guide'; +import { demoGuideTimetable } from './lib/demo'; 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' }); @@ -51,8 +55,9 @@ function DepartureStatus({ departure, now, large = false }: { departure: Display ; } -export function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused, settings }: { +export function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused, settings, lastTripId }: { data: BoardData | null; departures: DisplayDeparture[]; now: number; alerts: Alert[]; loading: boolean; rowCount: number; fresh: boolean; error: string; paused: boolean; settings: Settings; + lastTripId?: string | null; }) { const first = firstAvailable(departures); const next = settings.showNextSameRoute ? nextSameRoute(departures, first) : undefined; @@ -88,6 +93,7 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo {waiting ?
{localDate(Date.parse(first.scheduledAt)) > localDate(now) ? 'Завтра в' : 'Сегодня в'}{first.status === 'notice' && Есть изменения}
: } :
0}>

{departures.length ? 'Ближайшие рейсы отменены' : loading ? 'Получаем расписание' : !data ? 'Расписание недоступно' : fresh ? 'На сегодня рейсов больше нет' : 'Сохранённые рейсы закончились'}

{departures.length ? 'Подробности — в информации о движении' : loading ? 'Ближайший транспорт появится здесь' : error === 'api_not_configured' ? 'Подключите RideBus API или включите деморежим в настройках' : !data || !fresh ? 'Ожидаем подключения к RideBus' : 'Новые рейсы появятся с началом движения'}

} + {first && first.id === lastTripId && }
{!first && departures.length ? 'ОТМЕНЁННЫЕ РЕЙСЫ' : remaining.some(item => item.status === 'cancelled') ? 'ДРУГИЕ РЕЙСЫ' : 'СЛЕДОМ ПРИБУДУТ'}{visible.pages > 1 ? {visible.start + 1}{visible.items.length > 1 ? `–${visible.start + visible.items.length}` : ''} из {visible.count} : }
1} style={{ '--rows': rows, '--row-height': `${tableHeight / rows}px` } as CSSProperties}> {visible.items.map((departure, index) =>
@@ -104,17 +110,19 @@ export function DeparturesScreen({ data, departures, now, alerts, loading, rowCo
; } -function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now, settings, journey, onJourneyPages }: { 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 }) { +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; + guide: PassengerGuide; onPlacePages: (pages: number) => void }) { const panel = useRef(null); const grid = useRef(null); const [compact, setCompact] = useState(false); - const blocks = informationBlocks(settings); + const blocks = informationBlocks(settings, { places: !!guide.places.length, fare: !!guide.fare }); 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]); useLayoutEffect(() => { if (!grid.current) return; - const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; setCompact(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(blocks.length > 3 || width < 1000 || innerHeight > innerWidth || innerHeight < 650 || height < 330 || settings.displayProfile === 'street' || settings.viewingDistance === 'far'); }); observer.observe(grid.current); return () => observer.disconnect(); }, [alerts.length, blocks.length, settings.displayProfile, settings.viewingDistance]); @@ -128,15 +136,18 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config }, 3500); 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="Информация для пассажиров"> -

{alerts.length ? 'Изменения движения' : 'Информация'}

- {!alerts.length && blocks.length > 1 && }
+ {(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 === 'weather' && } + {block.id === 'weather' && } {block.id === 'qr' && } + {block.id === 'places' && } + {block.id === 'fare' && guide.fare && }
)}
} {!alerts.length && (data?.alertsAvailable === false || !fresh) &&
Предупреждения сейчас недоступны
} @@ -186,6 +197,14 @@ function BoardApplication({ management }: { management?: { state: ManagedState; const statusById = new Map(allDepartures.map(item => [item.id, item])); const departures = rawDepartures.map(item => statusById.get(item.id)!); const first = firstAvailable(departures); + const minuteKey = Math.floor(now / 60000); + const guideDemo = useMemo(() => settings.demo ? demoGuideTimetable(liveData, settings.stopId) : null, [settings.demo, settings.stopId, liveData]); + // Work once per minute or when the nearest trip/config/data changes, not on + // every clock digit: full-route searches should not tax the TV's CPU. + const guide = useMemo(() => passengerGuide(settings, settings.demo ? guideDemo : timetable.data, liveData, now, config.cityId, reliableAlerts), + [settings, guideDemo, timetable.data, liveData, minuteKey, config.cityId, reliableAlerts, first?.id, fullAvailable]); + const [placePages, setPlacePages] = useState(1); + const availablePlaces = !!guide.places.length, availableFare = !!guide.fare; const journey = useMemo(() => settings.demo ? demoJourney(first) : onwardJourney(timetable.data, first, now, config.cityId, settings.stopId), [settings.demo, settings.stopId, config.cityId, timetable.data, first, now]); const nightAvailable = Boolean(fullAvailable && [-1, 0, 1].every(days => timetable.data?.calendarYears.includes(Number(shiftDate(date, days).slice(0, 4))))); const night = nightWindow(settings, displayDepartures(nightDepartures, alerts, settings.stopId, now, reliableAlerts), now, nightAvailable); @@ -193,8 +212,9 @@ function BoardApplication({ management }: { management?: { state: ManagedState; const held = shouldHoldScene(settings, first, now); 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), [settings, hasAlerts, urgent, night.active]); - const seconds = scene === 'departures' ? settings.mainSeconds : scene === 'info' ? Math.max(settings.infoSeconds, !hasAlerts && settings.infoServiceEnabled ? journeyPages * (reading.calm ? 6 : 4) : 0, Math.min(60, alerts.reduce((sum, alert) => sum + alert.message.length, 0) / 18)) : settings.adSeconds; + 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 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 }); @@ -278,12 +298,12 @@ function BoardApplication({ management }: { management?: { state: ManagedState; {management?.failed &&
Нет связи с управлением · сохранённые настройки
}
{night.active && night.first && } - {!night.active && scene === 'departures' && } - {!night.active && scene === 'info' && } + {!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} + journey={{ departure: first, journey, loading: timetable.loading || loading, paused: paused || settingsOpen, settings }} />} {!night.active && scene === 'ad' &&
РЕКЛАМА · ПРЕДПРОСМОТР

{settings.adTitle}

{settings.adText}

Время для нового.
}
- {!night.active && scene !== 'departures' &&
БЛИЖАЙШИЙ РЕЙС{first ? <>
{first.destination}
: Нет доступных рейсов}
} + {!night.active && scene !== 'departures' &&
БЛИЖАЙШИЙ РЕЙС{first ? <>
{first.destination}{guide.lastTripId === first.id && }
: Нет доступных рейсов}
}
{!fresh && !settings.demo && }{settings.demo ? 'Демонстрация' : held ? 'Приоритет транспорта' : `Обновлено в ${updateLabel}`}{data?.partial ? ' · часть рейсов недоступна' : ''}{offlineSaved === false ? ' · сохранение недоступно' : ''}
diff --git a/src/NextStops.tsx b/src/NextStops.tsx index 5d3986e..3b24c98 100644 --- a/src/NextStops.tsx +++ b/src/NextStops.tsx @@ -1,17 +1,16 @@ import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { NumericText } from './NumericText'; -import { RouteBadge } from './RouteBadge'; import { FittedDestination } from './FittedDestination'; -import { localDate } from './lib/timetable.mjs'; 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 }: { journey: OnwardJourney; focus: number }) { +const JourneyRail = memo(function JourneyRail({ journey, focus, translit }: { journey: OnwardJourney; focus: number; translit: boolean }) { const rail = useRef(null); const [geometry, setGeometry] = useState({ slot: 0, focused: 0, width: 0 }); useLayoutEffect(() => { @@ -38,27 +37,32 @@ const JourneyRail = memo(function JourneyRail({ journey, focus }: { journey: Onw '--journey-name-scale': geometry.focused > 0 ? geometry.slot / geometry.focused : .4 } as CSSProperties}>
; -}, (previous, next) => previous.focus === next.focus && previous.journey.more === next.journey.more +}, (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, now, paused, settings, active, onPages }: { - departure?: Departure; journey: OnwardJourney | null; loading: boolean; now: number; paused: boolean; settings: Settings; +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); @@ -73,14 +77,12 @@ export function NextStops({ departure, journey, loading, now, paused, settings, const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 6000 : 4000); return () => clearInterval(timer); }, [paused, active, pages, reading.calm]); - const tomorrow = departure && localDate(Date.parse(departure.scheduledAt)) > localDate(now); - return <>

Дальше по маршруту

+ return <>

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

{!departure ?

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

: <> -
{!journey ?

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

: !count ?

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

: <>
0 && focus === 0}>
{settings.demo ? 'Демонстрация поездки' : 'После посадки'}{focus + 1}-я остановка
-
+
Время пути по расписаниюПосле посадки · по расписанию{pages > 1 && {focus + 1} / {count}}
} diff --git a/src/PassengerInfo.tsx b/src/PassengerInfo.tsx new file mode 100644 index 0000000..0e68e1c --- /dev/null +++ b/src/PassengerInfo.tsx @@ -0,0 +1,54 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { RouteBadge } from './RouteBadge'; +import { FittedDestination } from './FittedDestination'; +import { NumericText } from './NumericText'; +import { Clock, Moon } from './Icons'; +import { localDate, shiftDate } from './lib/timetable.mjs'; +import { durationParts } from './lib/presentation.mjs'; +import { readability } from './lib/departures'; +import type { PlaceTrip } from './lib/passenger-guide'; +import type { FareTerms, Settings } from './types'; +import type { CSSProperties } from 'react'; + +const format = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', hour: '2-digit', minute: '2-digit' }); +const checked = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'long', year: 'numeric' }); +function TripDay({ at, now }: { at: string; now: number }) { + const day = localDate(Date.parse(at)), today = localDate(now); + return day <= today ? null : {day === shiftDate(today, 1) ? 'завтра' : day === shiftDate(today, 2) ? 'послезавтра' : day.slice(5).split('-').reverse().join('.')}; +} +export function LastTripNotice({ number, compact = false }: { number: string; compact?: boolean }) { + return
Последний №{number} перед ночным перерывом · по расписанию
; +} +export function PlacesCard({ trips, now, paused, active, settings, onPages }: { trips: PlaceTrip[]; now: number; paused: boolean; active: boolean; settings: Settings; onPages: (pages: number) => void }) { + const body = useRef(null); + const [height, setHeight] = useState(0), [page, setPage] = useState(0); + const reading = readability(settings); + const size = Math.max(1, Math.min(3, Math.floor(height / (110 * reading.scale)))); + const pages = Math.max(1, Math.ceil(trips.length / size)); + const visible = trips.slice((page % pages) * size, (page % pages + 1) * size); + useLayoutEffect(() => { + if (!body.current) return; + const observer = new ResizeObserver(entries => setHeight(entries[0].contentRect.height)); observer.observe(body.current); + return () => observer.disconnect(); + }, []); + useEffect(() => { if (active) onPages(pages); }, [active, pages, onPages]); + useEffect(() => { setPage(0); }, [settings.places]); + useEffect(() => { + if (paused || !active || pages < 2) return; + const timer = setInterval(() => setPage(value => value + 1), reading.calm ? 10000 : 7000); + return () => clearInterval(timer); + }, [paused, active, pages, reading.calm]); + return <>

До важных мест

+ {visible.map((trip, index) =>
+
+ {trip.departure ?
+ {trip.minutes !== null && {trip.minutes === 0 ? '<1 мин' : durationParts(trip.minutes).map(part => {part.unit})}}
+ :

Ближайший прямой рейс не найден

} +
+ {trip.arrivalAt &&
Прибытие
} +
)} +
Прямые рейсы · по расписанию{pages > 1 && / {pages}}
; +} +export function FareCard({ fare }: { fare: FareTerms }) { + return <>

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

Для ближайшего маршрута
Данные на {checked.format(Date.parse(`${fare.checkedAt}T12:00:00+03:00`))}
; +} diff --git a/src/PassengerSettings.tsx b/src/PassengerSettings.tsx new file mode 100644 index 0000000..5aab0c8 --- /dev/null +++ b/src/PassengerSettings.tsx @@ -0,0 +1,40 @@ +import { localDate } from './lib/timetable.mjs'; +import { demoPlaces } from './lib/demo'; +import type { Settings, Stop, Route } from './types'; + +export function PassengerSettings({ settings, stops, routes, cityId, update }: { settings: Settings; stops: Stop[]; routes: Route[]; cityId: number; update: (key: K, value: Settings[K]) => void }) { + const targets = settings.demo ? demoPlaces.map(stop => ({ ...stop, direction: 'демо' })) : stops; + return
+

Важные места

До трёх мест. Выберите конкретную остановку назначения и короткое название для пассажиров. Табло найдёт ближайший прямой рейс с учётом его направления и конечной.

+ {settings.places.map((place, index) =>
Место {index + 1} + + +
)} + +

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

Основные тарифы по типу транспорта. Укажите подтверждённую цену с валютой, способы оплаты и дату проверки. Без подходящего тарифа карточка скрыта.

+ {(['bus', 'minibus'] as const).map(type => { + const rule = settings.fareByType.find(rule => rule.type === type && rule.cityId === cityId); + const label = type === 'bus' ? 'автобуса' : 'маршрутки'; + const edit = (key: 'price' | 'payment' | 'checkedAt', value: string) => update('fareByType', settings.fareByType.map(item => item.type === type && item.cityId === cityId ? { ...item, [key]: value } : item)); + return
Тариф {label} + + {rule && <>
+ } +
; + })} +

Исключения для маршрутов

Тариф отдельного маршрута заменяет основной тариф его типа транспорта.

+ {settings.fareRules.map((rule, index) =>
Тариф {index + 1} +
+
+ + +
)} + +
; +} diff --git a/src/SettingsPanel.tsx b/src/SettingsPanel.tsx index 7edda73..f68e124 100644 --- a/src/SettingsPanel.tsx +++ b/src/SettingsPanel.tsx @@ -3,6 +3,7 @@ import { ArrowDown, ArrowUp, Check, Fullscreen, Reset, Close } from './Icons'; import { schemesForTheme } from './lib/presentation.mjs'; import { informationBlocks, informationOptions } from './lib/information'; import { useSettingsNavigation } from './useSettingsNavigation'; +import { PassengerSettings } from './PassengerSettings'; import type { BoardData, Config, Settings, Stop } from './types'; const sections = ['Остановка', 'Оформление', 'Экраны', 'Реклама']; @@ -100,6 +101,10 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls

Во время ночного перерыва длиннее двух часов. Обычный экран вернётся за выбранное время до первого рейса. Работает по полному расписанию; ночью остаются первый рейс, часы и уведомления.

Блоки информации

{informationOptions.map(block => )} + + + + {!informationBlocks(draft).length &&

Экран информации появится только при предупреждениях о движении.

}