feat: add verification layout themes documentation and new components
- Created `verification-layout-themes.md` to document layout and theme verification results. - Introduced `AlertCard` component for displaying alerts with appropriate icons and metadata. - Added `RouteCloud` component to manage and display route information dynamically. - Implemented tests for QR code generation and decoding to ensure functionality across multiple city and platform links.
18
README.md
@@ -1,6 +1,6 @@
|
||||
# RideBus Board
|
||||
|
||||
Табло остановки: React + TypeScript + Vite, сервер Node.js / Express. Фон строго `#000000`, локальный шрифт Manrope, контрастная белая типографика, настраиваемый акцент, иконки `@vkontakte/icons` и официальный SVG-логотип из RideBus PWA. По умолчанию — **Полоцк, Швейная фабрика, stopId=1**. Реальный API подтверждает эту остановку и 22 маршрута.
|
||||
Табло остановки: React + TypeScript + Vite, сервер Node.js / Express. Тёмная тема с фоном `#000000` и светлая с `#FFFFFF`, локальный шрифт Manrope, контрастная типографика, отдельные акцентные палитры, иконки `@vkontakte/icons` и официальный SVG-логотип из RideBus PWA. По умолчанию — **Полоцк, Швейная фабрика, stopId=1**. Реальный API подтверждает эту остановку и 22 маршрута.
|
||||
|
||||
## Запуск
|
||||
|
||||
@@ -18,17 +18,19 @@ npm start
|
||||
|
||||
## Возможности
|
||||
|
||||
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Даже 6 следующих отправлений помещаются на 1280×720 и 390×667. На очень низких экранах список сменяется равными страницами каждые 7 секунд с явным счётчиком; рейсы не обрезаются.
|
||||
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами каждые 7 секунд с явным счётчиком. На 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием.
|
||||
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
|
||||
- Информационный экран: маршруты и статус полного кеша, погода, QR-код и предупреждения. Часы и ближайший рейс остаются видимыми. В вертикальной компоновке показывается одна карточка; при следующих посещениях информационного экрана карточки сменяются. Их также можно выбрать кнопками «Маршруты», «Погода», «RideBus».
|
||||
- Информационный экран: маршруты, погода, QR-код и предупреждения. Часы и ближайший рейс с цветным номером остаются видимыми. В вертикальной и короткой компоновке показывается одна карточка; при следующих посещениях информационного экрана карточки сменяются. Их также можно выбрать кнопками «Маршруты», «Погода», «RideBus».
|
||||
- В широком блоке маршрутов — несколько слегка наклонённых дорожек с плавным встречным движением, номерами, названиями, типом транспорта и заметным временем следующего отправления. Зазоры учитывают наклон на краях. В узком блоке, включая компоновку с тремя карточками, — вертикальные карточки, сменяющиеся каждые 6,5 секунды с прокруткой названий и чисел. Пауза останавливает оба варианта. Технический статус полного кеша пассажирам не показывается.
|
||||
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены с крупными иконками, цветными номерами маршрутов, сроками действия и ненавязчивой графикой. Исходные заголовки, тексты и детали отмен сохраняются.
|
||||
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
|
||||
- Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят.
|
||||
- Настройки остановки, маршрута, числа строк, интервалов и деморежима сохраняются на устройстве. Новая остановка загружает свои данные и свой кеш.
|
||||
- Полный экран, пауза, ручная смена сцен. Клавиши: `S` — настройки; `F` — полный экран; `→` — следующий экран; `пробел` — пауза. `Esc` закрывает настройки.
|
||||
- Кнопки управления и курсор скрываются после 5 секунд без взаимодействия. Движение мыши, касание или клавиатура возвращают кнопки; открытые настройки и работа с клавиатурным фокусом не скрываются. Место панели сохраняется, поэтому список рейсов не скачет.
|
||||
- Изменившиеся цифры часов, времени отправления, минут ожидания и номеров маршрутов прокручиваются вертикально. Неизменившиеся позиции остаются на месте; увеличение идёт вверх, уменьшение — вниз. При `prefers-reduced-motion` движение выключено.
|
||||
- В настройках есть 6 контрастных схем: лайм, янтарь, голубой, мята, лаванда и белый. Акценты и официальный логотип меняются вместе, фон остаётся чёрным. Все цвета имеют контраст с чёрным не меньше 7:1. Схема сохраняется на устройстве.
|
||||
- Длинные номера, включая «16тк», автоматически вписываются с отступами; текст не касается рамок.
|
||||
- Кнопки управления и курсор скрываются после 5 секунд без взаимодействия. Движение мыши, касание или клавиатура возвращают кнопки; открытые настройки и работа с клавиатурным фокусом не скрываются. Высота панели сохраняется, а освободившуюся ширину занимает подпись «Обновлено» справа.
|
||||
- Изменившиеся цифры часов, времени отправления, ожидания и номеров маршрутов прокручиваются вертикально. Названия рейсов при смене страниц плавно сдвигаются тем же движением. Неизменившиеся позиции чисел остаются на месте; увеличение идёт вверх, уменьшение — вниз. При `prefers-reduced-motion` движение выключено.
|
||||
- Для чёрного фона доступны лайм, янтарь, голубой, мята, лаванда и белый; для белого — синий, зелёный, кармин, фиолетовый, бирюзовый и графит. Акцент и логотип меняются вместе. Контраст каждой палитры с её фоном не меньше 7:1. Тема и выбранные цвета сохраняются отдельно.
|
||||
- Длинные номера, включая «16тк», автоматически вписываются с отступами. Центрирование и нижнее выравнивание чисел рассчитаны по границам глифов шрифта. Ожидание от часа отображается как «2 ч 1 мин»; единицы увеличены и выровнены снизу. Завтрашние отправления имеют одну подпись времени с пометкой «завтра».
|
||||
|
||||
## Данные и работа без связи
|
||||
|
||||
@@ -42,7 +44,7 @@ npm start
|
||||
|
||||
При сбое появляется пометка сохранённых данных. Истёкшие рейсы удаляются. Если сохранённые рейсы закончились, табло честно сообщает об этом и продолжает переподключение. Предупреждения фильтруются по городу, маршрутам, срокам действия; сохраняются полный текст и детали отмены. Полный кеш расписания не подтверждает актуальность предупреждений — без свежего ответа API это явно указано.
|
||||
|
||||
QR-код создаётся локально, без стороннего сервиса генерации, и открывает `https://pwa.ridebus.by/?city=1&stop=1` для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной `BOARD_PWA_URL`. QR использует чёрные модули, светлый фон текущей схемы и свободное поле шириной 4 модуля.
|
||||
QR-код создаётся локально, без стороннего сервиса генерации, и открывает `https://pwa.ridebus.by/?city=1&stop=1` для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной `BOARD_PWA_URL`. QR использует скруглённые чёрные модули, белый фон и свободное поле шириной 4 модуля; рамка и внешняя подсветка следуют акценту темы. Декодирование нескольких ссылок проверено автоматически.
|
||||
|
||||
Погода запрашивается независимо через `/api/weather` у [Open-Meteo](https://open-meteo.com/en/docs) по публичным координатам остановки из API. В шапке — температура и состояние; на информационном экране — ощущаемая температура, ветер в м/с и три следующих часа. Процент осадков — максимальная почасовая вероятность в этих трёх часах. Это модельные погодные данные. Обновление раз в 10 минут; сохранённая погода помечается и показывается не дольше часа, затем скрывается. При отсутствии координат или сбое погоды транспорт продолжает работать. Данные сохраняются по городу и остановке; RideBus-ключ не передаётся поставщику погоды. Указаны источник и CC BY 4.0.
|
||||
|
||||
|
||||
3
artifacts/alert-preview.config.mts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
export default defineConfig({ plugins: [react()], base: '/__visual/', build: { outDir: 'dist/__visual', rollupOptions: { input: 'artifacts/alert-preview.html' } } });
|
||||
1
artifacts/alert-preview.html
Normal file
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>RideBus — примеры уведомлений</title></head><body><div id="root"></div><script type="module" src="./alert-preview.tsx"></script></body></html>
|
||||
14
artifacts/alert-preview.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
// Browser verification fixture. These sample notices are never loaded by the board.
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import type { CSSProperties } from 'react';
|
||||
import '@fontsource-variable/manrope';
|
||||
import '../src/styles.css';
|
||||
import { AlertCard } from '../src/AlertCard';
|
||||
import type { Alert } from '../src/types';
|
||||
const examples: Alert[] = [
|
||||
{ id: 'preview-cancellation', type: 'cancellation', routeNumbers: ['2', '12тк'], routeIds: [], title: 'Вечерние отправления отменены', message: 'Сегодня рейсы в 20:15 и 21:05 от остановки «Швейная фабрика» не выполняются. Выберите другое отправление.', startAt: null, endAt: '2026-10-09T20:59:00Z' },
|
||||
{ id: 'preview-detour', type: 'detour', routeNumbers: ['6', '11'], routeIds: [], title: 'Временный объезд', message: 'Транспорт следует через улицу Калинина. Остановка «Площадь Свободы» временно не обслуживается.', startAt: null, endAt: '2026-10-10T15:00:00Z' },
|
||||
{ id: 'preview-info', type: 'info', routeNumbers: [], routeIds: [], title: 'Посадка на соседней площадке', message: 'На время дорожных работ посадка перенесена на 50 метров по направлению движения.', startAt: null, endAt: null },
|
||||
];
|
||||
const light = new URLSearchParams(location.search).get('theme') === 'light';
|
||||
createRoot(document.getElementById('root')!).render(<div className="board" data-theme={light ? 'light' : 'dark'} style={{ '--accent': light ? '#154a9c' : '#d5ff70' } as CSSProperties}><header className="board-header"><div><h1>Уведомления</h1><div className="stop-direction">Примеры оформления · тестовые данные</div></div></header><main className="scene-container"><section className="info-screen"><div className="info-notices">{examples.map(alert => <AlertCard key={alert.id} alert={alert} />)}</div></section></main></div>);
|
||||
BIN
artifacts/board-alerts-dark.jpg
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
artifacts/board-alerts-light.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
artifacts/board-dark-879x593.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
artifacts/board-info-three-cards.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
artifacts/board-light-855x723.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
artifacts/board-next-day-times.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
artifacts/board-routes-lanes-1079x591.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
artifacts/board-weather-1079x591.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
23
artifacts/verification-layout-themes.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Макет, темы и информационные карточки — 9 октября 2026
|
||||
|
||||
Проверено в браузере на production-сборке. Основное табло использовало реальные маршруты и расписание Швейной фабрики. Уведомления проверены отдельно на явно отмеченных тестовых данных: во время проверки активных уведомлений для остановки не было.
|
||||
|
||||
- `npm run build`: TypeScript и Vite проходят. `npm test`: 45/45, включая контраст светлых палитр, границы часового ожидания, миграцию настроек и декодирование округлённого QR для трёх разных ссылок.
|
||||
- 879×593, 6 рейсов: страницы по 3, минимальный промежуток между названием и ожиданием 16 px. Последняя строка заканчивается на y=520, нижняя панель начинается на y=532.
|
||||
- 855×723, 4 рейса, светлая тема: все четыре видны, промежуток 16 px. Последняя строка y≈649, панель y=662. Фон `rgb(255,255,255)`, акцент `#154a9c`. Разница нижних границ чисел и «ч/мин» — 0 px; шрифт единиц в строках — 18 px. Отступ под ближайшим отправлением ≈18,8 px.
|
||||
- Центры числовых меток и рамок совпадают: горизонтальная разница 0, вертикальная <0,01 px. Длинные «12тк», «16тк», «29тк» также проверялись при разработке.
|
||||
- После бездействия ширина управления 0, правая граница статуса совпадает с правой границей панели: x≈829,35 при ширине 855.
|
||||
- 390×667, 6 рейсов: страницы по три, последний рейс заканчивается на y≈602, панель на y=614. Промежуток между колонками 10 px. Отступ ближайшего отправления 18 px.
|
||||
- Времена следующего дня проверены на реальном маршруте «29тк» при 1280×900: `08:38 завтра` и `10:23 завтра` показаны однократно, ожидание отдельно в часах/минутах (`board-next-day-times.jpg`).
|
||||
- 1280×900: три карточки, каждая `clientHeight = scrollHeight = 497`. Маршруты показаны вертикальными карточками, следующий час — 23 px, без подписи «Рейс». При смене названий в DOM есть предыдущий текст и `destination-up-in/out`.
|
||||
- 879×593 и 1079×591: широкая карточка маршрутов имеет две дорожки с направлениями `normal/reverse`. Минимальный вертикальный зазор после наклона ≈25 px; названия и времена не переполняют своих контейнеров. Время в маленьких дорожках 21 px.
|
||||
- Погода на 879×593 и 1079×591: текущие условия и почасовой прогноз занимают отдельные заполненные панели. Карточки не имеют внутреннего переполнения. Иконки будущих часов 36 px на коротком экране, 40 px в трёх карточках, до 64 px в широком высоком блоке.
|
||||
- Погода на 390×667: `clientHeight = scrollHeight = 320`, погодное содержимое помещается. Вертикальные маршруты показываются без горизонтального обрезания.
|
||||
- Уведомления проверены в обеих темах на 1280×900 и на 390×667: ширина текстового содержимого совпадает с его scrollWidth, исходный текст сохранён. Несколько длинных уведомлений допускают вертикальную прокрутку информационной сцены.
|
||||
- Браузерных ошибок и предупреждений при итоговой проверке основного табло и примеров уведомлений нет.
|
||||
|
||||
Примеры уведомлений воспроизводятся командой `npm exec vite build -- --config artifacts/alert-preview.config.mts`, после чего доступны локально по `/__visual/artifacts/alert-preview.html` и `?theme=light`. Эта сборка отдельная: обычный `npm run build` не включает примеры в табло и удаляет временную папку из dist.
|
||||
|
||||
Снимки: `board-dark-879x593.jpg`, `board-light-855x723.jpg`, `board-info-three-cards.jpg`, `board-routes-lanes-1079x591.jpg`, `board-weather-1079x591.jpg`, `board-alerts-dark.jpg`, `board-alerts-light.jpg`.
|
||||
|
||||
Физический дисплей и читаемость с конкретного расстояния не проверялись. Docker runtime ранее проверен импортом из изолированной структуры образа; полный Docker build локально не выполнялся из-за недоступного daemon.
|
||||
8
package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"jsqr": "^1.4.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.3"
|
||||
},
|
||||
@@ -1383,6 +1384,13 @@
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsqr": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"jsqr": "^1.4.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.3"
|
||||
}
|
||||
|
||||
24
src/AlertCard.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ArrowRight, Clock, Close, Info, Reset } from './Icons';
|
||||
import { RouteBadge } from './RouteBadge';
|
||||
import type { Alert } from './types';
|
||||
|
||||
const periodFormat = new Intl.DateTimeFormat('ru', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
export function AlertCard({ alert }: { alert: Alert }) {
|
||||
const cancelled = alert.type === 'cancellation';
|
||||
const detour = alert.type === 'detour';
|
||||
const delayed = alert.type === 'delay';
|
||||
const Icon = cancelled ? Close : detour ? Reset : delayed ? Clock : Info;
|
||||
const label = cancelled ? 'Отмена рейсов' : detour ? 'Изменение маршрута' : delayed ? 'Задержка движения' : alert.type === 'info' ? 'Обратите внимание' : 'Изменения движения';
|
||||
const periodAt = alert.endAt || alert.startAt;
|
||||
const period = periodAt && Number.isFinite(Date.parse(periodAt)) ? periodFormat.format(Date.parse(periodAt)) : null;
|
||||
return <article className="notice" data-kind={cancelled ? 'cancellation' : detour ? 'detour' : delayed ? 'delay' : 'info'}>
|
||||
<div className="notice-graphic" aria-hidden="true"><div className="notice-signal"><Icon size={44} /></div><span className="notice-line" /><span className="notice-endpoint" /></div>
|
||||
<div className="notice-content">
|
||||
<div className="notice-meta"><span className="notice-type">{label}</span>{period && <span className="notice-period">{alert.endAt ? 'До' : 'С'} {period}</span>}</div>
|
||||
{!!alert.routeNumbers.length && <div className="notice-routes" aria-label="Затронутые маршруты">{alert.routeNumbers.map(number => <RouteBadge key={number} number={number} filled />)}</div>}
|
||||
<h3>{alert.title}</h3><p>{alert.message}</p>
|
||||
{!alert.routeNumbers.length && <div className="notice-scope"><ArrowRight size={18} /><span>На этой остановке</span></div>}
|
||||
</div>
|
||||
<span className="notice-watermark" aria-hidden="true"><Icon size={180} /></span>
|
||||
</article>;
|
||||
}
|
||||
55
src/App.tsx
@@ -1,15 +1,17 @@
|
||||
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 { ArrowDown, ArrowRight, ArrowUpRight, BusFront, Clock, Fullscreen, Info, Pause, Play, Refresh, SettingsIcon, Shield, WifiOff } from './Icons';
|
||||
import { NumericText } from './NumericText';
|
||||
import { RouteBadge } from './RouteBadge';
|
||||
import { RouteCloud } from './RouteCloud';
|
||||
import { AlertCard } from './AlertCard';
|
||||
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 { colorScheme, departurePage, durationParts, 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';
|
||||
@@ -24,18 +26,13 @@ const sceneNames: Record<Scene, string> = { departures: 'Ближайшие ре
|
||||
|
||||
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>}
|
||||
const parts = durationParts(minutes);
|
||||
return <div className={`countdown ${large ? 'countdown-large' : ''}`} data-long={minutes >= 60} aria-label={minutes > 0 ? `Через ${parts.map(part => `${part.value} ${part.unit}`).join(' ')} по расписанию` : 'Сейчас по расписанию'}>
|
||||
{minutes > 0 ? <span className="countdown-value">{parts.map(part => <span className="countdown-part" key={part.unit}><NumericText value={part.value} /><small><NumericText value={part.unit} /></small></span>)}</span> : <span className="countdown-now">сейчас</span>}
|
||||
</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 ScheduledTime({ departure, now, className = '' }: { departure: Departure; now: number; className?: string }) {
|
||||
return <time className={className} dateTime={departure.scheduledAt}><NumericText value={departure.scheduledTime} />{localDate(Date.parse(departure.scheduledAt)) > localDate(now) && <span className="departure-day">завтра</span>}</time>;
|
||||
}
|
||||
|
||||
function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fresh, error, paused }: {
|
||||
@@ -45,7 +42,7 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
|
||||
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 capacity = Math.max(2, Math.floor(tableHeight / 62));
|
||||
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(() => {
|
||||
@@ -64,8 +61,8 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
|
||||
<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>
|
||||
<div className="hero-destination"><div className="transport-kind"><BusFront size={19} /> {first.type === 'minibus' ? 'Маршрутное такси' : 'Автобус'}</div><FittedDestination text={first.destination} as="h2" />
|
||||
<div className="hero-details"><span>{first.note || first.routeName}</span><ScheduledTime departure={first} now={now} className="schedule-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>}
|
||||
@@ -73,8 +70,8 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
|
||||
<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>
|
||||
<div className="row-destination"><FittedDestination text={departure.destination} rowHeight={tableHeight / rows} /><p>{departure.note || (departure.type === 'minibus' ? 'Маршрутное такси' : 'Автобус')}</p><ScheduledTime departure={departure} now={now} className="row-time-compact" /></div>
|
||||
<ScheduledTime departure={departure} now={now} className="row-time" />
|
||||
<Countdown departure={departure} now={now} />
|
||||
</article>)}
|
||||
{first && departures.length === 1 && <p className="empty-following">Это последний доступный рейс</p>}
|
||||
@@ -85,9 +82,17 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
|
||||
</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 }) {
|
||||
function InfoScreen({ data, routeDepartures, alerts, fresh, seconds, paused, page, onPage, config, weather, now }: { data: BoardData | null; routeDepartures: Departure[]; alerts: Alert[]; fresh: boolean; seconds: number; paused: boolean; page: number; onPage: (page: number) => void;
|
||||
config: Config; weather: ReturnType<typeof useWeather>; now: number }) {
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const grid = useRef<HTMLDivElement>(null);
|
||||
const [compact, setCompact] = useState(false);
|
||||
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); });
|
||||
observer.observe(grid.current);
|
||||
return () => observer.disconnect();
|
||||
}, [alerts.length]);
|
||||
useEffect(() => {
|
||||
const element = panel.current;
|
||||
if (!element || paused || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
@@ -98,11 +103,11 @@ function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config
|
||||
}, 3500);
|
||||
return () => { clearTimeout(start); clearInterval(timer); };
|
||||
}, [seconds, paused, page]);
|
||||
return <section ref={panel} className="info-screen" aria-label="Информация для пассажиров">
|
||||
return <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} 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>
|
||||
{alerts.length ? <div className="info-notices">{alerts.map(alert => <AlertCard key={alert.id} alert={alert} />)}</div> : <div ref={grid} className="info-grid">
|
||||
<section className="info-card routes-info" data-active={page % 3 === 0}><h3>Маршруты остановки</h3><RouteCloud routes={data?.routes || []} departures={routeDepartures} now={now} paused={paused} wide={compact} /></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>}
|
||||
@@ -197,7 +202,7 @@ export default function App() {
|
||||
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}>
|
||||
return <div className={`board ${scene !== 'departures' ? 'board-secondary' : ''} ${controlsVisible ? '' : 'controls-idle'}`} data-theme={settings.theme} data-row-count={settings.rowCount} style={{ '--accent': colorScheme(settings.theme === 'light' ? settings.lightColorScheme : settings.colorScheme, settings.theme).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>
|
||||
@@ -205,10 +210,10 @@ export default function App() {
|
||||
{!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 === 'info' && <InfoScreen data={data} routeDepartures={fullAvailable ? fullDepartures.filter(item => Date.parse(item.scheduledAt) + 60000 > now) : upcomingDepartures(data, now)} alerts={alerts} fresh={fresh} seconds={seconds} paused={paused || settingsOpen} page={infoPage} onPage={setInfoPage} config={config} 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>}
|
||||
{scene !== 'departures' && <div className="persistent-next"><span className="eyebrow">БЛИЖАЙШИЙ РЕЙС</span>{departures[0] ? <><RouteBadge number={departures[0].routeNumber} filled /><div className="persistent-destination"><strong>{departures[0].destination}</strong><ScheduledTime departure={departures[0]} now={now} /></div><Countdown departure={departures[0]} now={now} /></> : <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>
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
export function FittedDestination({ text, rowHeight }: { text: string; rowHeight: number }) {
|
||||
export function FittedDestination({ text, rowHeight, as: Tag = 'h3' }: { text: string; rowHeight?: number; as?: 'h2' | 'h3' }) {
|
||||
const heading = useRef<HTMLHeadingElement>(null);
|
||||
const [frame, setFrame] = useState({ current: text, previous: text, revision: 0 });
|
||||
if (frame.current !== text) setFrame({ current: text, previous: frame.current, revision: frame.revision + 1 });
|
||||
useLayoutEffect(() => {
|
||||
const element = heading.current, container = element?.parentElement;
|
||||
if (!element || !container) return;
|
||||
const fit = () => {
|
||||
element.style.fontSize = '';
|
||||
const base = parseFloat(getComputedStyle(element).fontSize);
|
||||
const details = container.querySelector('p');
|
||||
const detailsHeight = details && getComputedStyle(details).display !== 'none' ? details.getBoundingClientRect().height + 5 : 0;
|
||||
const available = Math.max(1, container.getBoundingClientRect().height - 6 - detailsHeight);
|
||||
if (element.getBoundingClientRect().height <= available) return;
|
||||
const detailsHeight = Array.from(container.children).filter(child => child !== element).reduce((height, child) => {
|
||||
const css = getComputedStyle(child);
|
||||
return height + (css.display === 'none' || css.position === 'absolute' ? 0 : child.clientHeight + parseFloat(css.marginTop) + parseFloat(css.marginBottom));
|
||||
}, 0);
|
||||
const css = getComputedStyle(container);
|
||||
const available = Math.max(1, container.clientHeight - parseFloat(css.paddingTop) - parseFloat(css.paddingBottom) - detailsHeight);
|
||||
if (element.clientHeight <= available) return;
|
||||
let low = Math.min(16, base), high = base;
|
||||
for (let attempt = 0; attempt < 7; attempt++) {
|
||||
const size = (low + high) / 2;
|
||||
element.style.fontSize = `${size}px`;
|
||||
if (element.getBoundingClientRect().height > available) high = size; else low = size;
|
||||
if (element.clientHeight > available) high = size; else low = size;
|
||||
}
|
||||
element.style.fontSize = `${low}px`;
|
||||
};
|
||||
@@ -25,6 +30,10 @@ export function FittedDestination({ text, rowHeight }: { text: string; rowHeight
|
||||
let active = true;
|
||||
void document.fonts.ready.then(() => { if (active) fit(); });
|
||||
return () => { active = false; observer.disconnect(); };
|
||||
}, [text, rowHeight]);
|
||||
return <h3 ref={heading}>{text}</h3>;
|
||||
}, [text, rowHeight, Tag]);
|
||||
return <Tag ref={heading} className="destination-heading" data-destination={frame.current}>
|
||||
<span className="sr-only">{frame.current}</span>
|
||||
{frame.revision > 0 && <span key={`old-${frame.revision}`} className="destination-old" aria-hidden="true">{frame.previous}</span>}
|
||||
<span key={`current-${frame.revision}`} className="destination-current" data-changing={frame.revision > 0} aria-hidden="true">{frame.current}</span>
|
||||
</Tag>;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { numericFrame } from './lib/presentation.mjs';
|
||||
|
||||
type Props = { value: string | number; numericValue?: number; className?: string };
|
||||
export function NumericText({ value, numericValue, className = '' }: Props) {
|
||||
const text = String(value);
|
||||
const element = useRef<HTMLSpanElement>(null);
|
||||
const [metrics, setMetrics] = useState({ height: .8, offset: -.13 });
|
||||
const symbols = text.replace(/\d/g, '');
|
||||
useLayoutEffect(() => {
|
||||
const node = element.current;
|
||||
if (!node) return;
|
||||
const context = document.createElement('canvas').getContext('2d');
|
||||
if (!context) return;
|
||||
let active = true;
|
||||
function measure() {
|
||||
if (!active || !context) return;
|
||||
const css = getComputedStyle(node!), size = parseFloat(css.fontSize);
|
||||
context.font = `${css.fontStyle} ${css.fontWeight} ${css.fontSize} ${css.fontFamily}`;
|
||||
const bounds = context.measureText(`0123456789${symbols}`);
|
||||
const baseline = (size - bounds.fontBoundingBoxAscent - bounds.fontBoundingBoxDescent) / 2 + bounds.fontBoundingBoxAscent;
|
||||
const next = { height: (bounds.actualBoundingBoxAscent + bounds.actualBoundingBoxDescent) / size + .04,
|
||||
offset: (bounds.actualBoundingBoxAscent - baseline) / size + .02 };
|
||||
if (!Number.isFinite(next.height) || !Number.isFinite(next.offset)) return;
|
||||
setMetrics(previous => Math.abs(previous.height - next.height) < .001 && Math.abs(previous.offset - next.offset) < .001 ? previous : next);
|
||||
}
|
||||
const observer = new ResizeObserver(measure); observer.observe(node); measure();
|
||||
void document.fonts.ready.then(measure);
|
||||
return () => { active = false; observer.disconnect(); };
|
||||
}, [symbols]);
|
||||
const number = numericValue ?? Number(text.replace(/\D/g, ''));
|
||||
const [frame, setFrame] = useState({ current: text, previous: text, number, previousNumber: number, revision: 0 });
|
||||
if (frame.current !== text) setFrame({ current: text, previous: frame.current, number, previousNumber: frame.number, revision: frame.revision + 1 });
|
||||
const transition = numericFrame(frame.previous, frame.current, frame.previousNumber, frame.number);
|
||||
return <span className={`numeric-text ${className}`} data-value={text}>
|
||||
return <span ref={element} className={`numeric-text ${className}`} data-value={text} style={{ '--numeric-height': `${metrics.height}em`, '--numeric-offset': `${metrics.offset}em` } as CSSProperties}>
|
||||
<span className="sr-only">{text}</span>
|
||||
<span className="numeric-track" aria-hidden="true">
|
||||
{transition.glyphs.map(glyph => {
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { ArrowUpRight } from './Icons';
|
||||
import type { Config, Stop } from './types';
|
||||
import { passengerLink } from './lib/passenger.mjs';
|
||||
import { createPassengerQr, passengerLink } from './lib/passenger.mjs';
|
||||
|
||||
export function PassengerQr({ config, stop }: { config: Config; stop: Stop | undefined }) {
|
||||
const url = passengerLink(config.passengerBaseUrl, config.cityId, stop?.id || config.defaultStopId);
|
||||
const qr = useMemo(() => {
|
||||
const code = QRCode.create(url, { errorCorrectionLevel: 'M' }), size = code.modules.size;
|
||||
let path = '';
|
||||
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (code.modules.get(y, x)) path += `M${x + 4} ${y + 4}h1v1h-1z`;
|
||||
return { path, size: size + 8 };
|
||||
}, [url]);
|
||||
return <><h3>Расписание в телефоне</h3><div className="qr-content"><a href={url} target="_blank" rel="noreferrer" aria-label={`Открыть ${stop?.name || config.stopName} в RideBus`}>
|
||||
<svg className="passenger-qr" viewBox={`0 0 ${qr.size} ${qr.size}`} role="img" aria-label="QR-код расписания этой остановки" shapeRendering="crispEdges">
|
||||
<rect width={qr.size} height={qr.size} fill="var(--accent)" /><path d={qr.path} fill="#000000" />
|
||||
</svg></a><p>Наведите камеру на QR-код</p></div><a className="passenger-link" href={url} target="_blank" rel="noreferrer">{new URL(url).host}<ArrowUpRight size={20} /></a></>;
|
||||
const qr = useMemo(() => createPassengerQr(url), [url]);
|
||||
return <><h3>Расписание в телефоне</h3><div className="qr-content"><a className="qr-paper" href={url} target="_blank" rel="noreferrer" aria-label={`Открыть ${stop?.name || config.stopName} в RideBus`}>
|
||||
<svg className="passenger-qr" viewBox={`0 0 ${qr.size} ${qr.size}`} role="img" aria-label="QR-код расписания этой остановки">
|
||||
<rect width={qr.size} height={qr.size} fill="#ffffff" />{qr.rectangles.map((rectangle, index) => <rect key={index} {...rectangle} />)}
|
||||
</svg></a><div className="qr-description"><p>Наведите камеру на QR-код</p><a className="passenger-link" href={url} target="_blank" rel="noreferrer">{new URL(url).host}<ArrowUpRight size={20} /></a></div></div></>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { NumericText } from './NumericText';
|
||||
|
||||
export function RouteBadge({ number, large = false }: { number: string; large?: boolean }) {
|
||||
export function RouteBadge({ number, large = false, filled = false }: { number: string; large?: boolean; filled?: boolean }) {
|
||||
const badge = useRef<HTMLSpanElement>(null);
|
||||
const label = useRef<HTMLSpanElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -20,7 +20,7 @@ export function RouteBadge({ number, large = false }: { number: string; large?:
|
||||
fit(); void document.fonts.ready.then(fit);
|
||||
return () => { mounted = false; observer.disconnect(); };
|
||||
}, [number]);
|
||||
return <span ref={badge} className={`route-badge ${large ? 'route-badge-large' : ''}`}>
|
||||
return <span ref={badge} className={`route-badge ${large ? 'route-badge-large' : ''} ${filled ? 'route-badge-filled' : ''}`}>
|
||||
<span ref={label} className="route-badge-label" style={{ transform: `scale(${scale})` }}><NumericText value={number} /></span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
76
src/RouteCloud.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { Departure, Route } from './types';
|
||||
import { BusFront } from './Icons';
|
||||
import { RouteBadge } from './RouteBadge';
|
||||
import { FittedDestination } from './FittedDestination';
|
||||
import { NumericText } from './NumericText';
|
||||
import { localDate } from './lib/timetable.mjs';
|
||||
|
||||
type Props = { routes: Route[]; departures: Departure[]; now: number; paused: boolean; wide: boolean };
|
||||
export function RouteCloud({ routes, departures, now, paused, wide }: Props) {
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
const [epoch] = useState(() => Date.now() / 1000);
|
||||
const [page, setPage] = useState(() => Math.floor(Date.now() / 6500));
|
||||
const [area, setArea] = useState({ width: 300, height: 300 });
|
||||
const carousel = wide && area.width >= 650;
|
||||
// Opposite inclines need extra clearance at the edges of a wide display.
|
||||
const laneGap = Math.max(36, Math.ceil(area.width * .016) + 24);
|
||||
const edge = Math.ceil(area.width * .008) + 8;
|
||||
const lanes = Math.min(3, Math.max(2, Math.floor((area.height + laneGap) / (155 + laneGap))), Math.max(1, routes.length));
|
||||
const cardHeight = Math.max(1, (area.height - (lanes - 1) * laneGap) / lanes);
|
||||
const cardWidth = cardHeight < 150 ? 420 : Math.max(300, Math.min(380, area.width * .32));
|
||||
const capacity = Math.min(Math.max(1, routes.length), Math.max(1, Math.floor(area.height / 128)));
|
||||
const pages = Math.max(1, Math.ceil(routes.length / capacity));
|
||||
const pageIndex = page % pages;
|
||||
const baseSize = Math.floor(routes.length / pages), extra = routes.length % pages;
|
||||
const pageSize = Math.max(1, baseSize + (pageIndex < extra ? 1 : 0));
|
||||
const pageStart = pageIndex * baseSize + Math.min(pageIndex, extra);
|
||||
const nextByRoute = useMemo(() => {
|
||||
const result = new Map<number, Departure>();
|
||||
for (const departure of departures) if (!result.has(departure.routeId)) result.set(departure.routeId, departure);
|
||||
return result;
|
||||
}, [departures]);
|
||||
useLayoutEffect(() => {
|
||||
if (!element.current) return;
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
setArea(previous => Math.abs(previous.width - width) < .5 && Math.abs(previous.height - height) < .5 ? previous : { width, height });
|
||||
});
|
||||
observer.observe(element.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (carousel || paused || pages < 2) return;
|
||||
const timer = setInterval(() => setPage(value => value + 1), 6500);
|
||||
return () => clearInterval(timer);
|
||||
}, [carousel, paused, pages]);
|
||||
const tile = (route: Route, key: string) => {
|
||||
const next = nextByRoute.get(route.id);
|
||||
return <article className="route-tile" key={key}>
|
||||
<span className="route-watermark"><BusFront size={100} /></span><RouteBadge number={route.number} filled />
|
||||
<div className="route-card-name"><FittedDestination text={route.name} rowHeight={carousel ? cardHeight : area.height / pageSize} />
|
||||
<div className="route-card-next">{next ? <><time dateTime={next.scheduledAt}><NumericText value={next.scheduledTime} /></time>{localDate(Date.parse(next.scheduledAt)) > localDate(now) && <span>завтра</span>}</> : <span>Расписание уточняется</span>}
|
||||
<span className="route-kind">{route.type === 'minibus' ? 'Маршрутка' : 'Автобус'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>;
|
||||
};
|
||||
return <div ref={element} className="route-cloud" data-carousel={carousel} data-horizontal={cardHeight < 150} style={{ '--lane-height': `${cardHeight}px`, '--route-card-width': `${cardWidth}px`, '--lane-gap': `${laneGap}px`, '--lane-edge': `${edge}px`, '--route-rows': pageSize } as CSSProperties}>
|
||||
{carousel ? <>
|
||||
<ul className="sr-only" aria-label="Все маршруты остановки">{routes.map(route => <li key={route.id}>№{route.number} · {route.name}</li>)}</ul>
|
||||
{Array.from({ length: lanes }, (_, lane) => {
|
||||
const items = routes.filter((_, index) => index % lanes === lane);
|
||||
const group = items.length ? Array.from({ length: Math.max(1, Math.ceil(4 / items.length)) }, () => items).flat() : [];
|
||||
const duration = Math.max(12, group.length * (cardWidth + 16) / 24);
|
||||
return <div className="route-lane" key={lane} aria-hidden="true" style={{ '--lane-angle': `${lane % 2 ? .9 : -.9}deg` } as CSSProperties}>
|
||||
<div className="route-track" style={{ animationDuration: `${duration}s`, animationDelay: `-${epoch % duration}s`, animationDirection: lane % 2 ? 'reverse' : 'normal', animationPlayState: paused ? 'paused' : 'running' }}>
|
||||
{[0, 1].map(copy => <div className="route-group" key={copy}>{group.map((route, index) => tile(route, `${route.id}:${index}`))}</div>)}
|
||||
</div>
|
||||
</div>;
|
||||
})}</> : <><div className="route-list">{routes.slice(pageStart, pageStart + pageSize).map((route, index) => tile(route, `position-${index}`))}</div>
|
||||
{pages > 1 && <div className="route-list-pages" aria-label={`Страница маршрутов ${pageIndex + 1} из ${pages}`}><span>Через эту остановку</span><NumericText value={`${pageIndex + 1}/${pages}`} /></div>}
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowUpRight, Check, Fullscreen, Reset, Close } from './Icons';
|
||||
import { colorSchemes } from './lib/presentation.mjs';
|
||||
import { schemesForTheme } from './lib/presentation.mjs';
|
||||
import type { BoardData, Config, Settings, Stop } from './types';
|
||||
|
||||
export function SettingsPanel({ settings, config, data, onSave, onClose, onFullscreen }: {
|
||||
@@ -35,10 +35,11 @@ export function SettingsPanel({ settings, config, data, onSave, onClose, onFulls
|
||||
</select></label>
|
||||
<label className="switch-row"><span>Демонстрационные данные<small>Для проверки оформления без API</small></span><input type="checkbox" checked={draft.demo} onChange={event => update('demo', event.target.checked)} /></label>
|
||||
</section>
|
||||
<section><h3>Цветовая схема</h3>
|
||||
<fieldset className="theme-picker"><legend className="sr-only">Контрастный цвет на чёрном фоне</legend>
|
||||
{colorSchemes.map(scheme => <label className="theme-option" key={scheme.id}><input type="radio" name="color-scheme" value={scheme.id} checked={draft.colorScheme === scheme.id} onChange={() => update('colorScheme', scheme.id)} /><span className="theme-swatch" style={{ background: scheme.color }} /><span>{scheme.name}</span></label>)}
|
||||
</fieldset><p className="form-hint">Фон остаётся чёрным. Меняются акценты, номера маршрутов и логотип.</p>
|
||||
<section><h3>Оформление</h3>
|
||||
<fieldset className="theme-picker appearance-picker"><legend className="sr-only">Тема табло</legend>{([{ id: 'dark', name: 'Тёмная' }, { id: 'light', name: 'Светлая' }] as const).map(theme => <label className="theme-option" key={theme.id}><input type="radio" name="appearance" checked={draft.theme === theme.id} onChange={() => update('theme', theme.id)} /><span>{theme.name}</span></label>)}</fieldset>
|
||||
<h4>Цветовая схема</h4><fieldset className="theme-picker"><legend className="sr-only">Акцент для {draft.theme === 'light' ? 'белого' : 'чёрного'} фона</legend>
|
||||
{schemesForTheme(draft.theme).map(scheme => <label className="theme-option" key={scheme.id}><input type="radio" name="color-scheme" value={scheme.id} checked={(draft.theme === 'light' ? draft.lightColorScheme : draft.colorScheme) === scheme.id} onChange={() => update(draft.theme === 'light' ? 'lightColorScheme' : 'colorScheme', scheme.id)} /><span className="theme-swatch" style={{ background: scheme.color }} /><span>{scheme.name}</span></label>)}
|
||||
</fieldset><p className="form-hint">У каждой темы своя контрастная палитра. Выбранные цвета сохраняются отдельно.</p>
|
||||
</section>
|
||||
<section><h3>Экраны и переходы</h3>
|
||||
<div className="form-grid"><label>Главный экран, сек<input type="number" min="5" max="120" required value={draft.mainSeconds} onChange={event => update('mainSeconds', Number(event.target.value))} /></label>
|
||||
|
||||
@@ -22,9 +22,9 @@ export function WeatherCard({ data, now, loading, failed, demo }: { data: Weathe
|
||||
if (!available || !data) return <><div className="card-icon"><Cloud /></div><h3>Погода</h3><p>{demo ? 'Погода доступна с реальными данными остановки.' : loading ? 'Получаем прогноз…' : 'Прогноз сейчас недоступен.'}</p><div className="info-caption">{data ? 'Сохранённый прогноз устарел' : 'Погода обновится при подключении'}</div></>;
|
||||
const hours = data.hourly.filter(hour => Date.parse(hour.at) > now).slice(0, 3);
|
||||
const probability = hours.length ? Math.max(...hours.map(hour => hour.precipitationProbability)) : null;
|
||||
return <><h3>Погода</h3><div className="weather-current"><WeatherIcon code={data.code} isDay={data.isDay} size={60} /><strong><NumericText value={degrees(data.temperature)} /></strong></div>
|
||||
<p className="weather-description">{weatherCondition(data.code, data.isDay).label}</p><div className="weather-details"><span>Ощущается как <NumericText value={degrees(data.apparentTemperature)} /></span><span><Wind size={20} /><NumericText value={Math.round(data.windSpeed)} /> м/с</span></div>
|
||||
{!!hours.length && <div className="weather-hours">{hours.map(hour => <div key={hour.at}><time dateTime={hour.at}><NumericText value={hourFormat.format(Date.parse(hour.at))} /></time><WeatherIcon code={hour.code} size={24} /><strong><NumericText value={degrees(hour.temperature)} /></strong></div>)}</div>}
|
||||
{probability !== null && <div className="precipitation-chance">Осадки в ближайшие 3 часа: <NumericText value={probability} />%</div>}
|
||||
return <><h3>Погода</h3><div className="weather-body"><div className="weather-now"><span className="weather-now-label">СЕЙЧАС</span><div className="weather-current"><WeatherIcon code={data.code} isDay={data.isDay} size={60} /><strong><NumericText value={degrees(data.temperature)} /></strong></div>
|
||||
<p className="weather-description">{weatherCondition(data.code, data.isDay).label}</p><div className="weather-details"><span>Ощущается как <NumericText value={degrees(data.apparentTemperature)} /></span><span><Wind size={20} /><NumericText value={Math.round(data.windSpeed)} /> м/с</span></div></div><div className="weather-future">
|
||||
{!!hours.length && <div className="weather-hours">{hours.map(hour => <div key={hour.at}><time dateTime={hour.at}><NumericText value={hourFormat.format(Date.parse(hour.at))} /></time><WeatherIcon code={hour.code} size={40} /><strong><NumericText value={degrees(hour.temperature)} /></strong><small className="weather-hour-rain"><NumericText value={hour.precipitationProbability} />%<span>осадки</span></small></div>)}</div>}
|
||||
{probability !== null && <div className="precipitation-chance">Осадки в ближайшие 3 часа: <NumericText value={probability} />%</div>}</div></div>
|
||||
<div className="weather-attribution"><span>{failed ? 'Сохранено' : 'Обновлено'} в {hourFormat.format(Date.parse(data.updatedAt))}</span><a href="https://open-meteo.com/" target="_blank" rel="noreferrer">Open-Meteo · CC BY 4.0</a></div></>;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
.st0{clip-path:url(#SVGID_2_);}
|
||||
.st1{fill:var(--accent, #d5ff70);}
|
||||
.st2{display:none;fill:none;stroke:#FFFFFF;stroke-width:1.9625;}
|
||||
.st3{fill:#000000;}
|
||||
.st3{fill:var(--accent-ink, #000000);}
|
||||
</style>
|
||||
<g>
|
||||
<defs>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
@@ -1,3 +1,17 @@
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export function createPassengerQr(url) {
|
||||
const code = QRCode.create(url, { errorCorrectionLevel: 'M' }), size = code.modules.size;
|
||||
const rectangles = [];
|
||||
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (code.modules.get(y, x)
|
||||
&& !(x < 7 && y < 7 || x >= size - 7 && y < 7 || x < 7 && y >= size - 7)) rectangles.push({ x: x + 4, y: y + 4, width: 1, height: 1, rx: .18, fill: '#101010' });
|
||||
for (const { x, y } of [{ x: 4, y: 4 }, { x: size - 3, y: 4 }, { x: 4, y: size - 3 }]) {
|
||||
rectangles.push({ x, y, width: 7, height: 7, rx: 1.25, fill: '#101010' },
|
||||
{ x: x + 1, y: y + 1, width: 5, height: 5, rx: .7, fill: '#ffffff' },
|
||||
{ x: x + 2, y: y + 2, width: 3, height: 3, rx: .55, fill: '#101010' });
|
||||
}
|
||||
return { size: size + 8, rectangles };
|
||||
}
|
||||
export function passengerLink(baseUrl, cityId, stopId) {
|
||||
let url;
|
||||
try { url = new URL(baseUrl || 'https://pwa.ridebus.by/'); if (!['https:', 'http:'].includes(url.protocol)) throw new Error(); }
|
||||
|
||||
@@ -6,7 +6,21 @@ export const colorSchemes = [
|
||||
{ id: 'lavender', name: 'Лаванда', color: '#d3bdff' },
|
||||
{ id: 'white', name: 'Белый', color: '#ffffff' },
|
||||
];
|
||||
export function colorScheme(id) { return colorSchemes.find(scheme => scheme.id === id) || colorSchemes[0]; }
|
||||
export const lightColorSchemes = [
|
||||
{ id: 'blue', name: 'Синий', color: '#154a9c' },
|
||||
{ id: 'forest', name: 'Зелёный', color: '#195c35' },
|
||||
{ id: 'crimson', name: 'Кармин', color: '#a31d39' },
|
||||
{ id: 'violet', name: 'Фиолетовый', color: '#5d329e' },
|
||||
{ id: 'teal', name: 'Бирюзовый', color: '#005d64' },
|
||||
{ id: 'graphite', name: 'Графит', color: '#303744' },
|
||||
];
|
||||
export const schemesForTheme = theme => theme === 'light' ? lightColorSchemes : colorSchemes;
|
||||
export function colorScheme(id, theme = 'dark') { const schemes = schemesForTheme(theme); return schemes.find(scheme => scheme.id === id) || schemes[0]; }
|
||||
export function durationParts(minutes) {
|
||||
const value = Math.max(0, Math.ceil(minutes));
|
||||
if (value < 60) return [{ value, unit: 'мин' }];
|
||||
return [{ value: Math.floor(value / 60), unit: 'ч' }, ...(value % 60 ? [{ value: value % 60, unit: 'мин' }] : [])];
|
||||
}
|
||||
|
||||
export function numericFrame(previous, current, previousNumber, currentNumber) {
|
||||
const width = Math.max(previous.length, current.length);
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Timetable, Weather } from '../types';
|
||||
|
||||
const SETTINGS_KEY = 'ridebus-board-settings-v1';
|
||||
export const defaultSettings: Settings = { stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true,
|
||||
rowCount: 4, routeFilter: '', colorScheme: 'lime', adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
|
||||
rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
|
||||
export function readSettings(): Settings {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
|
||||
@@ -15,7 +15,8 @@ export function readSettings(): Settings {
|
||||
return { ...defaultSettings, stopId: bound(saved.stopId, 1, 1, 1000000), demo: saved.demo === true,
|
||||
mainSeconds: bound(saved.mainSeconds, 25, 5, 120), infoSeconds: bound(saved.infoSeconds, 8, 5, 60), rowCount: bound(saved.rowCount, 4, 2, 6),
|
||||
holdNear: saved.holdNear !== false, routeFilter: typeof saved.routeFilter === 'string' && /^\d*$/.test(saved.routeFilter) ? saved.routeFilter : '',
|
||||
colorScheme: colorScheme(saved.colorScheme).id, adsEnabled: saved.adsEnabled === true, adSeconds: bound(saved.adSeconds, 7, 5, 30),
|
||||
theme: saved.theme === 'light' ? 'light' : 'dark', colorScheme: colorScheme(saved.colorScheme).id, lightColorScheme: colorScheme(saved.lightColorScheme, 'light').id,
|
||||
adsEnabled: saved.adsEnabled === true, adSeconds: bound(saved.adSeconds, 7, 5, 30),
|
||||
adTitle: typeof saved.adTitle === 'string' ? saved.adTitle.slice(0, 80) : defaultSettings.adTitle,
|
||||
adText: typeof saved.adText === 'string' ? saved.adText.slice(0, 200) : defaultSettings.adText };
|
||||
} catch { return defaultSettings; }
|
||||
|
||||
592
src/styles.css
@@ -1,6 +1,6 @@
|
||||
:root { font-family: 'Manrope Variable', Arial, sans-serif; color: #f5f5f5; background: #000000; font-synthesis: none; --accent: #d5ff70; --muted: #b7b7b7; --line: #303030; --warning: #ffd479; color-scheme: dark; }
|
||||
:root { font-family: 'Manrope Variable', Arial, sans-serif; background: #000; font-synthesis: none; color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { margin: 0; width: 100%; height: 100%; background: #000000; }
|
||||
html, body, #root { margin: 0; width: 100%; height: 100%; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; color: inherit; }
|
||||
button, a { touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
|
||||
@@ -9,15 +9,15 @@ svg { display: block; flex-shrink: 0; }
|
||||
.vkuiIcon { display: inline-flex; flex-shrink: 0; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
|
||||
/* The main track alone can shrink. The header and footer always own their space. */
|
||||
.board { --hero-height: clamp(100px, 18vh, 230px); height: 100dvh; width: 100%; display: flex; flex-direction: column; overflow: hidden; padding: clamp(14px, 2vh, 28px) clamp(18px, 3vw, 64px) 0; background: #000000; }
|
||||
.board[data-row-count='5'], .board[data-row-count='6'] { --hero-height: clamp(82px, 14vh, 175px); }
|
||||
.board { --background: #000000; --foreground: #f5f5f5; --muted: #b7b7b7; --secondary: #c3c3c3; --line: #303030; --outline: #595959; --surface: #0a0a0a; --input: #141414; --warning: #ffd479; --accent-ink: #000; --hero-height: clamp(110px, 18vh, 230px); --hero-gap: clamp(18px, 2.6vh, 32px); --control-size: 40px; --control-gap: 6px; color-scheme: dark; color: var(--foreground); background: var(--background); height: 100dvh; width: 100%; display: flex; flex-direction: column; overflow: hidden; padding: clamp(14px, 2vh, 28px) clamp(18px, 3vw, 64px) 0; }
|
||||
.board[data-theme='light'] { --background: #FFFFFF; --foreground: #171b23; --muted: #535b68; --secondary: #535b68; --line: #d7dbe2; --outline: #939ba8; --surface: #fff; --input: #f2f4f7; --warning: #8a4300; --accent-ink: #fff; color-scheme: light; }
|
||||
.board[data-row-count='5'], .board[data-row-count='6'] { --hero-height: clamp(100px, 15vh, 180px); }
|
||||
.board-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; flex: 0 0 auto; border-bottom: 1px solid var(--line); padding-bottom: clamp(12px, 1.8vh, 24px); view-transition-name: board-header; }
|
||||
.stop-heading { min-width: 0; }
|
||||
h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1.08; margin: 0; font-weight: 650; overflow-wrap: anywhere; }
|
||||
.stop-meta { display: flex; align-items: center; gap: 10px 24px; flex-wrap: wrap; }
|
||||
.stop-direction { color: var(--muted); display: flex; align-items: center; gap: 10px; font-size: clamp(18px, 1.6vw, 32px); margin-top: 10px; line-height: 1.2; }
|
||||
.stop-direction svg { color: var(--accent); }
|
||||
.stop-meta { display: flex; align-items: center; gap: 12px 24px; flex-wrap: wrap; }
|
||||
.weather-summary { display: flex; align-items: center; gap: 9px; margin-top: 10px; color: var(--muted); font-size: clamp(16px, 1.3vw, 26px); line-height: 1.2; }
|
||||
.weather-summary svg, .weather-summary strong { color: var(--accent); }
|
||||
.weather-summary strong { font-size: 1.25em; font-weight: 600; }
|
||||
@@ -26,166 +26,185 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.brand { grid-row: 1 / 3; display: flex; align-items: center; gap: 12px; font-size: clamp(24px, 2.3vw, 44px); font-weight: 800; letter-spacing: -.07em; white-space: nowrap; }
|
||||
.official-mark { display: block; width: clamp(40px, 3.5vw, 70px); height: auto; flex-shrink: 0; }
|
||||
.official-mark svg { width: 100%; height: auto; }
|
||||
.brand-dot { color: var(--accent); }
|
||||
.clock { display: flex; gap: 10px; align-items: baseline; font-size: clamp(38px, 4.6vw, 90px); font-weight: 550; line-height: 1; font-variant-numeric: tabular-nums; letter-spacing: -.035em; white-space: nowrap; }
|
||||
.clock small { font-size: .42em; color: #bdbdbd; font-weight: 500; }
|
||||
.today { grid-column: 2; color: #bdbdbd; text-align: right; font-size: clamp(13px, 1.15vw, 22px); margin-top: 4px; white-space: nowrap; }
|
||||
.clock { display: flex; gap: 10px; align-items: flex-end; font-size: clamp(38px, 4.6vw, 90px); font-weight: 550; line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.clock small { font-size: .42em; color: var(--secondary); font-weight: 500; }
|
||||
.today { grid-column: 2; color: var(--secondary); text-align: right; font-size: clamp(13px, 1.15vw, 22px); margin-top: 8px; white-space: nowrap; }
|
||||
.scene-container { flex: 1 1 0; min-height: 0; min-width: 0; overflow: hidden; }
|
||||
.scene { width: 100%; height: 100%; min-height: 0; animation: scene-in 400ms ease-out both; }
|
||||
.departure-layout { display: flex; flex-direction: column; height: 100%; min-height: 0; padding: clamp(10px, 1.6vh, 24px) 0; }
|
||||
.departure-content { flex: 1 1 0; min-height: 0; min-width: 0; display: grid; grid-template-rows: auto var(--hero-height) auto minmax(0, 1fr); }
|
||||
.departure-layout { display: flex; flex-direction: column; height: 100%; min-height: 0; padding: clamp(12px, 1.8vh, 24px) 0; }
|
||||
.departure-content { flex: 1 1 0; min-height: 0; min-width: 0; display: grid; grid-template-rows: auto calc(var(--hero-height) + var(--hero-gap)) auto minmax(0, 1fr); }
|
||||
.section-heading, .next-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; min-width: 0; }
|
||||
.eyebrow { font-size: clamp(12px, 1.05vw, 21px); font-weight: 650; letter-spacing: .12em; color: var(--muted); }
|
||||
.source-label { display: flex; align-items: center; gap: 8px; color: #c3c3c3; font-size: clamp(13px, 1.15vw, 23px); white-space: nowrap; }
|
||||
.source-label { display: flex; align-items: center; gap: 8px; color: var(--secondary); font-size: clamp(13px, 1.15vw, 23px); white-space: nowrap; }
|
||||
.source-label svg { width: 1.2em; height: 1.2em; }
|
||||
.hero-departure { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr); align-items: center; gap: clamp(18px, 2.4vw, 50px); min-height: 0; padding: 6px 0; }
|
||||
.route-badge { display: inline-flex; justify-content: center; align-items: center; flex-shrink: 0; border: 1.5px solid #595959; border-radius: 12px; width: clamp(64px, 8vw, 150px); height: min(82px, calc(var(--row-height, 96px) * .76)); padding: 0 clamp(10px, 1vw, 18px); font-size: min(50px, calc(var(--row-height, 96px) * .48)); font-weight: 650; line-height: 1.12; font-variant-numeric: tabular-nums; letter-spacing: -.025em; overflow: hidden; }
|
||||
.route-badge-label { display: inline-block; flex-shrink: 0; white-space: nowrap; transform-origin: center; }
|
||||
.route-badge-large { width: calc(var(--hero-height) * 1.05); height: calc(var(--hero-height) - 12px); border: none; background: var(--accent); color: #000; border-radius: clamp(14px, 1.5vw, 28px); padding: 0 clamp(16px, 1.4vw, 28px); font-size: min(130px, calc(var(--hero-height) * .68)); }
|
||||
.hero-destination { min-width: 0; }
|
||||
.transport-kind { display: flex; align-items: center; gap: 9px; color: var(--accent); font-size: min(30px, calc(var(--hero-height) * .17)); line-height: 1.2; margin-bottom: 5px; }
|
||||
.hero-destination h2 { font-size: min(5vw, calc(var(--hero-height) * .34)); line-height: 1.08; letter-spacing: -.035em; font-weight: 650; margin: 0; overflow-wrap: anywhere; }
|
||||
.hero-details { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 13px; font-size: min(27px, calc(var(--hero-height) * .12)); color: #bdbdbd; margin-top: 4px; line-height: 1.25; }
|
||||
.schedule-time { border: 1px solid #505050; border-radius: 6px; padding: 1px 6px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.countdown { display: flex; align-items: baseline; justify-content: flex-end; gap: 9px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.countdown > span { font-size: min(68px, calc(var(--row-height, 92px) * .64)); font-weight: 550; line-height: 1.1; letter-spacing: -.035em; }
|
||||
.countdown small { color: #c3c3c3; font-size: min(27px, calc(var(--row-height, 92px) * .29)); }
|
||||
.countdown-large > span { color: var(--accent); font-size: min(154px, calc(var(--hero-height) * .86)); }
|
||||
.countdown-large small { font-size: clamp(19px, 2vw, 38px); }
|
||||
.countdown .countdown-now { font-size: min(40px, calc(var(--row-height, 92px) * .4)); }
|
||||
.countdown-large .countdown-now { font-size: min(72px, calc(var(--hero-height) * .44)); }
|
||||
.countdown-future { flex-direction: column; align-items: flex-end; gap: 1px; }
|
||||
.countdown-future > span { font-size: min(40px, calc(var(--row-height, 92px) * .4)); }
|
||||
.countdown-large.countdown-future > span { font-size: min(70px, calc(var(--hero-height) * .45)); }
|
||||
.next-heading { border-top: 1px solid var(--line); padding: clamp(8px, 1.2vh, 16px) 0; }
|
||||
.next-heading svg { color: #bdbdbd; }
|
||||
.hero-departure { height: var(--hero-height); display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr); align-items: center; gap: clamp(18px, 2.4vw, 50px); min-height: 0; padding: 10px 0 0; }
|
||||
.route-badge { display: inline-flex; justify-content: center; align-items: center; flex-shrink: 0; border: 1.5px solid var(--outline); border-radius: 14px; width: clamp(64px, 8vw, 150px); height: min(82px, calc(var(--row-height, 96px) * .74)); padding: 0 clamp(10px, 1vw, 18px); font-size: min(50px, calc(var(--row-height, 96px) * .48)); font-weight: 650; line-height: 1; font-variant-numeric: tabular-nums; overflow: hidden; }
|
||||
.route-badge-label { display: flex; align-items: center; flex-shrink: 0; white-space: nowrap; transform-origin: center; }
|
||||
.route-badge-large { width: calc(var(--hero-height) * 1.05); height: calc(var(--hero-height) - 10px); border: 0; background: var(--accent); color: var(--accent-ink); border-radius: calc(var(--hero-height) * .27); padding: 0 clamp(16px, 1.4vw, 28px); font-size: min(130px, calc(var(--hero-height) * .68)); }
|
||||
.route-badge-filled { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
||||
.hero-destination { min-width: 0; height: 100%; display: flex; flex-direction: column; justify-content: center; }
|
||||
.transport-kind { display: flex; align-items: center; gap: 9px; color: var(--accent); font-size: min(30px, calc(var(--hero-height) * .16)); line-height: 1.2; margin-bottom: 7px; }
|
||||
.hero-destination h2 { flex-shrink: 0; font-size: min(4.7vw, calc(var(--hero-height) * .34)); line-height: 1.08; letter-spacing: -.035em; font-weight: 650; margin: 0; overflow-wrap: anywhere; }
|
||||
.hero-details { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: min(27px, calc(var(--hero-height) * .12)); color: var(--secondary); margin-top: 8px; line-height: 1.25; }
|
||||
.hero-details > span { min-width: 0; }
|
||||
.schedule-time { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--outline); border-radius: 6px; padding: 4px 6px; white-space: nowrap; }
|
||||
.departure-day { font-size: .6em; color: var(--muted); white-space: nowrap; }
|
||||
.countdown { display: flex; align-items: flex-end; justify-content: flex-end; min-width: 0; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.countdown-value { display: inline-flex; align-items: flex-end; gap: .22em; font-size: min(68px, calc(var(--row-height, 92px) * .64)); font-weight: 550; line-height: 1; }
|
||||
.countdown-part { display: inline-flex; align-items: flex-end; gap: .12em; }
|
||||
.countdown-part small { color: var(--secondary); font-size: max(18px, .42em); font-weight: 500; display: flex; align-items: flex-end; line-height: 1; }
|
||||
.countdown[data-long='true'] .countdown-value { font-size: min(54px, calc(var(--row-height, 92px) * .48)); }
|
||||
.countdown-large .countdown-value { color: var(--accent); font-size: min(150px, calc(var(--hero-height) * .9)); }
|
||||
.countdown-large[data-long='true'] .countdown-value { font-size: min(105px, calc(var(--hero-height) * .66)); }
|
||||
.countdown-now { font-size: min(40px, calc(var(--row-height, 92px) * .4)); font-weight: 550; }
|
||||
.countdown-large .countdown-now { color: var(--accent); font-size: min(72px, calc(var(--hero-height) * .44)); }
|
||||
.next-heading { border-top: 1px solid var(--line); padding: clamp(10px, 1.4vh, 18px) 0; }
|
||||
.next-heading svg { color: var(--muted); }
|
||||
.page-indicator { color: var(--accent); font-size: 15px; }
|
||||
.departure-table { min-height: 0; display: grid; grid-template-rows: repeat(var(--rows), minmax(0, 1fr)); overflow: hidden; }
|
||||
.departure-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto minmax(105px, .17fr); align-items: center; gap: clamp(15px, 2.4vw, 48px); min-height: 0; border-bottom: 1px solid #303030; }
|
||||
.departure-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: clamp(16px, 2.2vw, 44px); min-height: 0; padding: 5px 0; border-bottom: 1px solid var(--line); }
|
||||
.departure-row:last-child { border-bottom: 0; }
|
||||
.row-destination { min-width: 0; min-height: 0; height: 100%; padding: 3px 0; display: flex; flex-direction: column; justify-content: center; }
|
||||
.row-destination h3 { flex-shrink: 0; font-size: min(54px, 3.5vw, calc(var(--row-height) * .43)); font-weight: 600; line-height: 1.1; letter-spacing: -.025em; margin: 0; overflow-wrap: anywhere; }
|
||||
.row-destination p { margin: 5px 0 0; font-size: min(24px, 1.35vw, calc(var(--row-height) * .2)); line-height: 1.2; color: #bcbcbc; }
|
||||
.row-destination { min-width: 0; min-height: 0; height: 100%; display: flex; flex-direction: column; justify-content: center; }
|
||||
.row-destination h3 { flex-shrink: 0; font-size: min(54px, 3.5vw, calc(var(--row-height) * .4)); font-weight: 600; line-height: 1.1; letter-spacing: -.025em; margin: 0; overflow-wrap: anywhere; }
|
||||
.row-destination p { margin: 6px 0 0; font-size: min(24px, 1.35vw, calc(var(--row-height) * .2)); line-height: 1.2; color: var(--secondary); }
|
||||
.departure-table[data-rows='5'] .row-destination p, .departure-table[data-rows='6'] .row-destination p { display: none; }
|
||||
.row-time { color: #c3c3c3; font-size: min(46px, calc(var(--row-height) * .43)); font-variant-numeric: tabular-nums; letter-spacing: -.025em; }
|
||||
.transport-notice { display: flex; align-items: center; gap: 10px; color: var(--warning); padding: 10px 0 0; font-size: clamp(15px, 1.4vw, 26px); flex: 0 0 auto; }
|
||||
.row-time { display: flex; flex-direction: column; align-items: flex-end; gap: 7px; color: var(--secondary); font-size: min(42px, calc(var(--row-height) * .36)); font-variant-numeric: tabular-nums; }
|
||||
.row-time-compact { display: none; }
|
||||
.transport-notice { display: flex; align-items: center; gap: 10px; color: var(--warning); padding-top: 10px; font-size: clamp(15px, 1.4vw, 26px); flex: 0 0 auto; }
|
||||
.empty-departures { display: flex; flex-direction: column; justify-content: center; min-height: 0; }
|
||||
.empty-departures > svg { color: var(--accent); }
|
||||
.empty-departures h2 { font-size: clamp(24px, 3vw, 54px); font-weight: 550; margin: 10px 0; letter-spacing: -.04em; }
|
||||
.empty-departures p, .empty-following { color: #bdbdbd; font-size: clamp(14px, 1.5vw, 28px); margin: 0; line-height: 1.4; }
|
||||
.empty-departures p, .empty-following { color: var(--secondary); font-size: clamp(14px, 1.5vw, 28px); margin: 0; line-height: 1.4; }
|
||||
.quiet-placeholder { display: none; }
|
||||
.offline-banner { display: flex; align-items: center; gap: 9px; color: var(--warning); font-size: clamp(13px, 1.2vw, 22px); padding-top: 9px; flex: 0 0 auto; }
|
||||
.offline-banner button { color: inherit; border: 0; background: none; display: flex; padding: 3px; margin-left: auto; }
|
||||
|
||||
/* Secondary content occupies the same bounded screen area. */
|
||||
.info-screen { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 18px 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #555 #000; }
|
||||
.info-intro { flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 18px; }
|
||||
.info-intro h2 { margin: 0; font-size: clamp(28px, 3.5vw, 62px); letter-spacing: -.045em; font-weight: 550; }
|
||||
.information-tabs { display: none; gap: 10px; }
|
||||
.information-tabs button { font-size: 14px; border: 1px solid #484848; background: transparent; padding: 8px 12px; border-radius: 8px; color: #bdbdbd; }
|
||||
.information-tabs button[aria-current='page'] { color: #000; border-color: var(--accent); background: var(--accent); }
|
||||
.info-grid { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 24px; }
|
||||
.info-card { min-height: 0; border: 1px solid #3b3b3b; border-radius: 18px; padding: clamp(18px, 2vw, 38px); display: flex; flex-direction: column; overflow-y: auto; container-type: size; }
|
||||
.card-icon { color: var(--accent); margin-bottom: 18px; }
|
||||
/* Cards switch to one panel when the actual available area is too small. */
|
||||
.info-screen { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 16px 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--outline) var(--background); }
|
||||
.info-intro { flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
|
||||
.info-intro h2 { margin: 0; font-size: clamp(28px, 3vw, 56px); letter-spacing: -.045em; font-weight: 550; }
|
||||
.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(3, minmax(0, 1fr)); gap: 24px; }
|
||||
.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; }
|
||||
.card-icon { color: var(--accent); margin-bottom: 16px; }
|
||||
.card-icon svg { width: 32px; height: 32px; }
|
||||
.info-card h3 { font-size: clamp(23px, 2vw, 38px); line-height: 1.2; font-weight: 600; letter-spacing: -.03em; margin: 0 0 16px; }
|
||||
.info-card p { color: #c8c8c8; font-size: clamp(18px, 1.75vw, 32px); line-height: 1.5; margin: 0 0 20px; }
|
||||
.info-caption { display: flex; align-items: center; gap: 9px; font-size: clamp(14px, 1.3vw, 23px); color: #bdbdbd; margin-top: auto; line-height: 1.4; }
|
||||
.info-caption svg { color: var(--accent); }
|
||||
.route-cloud { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 16px; }
|
||||
.route-cloud > span { display: inline-flex; align-items: center; justify-content: center; min-width: 48px; height: 42px; padding: 0 10px; border: 1px solid #555; border-radius: 8px; font-size: clamp(20px, 1.5vw, 28px); font-weight: 550; }
|
||||
.routes-info p { margin: auto 0 0; font-size: clamp(16px, 1.4vw, 25px); }
|
||||
.ridebus-link { display: flex; gap: 10px; align-items: center; color: var(--accent); font-size: clamp(28px, 3vw, 54px); letter-spacing: -.045em; text-decoration: none; margin: auto 0 20px; }
|
||||
.cache-caption { display: flex; gap: 9px; align-items: center; color: var(--accent); font-size: clamp(15px, 1.25vw, 24px); line-height: 1.35; margin-top: auto; }
|
||||
.routes-info .schedule-explanation { margin: 12px 0 0; font-size: clamp(14px, 1.1vw, 21px); line-height: 1.35; }
|
||||
.weather-current { display: flex; align-items: center; gap: 20px; color: var(--accent); }
|
||||
.weather-current strong { font-size: clamp(64px, 5.5vw, 106px); line-height: 1.15; font-weight: 550; }
|
||||
.weather-info .weather-description { margin: 6px 0 12px; font-size: clamp(20px, 1.7vw, 32px); line-height: 1.2; }
|
||||
.weather-details { display: flex; flex-wrap: wrap; gap: 6px 18px; color: #c3c3c3; font-size: clamp(15px, 1.15vw, 23px); }
|
||||
.info-card p { color: var(--secondary); font-size: clamp(18px, 1.75vw, 32px); line-height: 1.4; margin: 0 0 16px; }
|
||||
.info-caption { display: flex; align-items: center; gap: 9px; font-size: clamp(14px, 1.3vw, 23px); color: var(--secondary); margin-top: auto; line-height: 1.4; }
|
||||
.route-cloud { flex: 1 1 0; min-height: 0; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.route-cloud[data-carousel='true'] { justify-content: center; gap: var(--lane-gap); padding: var(--lane-edge) 0; background-image: radial-gradient(color-mix(in srgb, var(--accent) 18%, transparent) 1px, transparent 1px); background-size: 26px 26px; mask-image: linear-gradient(to right, transparent, #000 4%, #000 96%, transparent); }
|
||||
.route-lane { height: var(--lane-height, 180px); flex-shrink: 0; width: 100%; transform: rotate(var(--lane-angle, -.9deg)); }
|
||||
.route-track { display: flex; width: max-content; animation: routes-scroll 60s linear infinite; will-change: transform; }
|
||||
.route-group { display: flex; gap: 16px; padding-right: 16px; }
|
||||
.route-tile { position: relative; overflow: hidden; width: var(--route-card-width, 300px); height: var(--lane-height, 180px); padding: 14px 18px; display: flex; flex-direction: column; justify-content: center; flex-shrink: 0; border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 22px; background: color-mix(in srgb, var(--accent) 8%, var(--background)); }
|
||||
.route-cloud .route-badge { --row-height: 100px; width: 105px; height: clamp(38px, calc(var(--lane-height, 180px) * .30), 72px); font-size: clamp(30px, calc(var(--lane-height, 180px) * .28), 54px); border-radius: 16px; padding: 0 10px; margin-bottom: 12px; }
|
||||
.route-card-name { width: 100%; flex: 1 1 0; min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: center; }
|
||||
.route-tile .destination-heading { width: 100%; font-size: clamp(17px, calc(var(--lane-height, 180px) * .14), 26px); line-height: 1.12; margin: 0; font-weight: 550; letter-spacing: -.025em; flex-shrink: 0; overflow-wrap: anywhere; }
|
||||
.route-card-next { display: flex; align-items: center; flex-wrap: wrap; gap: 5px 8px; margin-top: 10px; font-size: 12px; color: var(--secondary); flex-shrink: 0; }
|
||||
.route-card-next time { display: inline-flex; color: var(--accent); font-size: clamp(21px, calc(var(--lane-height, 180px) * .13), 28px); font-weight: 650; }
|
||||
.route-kind { font-size: 11px; color: var(--muted); margin-left: auto; }
|
||||
.route-cloud[data-carousel='true'][data-horizontal='true'] .route-tile { flex-direction: row; align-items: center; gap: 14px; padding: 10px 14px; }
|
||||
.route-cloud[data-carousel='true'][data-horizontal='true'] .route-badge { width: 72px; height: min(56px, calc(var(--lane-height) - 20px)); font-size: 36px; padding: 0 8px; margin-bottom: 0; }
|
||||
.route-cloud[data-carousel='true'][data-horizontal='true'] .route-card-name { height: 100%; }
|
||||
.route-cloud[data-carousel='true'][data-horizontal='true'] .route-kind { display: none; }
|
||||
.route-cloud[data-carousel='true'][data-horizontal='true'] .route-card-next { margin-top: 6px; }
|
||||
.route-list { flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: repeat(var(--route-rows), minmax(0, 1fr)); gap: 12px; }
|
||||
.route-list .route-tile { width: 100%; height: 100%; flex-direction: row; align-items: center; gap: 14px; padding: 14px; border-radius: 18px; }
|
||||
.route-list .route-badge { width: 70px; height: 62px; font-size: 35px; border-radius: 16px; margin: 0; }
|
||||
.route-list .route-card-name { height: 100%; }
|
||||
.route-list .destination-heading { font-size: clamp(18px, 5cqw, 25px); }
|
||||
.route-list .route-card-next time { font-size: clamp(23px, 6cqw, 30px); }
|
||||
.route-list-pages { display: flex; justify-content: space-between; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; padding-top: 12px; }
|
||||
.route-list-pages > .numeric-text { color: var(--accent); }
|
||||
.route-watermark { position: absolute; right: -18px; bottom: -22px; color: var(--accent); opacity: .06; transform: rotate(-12deg); }
|
||||
@keyframes routes-scroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
|
||||
.weather-body { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-height: 0; }
|
||||
.weather-current { display: flex; align-items: center; gap: 18px; color: var(--accent); }
|
||||
.weather-current strong { font-size: clamp(60px, 5.4vw, 100px); font-weight: 550; line-height: 1; }
|
||||
.weather-info .weather-description { margin: 12px 0; font-size: clamp(20px, 1.7vw, 32px); line-height: 1.2; }
|
||||
.weather-details { display: flex; flex-wrap: wrap; gap: 8px 18px; color: var(--secondary); font-size: clamp(15px, 1.15vw, 23px); }
|
||||
.weather-details > span { display: flex; align-items: center; gap: 6px; }
|
||||
.weather-hours { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; border-top: 1px solid var(--line); margin-top: 18px; padding-top: 14px; }
|
||||
.weather-hours > div { display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.weather-hours time { color: #bdbdbd; font-size: clamp(14px, 1vw, 22px); }
|
||||
.weather-hours svg { color: var(--accent); }
|
||||
.weather-hours { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; border-top: 1px solid var(--line); padding-top: 14px; }
|
||||
.weather-hours > div { display: flex; flex-direction: column; align-items: center; gap: 10px; }
|
||||
.weather-hours time { color: var(--secondary); font-size: clamp(14px, 1vw, 22px); }
|
||||
.weather-hours svg { color: var(--accent); width: 40px !important; height: 40px !important; }
|
||||
.weather-hours strong { font-size: clamp(22px, 1.8vw, 34px); font-weight: 550; }
|
||||
.precipitation-chance { font-size: clamp(14px, 1vw, 21px); color: #c3c3c3; margin-top: 14px; line-height: 1.35; }
|
||||
.weather-attribution { margin-top: auto; padding-top: 14px; display: flex; flex-wrap: wrap; gap: 5px 12px; font-size: clamp(12px, .8vw, 17px); color: #aaa; }
|
||||
.precipitation-chance { font-size: clamp(14px, 1vw, 21px); color: var(--secondary); margin-top: 12px; line-height: 1.35; }
|
||||
.weather-attribution { margin-top: auto; padding-top: 16px; display: flex; flex-wrap: wrap; gap: 5px 12px; font-size: clamp(12px, .8vw, 17px); color: var(--muted); }
|
||||
.weather-attribution a { color: inherit; text-underline-offset: 3px; }
|
||||
.weather-now-label, .weather-hour-rain { display: none; }
|
||||
.qr-info { align-items: center; text-align: center; }
|
||||
.qr-content { margin: auto 0; min-height: 0; display: flex; align-items: center; flex-direction: column; }
|
||||
.passenger-qr { width: clamp(130px, 23vh, 248px); max-width: 100%; aspect-ratio: 1; }
|
||||
.qr-content p { font-size: clamp(16px, 1.25vw, 24px); line-height: 1.3; margin: 14px 0; }
|
||||
.passenger-link { display: flex; align-items: center; gap: 9px; color: var(--accent); text-decoration: none; font-size: clamp(17px, 1.4vw, 27px); margin-top: auto; }
|
||||
@media (max-height: 850px) {
|
||||
.info-screen { padding: 12px 0; }
|
||||
.info-intro { margin-bottom: 12px; }
|
||||
.info-intro h2 { font-size: 32px; }
|
||||
.info-status { padding-top: 12px; font-size: 16px; }
|
||||
.persistent-next .countdown > span { font-size: 40px; }
|
||||
.routes-info .card-icon { display: none; }
|
||||
.routes-info .route-cloud { gap: 7px; }
|
||||
.routes-info .route-cloud > span { height: 32px; min-width: 40px; padding: 0 7px; font-size: 18px; }
|
||||
.info-card { padding: 20px; }
|
||||
}
|
||||
@container (max-height: 370px) {
|
||||
.info-card h3 { margin-bottom: 12px; font-size: 24px; }
|
||||
.weather-current strong { font-size: 56px; }
|
||||
.weather-current svg { width: 44px; height: 44px; }
|
||||
.weather-info .weather-description { font-size: 20px; margin: 4px 0 10px; }
|
||||
.weather-details { font-size: 14px; }
|
||||
.weather-hours { margin-top: 12px; padding-top: 10px; }
|
||||
.weather-hours > div { display: grid; grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 6px; }
|
||||
.weather-hours time { grid-column: 1 / -1; text-align: center; font-size: 14px; }
|
||||
.weather-hours strong { font-size: 21px; }
|
||||
.precipitation-chance { font-size: 14px; margin-top: 10px; }
|
||||
.weather-attribution { padding-top: 10px; font-size: 11px; }
|
||||
.routes-info .schedule-explanation { display: none; }
|
||||
}
|
||||
.info-status { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: #c3c3c3; font-size: clamp(14px, 1.4vw, 25px); padding-top: 16px; line-height: 1.3; }
|
||||
.qr-info h3 { width: 100%; }
|
||||
.qr-content { flex: 1 1 0; min-height: 0; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; }
|
||||
.qr-paper { display: block; flex-shrink: 0; padding: 8px; border: 1px solid var(--outline); border-radius: 24px; background: #fff; box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); }
|
||||
.passenger-qr { width: clamp(108px, calc(100cqh - 175px), 248px); max-width: 100%; aspect-ratio: 1; }
|
||||
.qr-description { display: flex; flex-direction: column; align-items: center; gap: 12px; }
|
||||
.qr-description p { font-size: clamp(16px, 1.25vw, 24px); line-height: 1.3; margin: 0; }
|
||||
.passenger-link { display: flex; align-items: center; gap: 9px; color: var(--accent); text-decoration: none; font-size: clamp(17px, 1.4vw, 27px); }
|
||||
.info-screen[data-compact='true'] .info-grid { grid-template-columns: 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; }
|
||||
.info-status { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: var(--secondary); font-size: clamp(14px, 1.2vw, 22px); padding-top: 14px; line-height: 1.3; }
|
||||
.info-status svg { color: var(--accent); }
|
||||
.info-notices { display: grid; gap: 18px; }
|
||||
.notice { padding: 24px; border: 1px solid #586447; border-radius: 16px; }
|
||||
.notice-warning { border-color: #76603a; }
|
||||
.notice-title { color: var(--accent); display: flex; align-items: center; gap: 10px; font-size: 20px; }
|
||||
.notice-warning .notice-title, .notice-warning .notice-routes { color: var(--warning); }
|
||||
.notice-routes { margin-top: 15px; font-size: 24px; }
|
||||
.notice h3 { font-size: clamp(24px, 2.4vw, 42px); font-weight: 600; line-height: 1.2; margin: 15px 0; }
|
||||
.notice p { font-size: clamp(18px, 1.8vw, 32px); line-height: 1.5; color: #d1d1d1; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.persistent-next { display: flex; align-items: center; gap: 20px; border-top: 1px solid var(--line); padding: 12px 0; flex: 0 0 auto; --row-height: 72px; }
|
||||
.persistent-next .route-badge { width: 78px; height: 48px; font-size: 31px; padding: 0 10px; border-radius: 9px; }
|
||||
.persistent-next strong { min-width: 0; font-weight: 600; font-size: clamp(21px, 2.4vw, 42px); line-height: 1.15; }
|
||||
.notice { --notice-color: var(--accent); position: relative; isolation: isolate; display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 24px; padding: clamp(20px, 2vw, 36px); border: 1px solid color-mix(in srgb, var(--notice-color) 35%, var(--line)); border-radius: 26px; background: color-mix(in srgb, var(--notice-color) 5%, var(--background)); overflow: hidden; }
|
||||
.notice[data-kind='cancellation'], .notice[data-kind='delay'] { --notice-color: var(--warning); }
|
||||
.notice::after { content: ''; position: absolute; z-index: -1; width: 36%; top: -50%; right: -5%; height: 200%; transform: rotate(16deg); background: repeating-linear-gradient(0deg, transparent 0 24px, color-mix(in srgb, var(--notice-color) 5%, transparent) 24px 25px); pointer-events: none; }
|
||||
.notice-graphic { display: flex; flex-direction: column; align-items: center; color: var(--notice-color); padding: 2px 0; }
|
||||
.notice-signal { display: grid; place-items: center; width: 76px; height: 76px; border-radius: 23px; background: color-mix(in srgb, var(--notice-color) 16%, var(--background)); border: 1px solid color-mix(in srgb, var(--notice-color) 45%, var(--line)); }
|
||||
.notice-line { width: 2px; flex: 1 1 0; min-height: 30px; margin: 10px 0; background: linear-gradient(var(--notice-color), color-mix(in srgb, var(--notice-color) 20%, transparent)); }
|
||||
.notice[data-kind='cancellation'] .notice-line { background: repeating-linear-gradient(to bottom, var(--notice-color) 0 5px, transparent 5px 11px); }
|
||||
.notice-endpoint { width: 12px; height: 12px; border: 2px solid var(--notice-color); border-radius: 50%; }
|
||||
.notice-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px 18px; margin-bottom: 16px; }
|
||||
.notice-type { color: var(--notice-color); font-size: clamp(14px, 1.2vw, 22px); font-weight: 650; }
|
||||
.notice-period { color: var(--secondary); font-size: clamp(13px, 1vw, 18px); }
|
||||
.notice-routes { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
|
||||
.notice-routes .route-badge { width: auto; min-width: 54px; height: 42px; padding: 0 14px; font-size: 26px; border-radius: 12px; background: var(--notice-color); border-color: var(--notice-color); }
|
||||
.notice h3 { font-size: clamp(25px, 2.6vw, 46px); font-weight: 600; line-height: 1.12; margin: 0 0 14px; letter-spacing: -.035em; overflow-wrap: anywhere; }
|
||||
.notice p { font-size: clamp(18px, 1.65vw, 30px); line-height: 1.45; color: var(--secondary); margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.notice-scope { display: flex; align-items: center; gap: 8px; margin-top: 18px; font-size: 14px; color: var(--notice-color); }
|
||||
.notice-watermark { position: absolute; right: -30px; bottom: -50px; color: var(--notice-color); opacity: .04; transform: rotate(-15deg); z-index: -1; }
|
||||
.persistent-next { display: flex; align-items: center; gap: 18px; border-top: 1px solid var(--line); padding: 14px 0; flex: 0 0 auto; --row-height: 72px; }
|
||||
.persistent-next .route-badge { width: 78px; height: 50px; font-size: 31px; padding: 0 10px; border-radius: 14px; }
|
||||
.persistent-destination { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
.persistent-destination strong { font-weight: 600; font-size: clamp(21px, 2.2vw, 40px); line-height: 1.1; }
|
||||
.persistent-destination time { display: flex; align-items: center; gap: 8px; font-size: clamp(14px, 1.2vw, 23px); color: var(--secondary); }
|
||||
.persistent-next .countdown { margin-left: auto; }
|
||||
.persistent-next .countdown-future { flex-direction: row; align-items: baseline; }
|
||||
.persistent-next .countdown-value { font-size: 40px; }
|
||||
.persistent-next .countdown[data-long='true'] .countdown-value { font-size: 34px; }
|
||||
.ad-screen { padding: 4vh 0; height: 100%; overflow-y: auto; }
|
||||
.ad-screen h2 { font-size: clamp(44px, 7vw, 135px); color: var(--accent); line-height: 1.06; letter-spacing: -.055em; font-weight: 550; margin: 4vh 0 3vh; overflow-wrap: anywhere; }
|
||||
.ad-screen p { font-size: clamp(22px, 2.7vw, 50px); color: #ccc; line-height: 1.4; margin: 0; }
|
||||
.ad-signature { display: flex; align-items: center; gap: 15px; color: #bbb; margin-top: 4vh; font-size: 22px; }
|
||||
.ad-screen p { font-size: clamp(22px, 2.7vw, 50px); color: var(--secondary); line-height: 1.4; margin: 0; }
|
||||
.ad-signature { display: flex; align-items: center; gap: 15px; color: var(--muted); margin-top: 4vh; font-size: 22px; }
|
||||
|
||||
/* Footer is a normal flex item, never an overlay. */
|
||||
.board-footer { flex: 0 0 auto; position: relative; border-top: 1px solid var(--line); view-transition-name: board-footer; }
|
||||
.cycle-progress { position: absolute; top: -1px; width: 100%; height: 1px; }
|
||||
.cycle-progress > span { display: block; width: 100%; height: 1px; background: var(--accent); transform-origin: left; transition: transform 1s linear; }
|
||||
.footer-main { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 58px; padding: 8px 0; }
|
||||
.scene-navigation { display: flex; align-items: center; gap: 22px; }
|
||||
.scene-navigation button { border: 0; padding: 4px 0; background: none; color: #bdbdbd; display: flex; align-items: center; gap: 9px; font-size: clamp(13px, 1.05vw, 20px); white-space: nowrap; }
|
||||
.scene-navigation button.active { color: #fff; }
|
||||
.scene-index { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid #555; border-radius: 50%; font-size: 13px; }
|
||||
.active .scene-index { color: #000; background: var(--accent); border-color: var(--accent); }
|
||||
.footer-status { display: flex; align-items: center; gap: 8px; color: #aaa; margin-left: auto; font-size: clamp(12px, .95vw, 18px); }
|
||||
.board-controls { display: flex; gap: 6px; flex-shrink: 0; opacity: 1; transition: opacity 250ms ease, transform 250ms ease; }
|
||||
.footer-main { display: flex; align-items: center; min-height: 60px; padding: 8px 0; }
|
||||
.scene-navigation { display: flex; align-items: center; gap: 22px; flex-shrink: 0; }
|
||||
.scene-navigation button { border: 0; padding: 4px 0; background: none; color: var(--secondary); display: flex; align-items: center; gap: 9px; font-size: clamp(13px, 1.05vw, 20px); white-space: nowrap; }
|
||||
.scene-navigation button.active { color: var(--foreground); }
|
||||
.scene-index { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid var(--outline); border-radius: 50%; font-size: 13px; }
|
||||
.active .scene-index { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); }
|
||||
.footer-status { display: flex; align-items: center; justify-content: flex-end; gap: 8px; color: var(--muted); margin-left: auto; padding-left: 16px; text-align: right; font-size: clamp(12px, .95vw, 18px); min-width: 0; }
|
||||
.board-controls { display: flex; gap: var(--control-gap); width: calc(4 * var(--control-size) + 3 * var(--control-gap)); margin-left: 14px; flex-shrink: 0; min-width: 0; overflow: hidden; opacity: 1; transition: width 280ms ease, margin-left 280ms ease, opacity 200ms ease; }
|
||||
.controls-idle, .controls-idle * { cursor: none; }
|
||||
.controls-idle .board-controls { opacity: 0; transform: translateY(3px); pointer-events: none; }
|
||||
.icon-button { display: inline-flex; justify-content: center; align-items: center; width: 40px; height: 40px; color: #c0c0c0; border: 1px solid transparent; border-radius: 9px; background: none; transition: color 180ms, border-color 180ms; }
|
||||
.icon-button:hover { color: #fff; border-color: #555; }
|
||||
.toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); max-width: 90vw; z-index: 20; padding: 15px 22px; background: #171717; border: 1px solid #555; border-radius: 12px; font-size: 15px; }
|
||||
.controls-idle .board-controls { width: 0; margin-left: 0; opacity: 0; pointer-events: none; }
|
||||
.icon-button { display: inline-flex; justify-content: center; align-items: center; flex-shrink: 0; width: var(--control-size, 40px); height: var(--control-size, 40px); color: var(--secondary); border: 1px solid transparent; border-radius: 9px; background: none; }
|
||||
.icon-button:hover { color: var(--foreground); border-color: var(--outline); }
|
||||
.toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); max-width: 90vw; z-index: 20; padding: 15px 22px; background: var(--surface); color: var(--foreground); border: 1px solid var(--outline); border-radius: 12px; font-size: 15px; }
|
||||
|
||||
/* Rolling digits keep tabular widths; only changed positions animate. */
|
||||
.numeric-text { display: inline-block; vertical-align: middle; line-height: 1.12; letter-spacing: 0; }
|
||||
.numeric-track { display: inline-flex; align-items: center; height: 1.12em; white-space: nowrap; }
|
||||
.numeric-glyph { position: relative; display: inline-block; width: .62em; height: 1.12em; overflow: hidden; line-height: 1.12; text-align: center; flex-shrink: 0; }
|
||||
.numeric-symbol { display: inline-block; line-height: 1.12; }
|
||||
.numeric-current { display: block; height: 1.12em; }
|
||||
.numeric-old { position: absolute; left: 0; top: 0; width: 100%; height: 1.12em; }
|
||||
/* Measured ink bounds remove font leading from digits and share one baseline with units. */
|
||||
.numeric-text { display: inline-flex; vertical-align: baseline; height: var(--numeric-height, .8em); line-height: 1; letter-spacing: 0; }
|
||||
.numeric-track { display: inline-flex; align-items: flex-start; height: 100%; overflow: hidden; white-space: nowrap; }
|
||||
.numeric-glyph { position: relative; display: inline-block; width: .62em; height: 100%; overflow: hidden; text-align: center; flex-shrink: 0; }
|
||||
.numeric-symbol { display: block; position: relative; top: var(--numeric-offset, -.13em); line-height: 1em; }
|
||||
.numeric-current { display: block; position: relative; top: var(--numeric-offset, -.13em); height: 1em; line-height: 1em; }
|
||||
.numeric-old { position: absolute; left: 0; top: var(--numeric-offset, -.13em); width: 100%; height: 1em; line-height: 1em; }
|
||||
.numeric-glyph[data-rolling='true'] .numeric-current { animation: digit-up-in 380ms cubic-bezier(.22,.8,.22,1) both; }
|
||||
.numeric-glyph[data-rolling='true'] .numeric-old { animation: digit-up-out 380ms cubic-bezier(.22,.8,.22,1) both; }
|
||||
.numeric-glyph[data-direction='down'][data-rolling='true'] .numeric-current { animation-name: digit-down-in; }
|
||||
@@ -197,174 +216,226 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
@keyframes digit-down-out { from { transform: translateY(0); } to { transform: translateY(105%); } }
|
||||
@keyframes digit-collapse { from { width: .62em; } to { width: 0; } }
|
||||
@keyframes scene-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
.destination-heading { position: relative; overflow: hidden; }
|
||||
.destination-current { display: block; }
|
||||
.destination-current[data-changing='true'] { animation: destination-up-in 380ms cubic-bezier(.22,.8,.22,1) both; }
|
||||
.destination-old { position: absolute; inset: 0 0 auto; display: block; animation: destination-up-out 380ms cubic-bezier(.22,.8,.22,1) both; }
|
||||
@keyframes destination-up-in { from { transform: translateY(105%); } to { transform: translateY(0); } }
|
||||
@keyframes destination-up-out { from { transform: translateY(0); } to { transform: translateY(-105%); } }
|
||||
|
||||
/* Operator settings may scroll independently of the board. */
|
||||
.settings-dialog { width: min(600px, calc(100vw - 28px)); max-height: calc(100dvh - 32px); padding: 0; color: #eee; background: #0a0a0a; border: 1px solid #444; border-radius: 20px; overflow: auto; box-shadow: 0 25px 90px #000; }
|
||||
.settings-dialog::backdrop { background: #000c; backdrop-filter: blur(8px); }
|
||||
.settings-dialog { width: min(600px, calc(100vw - 28px)); max-height: calc(100dvh - 32px); padding: 0; color: var(--foreground); background: var(--surface); border: 1px solid var(--outline); border-radius: 20px; overflow: auto; box-shadow: 0 25px 90px #0006; }
|
||||
.settings-dialog::backdrop { background: #0009; backdrop-filter: blur(8px); }
|
||||
.settings-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 25px 28px; border-bottom: 1px solid var(--line); }
|
||||
.settings-heading h2 { margin: 8px 0 0; font-size: 27px; font-weight: 600; letter-spacing: -.04em; }
|
||||
.settings-heading .eyebrow { font-size: 11px; }
|
||||
.settings-body { padding: 0 28px 20px; }
|
||||
.settings-body section { border-bottom: 1px solid #303030; padding: 21px 0 14px; }
|
||||
.settings-body section { border-bottom: 1px solid var(--line); padding: 21px 0 14px; }
|
||||
.settings-body h3 { margin: 0 0 17px; font-size: 18px; font-weight: 650; }
|
||||
.settings-note { color: #b5b5b5; font-size: 13px; margin-bottom: 18px; }
|
||||
.settings-body label { display: flex; flex-direction: column; gap: 9px; font-size: 14px; color: #c8c8c8; margin: 17px 0; }
|
||||
.settings-body select, .settings-body input:not([type='checkbox']):not([type='radio']), .settings-body textarea { width: 100%; color: #f5f5f5; background: #141414; border: 1px solid #555; border-radius: 8px; padding: 12px; font-size: 15px; }
|
||||
.settings-body h4 { margin: 20px 0 14px; font-size: 14px; font-weight: 600; }
|
||||
.settings-note { color: var(--secondary); font-size: 13px; margin-bottom: 18px; }
|
||||
.settings-body label { display: flex; flex-direction: column; gap: 9px; font-size: 14px; color: var(--secondary); margin: 17px 0; }
|
||||
.settings-body select, .settings-body input:not([type='checkbox']):not([type='radio']), .settings-body textarea { width: 100%; color: var(--foreground); background: var(--input); border: 1px solid var(--outline); border-radius: 8px; padding: 12px; font-size: 15px; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.settings-body .switch-row { flex-direction: row; align-items: center; justify-content: space-between; gap: 15px; color: #eee; }
|
||||
.switch-row small { display: block; color: #b3b3b3; margin-top: 5px; font-size: 12px; line-height: 1.5; }
|
||||
.switch-row input { appearance: none; position: relative; flex-shrink: 0; margin: 0; width: 40px; height: 24px; background: #303030; border: 1px solid #606060; border-radius: 20px; transition: background 180ms; cursor: pointer; }
|
||||
.switch-row input::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #ccc; transition: transform 180ms; }
|
||||
.settings-body .switch-row { flex-direction: row; align-items: center; justify-content: space-between; gap: 15px; color: var(--foreground); }
|
||||
.switch-row small { display: block; color: var(--muted); margin-top: 5px; font-size: 12px; line-height: 1.5; }
|
||||
.switch-row input { appearance: none; position: relative; flex-shrink: 0; margin: 0; width: 40px; height: 24px; background: var(--line); border: 1px solid var(--outline); border-radius: 20px; transition: background 180ms; cursor: pointer; }
|
||||
.switch-row input::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: var(--secondary); transition: transform 180ms; }
|
||||
.switch-row input:checked { background: var(--accent); border-color: var(--accent); }
|
||||
.switch-row input:checked::after { transform: translateX(16px); background: #000; }
|
||||
.switch-row input:checked::after { transform: translateX(16px); background: var(--accent-ink); }
|
||||
.theme-picker { border: 0; padding: 0; margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.settings-body .theme-option { flex-direction: row; align-items: center; gap: 10px; padding: 12px; margin: 0; border: 1px solid #414141; border-radius: 9px; cursor: pointer; }
|
||||
.theme-option:has(input:checked) { border-color: var(--accent); color: #fff; }
|
||||
.settings-body .theme-option { flex-direction: row; align-items: center; gap: 10px; padding: 12px; margin: 0; border: 1px solid var(--outline); border-radius: 9px; cursor: pointer; }
|
||||
.theme-option:has(input:checked) { border-color: var(--accent); color: var(--foreground); }
|
||||
.theme-option input { accent-color: var(--accent); margin: 0; width: 16px; height: 16px; }
|
||||
.theme-swatch { width: 22px; height: 22px; border-radius: 50%; flex-shrink: 0; }
|
||||
.tiny-tag { font-size: 9px; color: var(--accent); border: 1px solid #555; border-radius: 4px; padding: 3px 5px; margin-left: 7px; letter-spacing: .07em; }
|
||||
.text-button { display: inline-flex; align-items: center; gap: 9px; border: 0; background: none; color: #c3c3c3; font-size: 14px; padding: 10px 0; }
|
||||
.tiny-tag { font-size: 9px; color: var(--accent); border: 1px solid var(--outline); border-radius: 4px; padding: 3px 5px; margin-left: 7px; letter-spacing: .07em; }
|
||||
.text-button { display: inline-flex; align-items: center; gap: 9px; border: 0; background: none; color: var(--secondary); font-size: 14px; padding: 10px 0; }
|
||||
.settings-body > .text-button { margin-top: 15px; }
|
||||
.form-hint { font-size: 12px; color: #b3b3b3; line-height: 1.6; }
|
||||
.form-hint { font-size: 12px; color: var(--muted); line-height: 1.6; }
|
||||
.settings-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 20px 28px; border-top: 1px solid var(--line); }
|
||||
.primary-button { display: inline-flex; align-items: center; gap: 8px; background: var(--accent); color: #000; border: 0; border-radius: 9px; padding: 13px 22px; font-size: 15px; font-weight: 650; }
|
||||
.primary-button { display: inline-flex; align-items: center; gap: 8px; background: var(--accent); color: var(--accent-ink); border: 0; border-radius: 9px; padding: 13px 22px; font-size: 15px; font-weight: 650; }
|
||||
|
||||
@media (max-width: 1050px) { .brand-word { display: none; } .header-right { column-gap: 18px; } .row-destination p { display: none; } .footer-status { max-width: 180px; font-size: 12px; } }
|
||||
@media (orientation: portrait), (max-width: 850px) {
|
||||
.board { padding: 16px 22px 0; }
|
||||
h1 { font-size: clamp(30px, 5.7vw, 62px); }
|
||||
.stop-direction { font-size: clamp(16px, 3vw, 28px); }
|
||||
@container (max-height: 380px) {
|
||||
.weather-current strong { font-size: 56px; }
|
||||
.weather-info .weather-description { font-size: 20px; margin: 10px 0; }
|
||||
.weather-body { gap: 12px; }
|
||||
.weather-hours > div { display: grid; grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 7px; }
|
||||
.weather-hours time { grid-column: 1 / -1; text-align: center; }
|
||||
.weather-hours strong { font-size: 22px; }
|
||||
.weather-attribution { padding-top: 12px; }
|
||||
}
|
||||
@media (min-width: 701px) {
|
||||
.info-screen[data-compact='true'] .weather-body { flex: 1 1 0; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: 32px; }
|
||||
.info-screen[data-compact='true'] .weather-now { height: 100%; min-height: 0; display: flex; flex-direction: column; justify-content: center; padding: clamp(12px, 1.5cqw, 24px); border: 1px solid var(--line); border-radius: 22px; background: color-mix(in srgb, var(--accent) 5%, var(--background)); }
|
||||
.info-screen[data-compact='true'] .weather-now-label { display: block; font-size: clamp(12px, 1.5cqw, 20px); color: var(--muted); letter-spacing: .1em; margin-bottom: 12px; }
|
||||
.info-screen[data-compact='true'] .weather-current { font-size: clamp(48px, min(13cqw, 28cqh), 180px); gap: .16em; }
|
||||
.info-screen[data-compact='true'] .weather-current strong { font-size: 1em; }
|
||||
.info-screen[data-compact='true'] .weather-current svg { width: .8em !important; height: .8em !important; }
|
||||
.info-screen[data-compact='true'] .weather-description { font-size: clamp(20px, 3cqw, 36px); margin: 12px 0; }
|
||||
.info-screen[data-compact='true'] .weather-details { font-size: clamp(16px, 2cqw, 23px); }
|
||||
.info-screen[data-compact='true'] .weather-future { height: 100%; display: flex; flex-direction: column; min-height: 0; }
|
||||
.info-screen[data-compact='true'] .weather-hours { flex: 1 1 0; min-height: 0; padding-top: 0; border: 0; }
|
||||
.info-screen[data-compact='true'] .weather-hours > div { display: flex; justify-content: space-between; min-height: 0; padding: clamp(8px, 1cqw, 18px) 6px; gap: 8px; border: 1px solid var(--line); border-radius: 18px; background: color-mix(in srgb, var(--accent) 5%, var(--background)); }
|
||||
.info-screen[data-compact='true'] .weather-hours time { font-size: clamp(15px, 2cqw, 26px); }
|
||||
.info-screen[data-compact='true'] .weather-hours svg { width: clamp(36px, 5cqw, 64px) !important; height: clamp(36px, 5cqw, 64px) !important; }
|
||||
.info-screen[data-compact='true'] .weather-hours strong { font-size: clamp(26px, 3.5cqw, 48px); }
|
||||
.info-screen[data-compact='true'] .weather-hour-rain { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 2px 4px; font-size: clamp(12px, 1.5cqw, 20px); color: var(--secondary); }
|
||||
.info-screen[data-compact='true'] .precipitation-chance { text-align: center; font-size: 17px; }
|
||||
.info-screen[data-compact='true'] .qr-content { flex-direction: row; gap: 40px; }
|
||||
.info-screen[data-compact='true'] .passenger-qr { width: clamp(110px, calc(100cqh - 85px), 230px); }
|
||||
.info-screen[data-compact='true'] .qr-description { align-items: flex-start; text-align: left; }
|
||||
.info-screen[data-compact='true'] .qr-description p { font-size: clamp(22px, 2.5vw, 32px); }
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.brand-word { display: none; }
|
||||
.clock { font-size: clamp(35px, 6.2vw, 68px); }
|
||||
.today { font-size: clamp(12px, 2.1vw, 20px); }
|
||||
.hero-destination h2 { font-size: min(6vw, calc(var(--hero-height) * .33)); }
|
||||
.transport-kind { font-size: clamp(14px, 2.5vw, 24px); }
|
||||
.hero-details { font-size: clamp(12px, 2.4vw, 22px); }
|
||||
.hero-departure { gap: 18px; }
|
||||
.countdown-large small { font-size: clamp(17px, 3vw, 28px); }
|
||||
.countdown-large .countdown-now { font-size: min(5.2vw, calc(var(--hero-height) * .4)); }
|
||||
.row-destination h3 { font-size: min(46px, 5.7vw, calc(var(--row-height) * .43)); }
|
||||
.row-time { font-size: min(36px, calc(var(--row-height) * .35)); }
|
||||
.departure-row { grid-template-columns: auto minmax(0, 1fr) auto auto; gap: 18px; }
|
||||
.departure-row .route-badge { width: clamp(68px, 12vw, 126px); }
|
||||
.info-intro { flex-direction: column; align-items: flex-start; gap: 12px; }
|
||||
.information-tabs { display: flex; }
|
||||
.info-grid { grid-template-columns: 1fr; }
|
||||
.header-right { column-gap: 18px; }
|
||||
.scene-navigation button > span:last-child { display: none; }
|
||||
.hero-details > span { display: none; }
|
||||
.hero-departure { gap: 20px; }
|
||||
.hero-destination h2 { font-size: min(4.5vw, calc(var(--hero-height) * .34)); }
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.departure-row { grid-template-columns: auto minmax(0, 1fr) auto; gap: 16px; }
|
||||
.row-time, .row-destination p { display: none; }
|
||||
.row-time-compact { display: flex; align-items: center; gap: 6px; margin-top: 8px; color: var(--secondary); font-size: min(20px, calc(var(--row-height) * .21)); flex-shrink: 0; }
|
||||
.row-time-compact .departure-day { font-size: .8em; }
|
||||
.row-destination h3 { font-size: min(36px, 4vw, calc(var(--row-height) * .36)); }
|
||||
.countdown[data-long='true'] .countdown-value { font-size: min(40px, calc(var(--row-height) * .42)); }
|
||||
.countdown-large[data-long='true'] .countdown-value { font-size: calc(var(--hero-height) * .62); }
|
||||
}
|
||||
@media (max-height: 750px) {
|
||||
.info-screen { padding: 12px 0; }
|
||||
.info-intro { margin-bottom: 12px; }
|
||||
.info-intro h2 { font-size: 30px; }
|
||||
.info-card { padding: 18px; }
|
||||
.card-icon { display: none; }
|
||||
.info-card h3 { font-size: 26px; margin-bottom: 14px; }
|
||||
.info-status { font-size: 14px; padding-top: 12px; }
|
||||
.persistent-next { padding: 11px 0; }
|
||||
.weather-attribution { font-size: 11px; }
|
||||
}
|
||||
@media (orientation: portrait), (max-width: 700px) {
|
||||
.board { padding: 16px 20px 0; }
|
||||
h1 { font-size: clamp(28px, 5.7vw, 62px); }
|
||||
.stop-direction { font-size: clamp(15px, 3vw, 28px); }
|
||||
.clock { font-size: clamp(32px, 6.2vw, 68px); }
|
||||
.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-card:not([data-active='true']) { display: none; }
|
||||
.info-card { padding: 24px; }
|
||||
.info-card h3 { font-size: clamp(26px, 4vw, 40px); }
|
||||
.info-card p { font-size: clamp(20px, 3.2vw, 34px); }
|
||||
.route-cloud > span { height: 44px; min-width: 54px; font-size: 24px; }
|
||||
.info-caption, .info-status { font-size: clamp(15px, 2.4vw, 24px); }
|
||||
.persistent-next { gap: 13px; }
|
||||
.persistent-next > .eyebrow, .persistent-next > .source-label { display: none; }
|
||||
.information-tabs { display: flex; }
|
||||
.persistent-next > .eyebrow { display: none; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.board { padding: 12px 16px 0; }
|
||||
.board-header { gap: 14px; padding-bottom: 12px; }
|
||||
.header-right { column-gap: 10px; }
|
||||
.official-mark { width: 32px; }
|
||||
.clock { font-size: clamp(28px, 6.8vw, 44px); gap: 4px; }
|
||||
h1 { font-size: clamp(27px, 6.5vw, 42px); }
|
||||
.today { font-size: 11px; }
|
||||
.stop-direction { font-size: 15px; margin-top: 7px; gap: 7px; }
|
||||
.stop-direction svg { width: 18px; height: 18px; }
|
||||
.board { --control-size: 32px; --control-gap: 3px; --hero-height: clamp(100px, 16vh, 150px); --hero-gap: 18px; padding: 12px 16px 0; }
|
||||
.board[data-row-count='5'], .board[data-row-count='6'] { --hero-height: 95px; }
|
||||
.board-header { gap: 12px; padding-bottom: 12px; }
|
||||
.header-right { column-gap: 9px; }
|
||||
.official-mark { width: 30px; }
|
||||
.clock { font-size: clamp(27px, 6.8vw, 44px); gap: 4px; }
|
||||
h1 { font-size: clamp(25px, 6.5vw, 42px); }
|
||||
.today { font-size: 10px; }
|
||||
.stop-meta { gap: 5px 12px; }
|
||||
.stop-direction { font-size: 14px; margin-top: 7px; gap: 7px; }
|
||||
.weather-summary { font-size: 13px; gap: 6px; margin-top: 7px; }
|
||||
.weather-summary svg { width: 20px; height: 20px; }
|
||||
.weather-summary > span { display: none; }
|
||||
.source-label { font-size: 12px; gap: 5px; }
|
||||
.eyebrow { font-size: 11px; }
|
||||
.hero-departure { gap: 13px; }
|
||||
.hero-destination h2 { font-size: min(6.3vw, calc(var(--hero-height) * .32)); }
|
||||
.transport-kind { font-size: 12px; gap: 5px; margin-bottom: 4px; }
|
||||
.transport-kind svg { width: 18px; height: 18px; }
|
||||
.hero-details { display: none; }
|
||||
.hero-details .schedule-time { display: none; }
|
||||
.route-badge-large { width: calc(var(--hero-height) * .72); padding: 0 12px; font-size: calc(var(--hero-height) * .55); }
|
||||
.countdown-large > span { font-size: calc(var(--hero-height) * .6); }
|
||||
.countdown-future > span { font-size: min(24px, calc(var(--row-height) * .36)); }
|
||||
.countdown-large.countdown-future > span { font-size: min(28px, calc(var(--hero-height) * .35)); }
|
||||
.eyebrow { font-size: 10px; }
|
||||
.hero-departure { gap: 12px; }
|
||||
.transport-kind { font-size: 11px; gap: 5px; margin-bottom: 5px; }
|
||||
.transport-kind svg { width: 16px; height: 16px; }
|
||||
.hero-details { margin-top: 7px; font-size: 12px; }
|
||||
.hero-destination h2 { font-size: min(6vw, calc(var(--hero-height) * .30)); }
|
||||
.route-badge-large { width: calc(var(--hero-height) * .70); padding: 0 10px; font-size: calc(var(--hero-height) * .54); }
|
||||
.countdown-large .countdown-value { font-size: calc(var(--hero-height) * .62); }
|
||||
.countdown-large[data-long='true'] .countdown-value { font-size: min(34px, calc(var(--hero-height) * .36)); }
|
||||
.countdown-large .countdown-now { font-size: 5vw; }
|
||||
.countdown-large small { font-size: 14px; }
|
||||
.countdown { gap: 5px; }
|
||||
.departure-row { gap: 12px; }
|
||||
.departure-row .route-badge { width: 65px; padding: 0 9px; }
|
||||
.row-time { display: none; }
|
||||
.departure-row { grid-template-columns: auto minmax(0, 1fr) auto; }
|
||||
.row-destination h3 { font-size: min(5.8vw, calc(var(--row-height) * .43)); }
|
||||
.countdown > span { font-size: min(36px, calc(var(--row-height) * .6)); }
|
||||
.countdown small { font-size: min(16px, calc(var(--row-height) * .25)); }
|
||||
.countdown-large > span { font-size: calc(var(--hero-height) * .6); }
|
||||
.departure-row { gap: 10px; }
|
||||
.departure-row .route-badge { width: 60px; padding: 0 8px; }
|
||||
.row-destination h3 { font-size: min(5.7vw, calc(var(--row-height) * .36)); }
|
||||
.row-time-compact { font-size: min(15px, calc(var(--row-height) * .19)); margin-top: 6px; }
|
||||
.countdown .countdown-value { font-size: min(40px, calc(var(--row-height) * .6)); }
|
||||
.countdown[data-long='true'] .countdown-value { font-size: min(27px, calc(var(--row-height) * .38)); }
|
||||
.countdown-part small { font-size: max(14px, .42em); }
|
||||
.countdown-large .countdown-value { font-size: calc(var(--hero-height) * .62); }
|
||||
.countdown-large[data-long='true'] .countdown-value { font-size: 32px; }
|
||||
.scene-navigation { gap: 12px; }
|
||||
.scene-navigation button > span:last-child { display: none; }
|
||||
.footer-main { min-height: 50px; gap: 8px; padding: 6px 0; }
|
||||
.footer-status { font-size: 10px; max-width: 95px; }
|
||||
.board-controls { gap: 3px; }
|
||||
.icon-button { width: 32px; height: 32px; }
|
||||
.footer-main { min-height: 50px; padding: 6px 0; }
|
||||
.footer-status { font-size: 10px; padding-left: 10px; }
|
||||
.board-controls { margin-left: 8px; }
|
||||
.board-controls svg { width: 21px; height: 21px; }
|
||||
.persistent-next { --row-height: 54px; gap: 10px; padding: 9px 0; }
|
||||
.persistent-next .route-badge { height: 38px; width: 64px; font-size: 26px; }
|
||||
.persistent-next strong { font-size: 18px; }
|
||||
.persistent-next .countdown > span { font-size: 29px; }
|
||||
.persistent-next { --row-height: 54px; gap: 10px; padding: 10px 0; }
|
||||
.persistent-next .route-badge { height: 42px; width: 60px; font-size: 27px; }
|
||||
.persistent-destination strong { font-size: 17px; }
|
||||
.persistent-destination time { font-size: 13px; }
|
||||
.persistent-next .countdown-value { font-size: 29px; }
|
||||
.persistent-next .countdown[data-long='true'] .countdown-value { font-size: 25px; }
|
||||
.persistent-next .countdown-now { font-size: 19px; }
|
||||
.info-screen { padding: 12px 0; }
|
||||
.info-intro { gap: 10px; margin-bottom: 12px; }
|
||||
.info-intro h2 { font-size: 30px; }
|
||||
.info-intro h2 { font-size: 28px; }
|
||||
.information-tabs { gap: 7px; }
|
||||
.information-tabs button { font-size: 12px; padding: 7px 10px; }
|
||||
.info-card { padding: 18px; }
|
||||
.card-icon { margin-bottom: 10px; }
|
||||
.card-icon svg { width: 25px; height: 25px; }
|
||||
.info-card h3 { font-size: 24px; margin-bottom: 12px; }
|
||||
.info-card p { font-size: 19px; }
|
||||
.weather-current strong { font-size: 68px; }
|
||||
.weather-current strong { font-size: 58px; }
|
||||
.weather-info .weather-description { font-size: 20px; }
|
||||
.weather-hours { margin-top: 14px; padding-top: 10px; }
|
||||
.weather-hours { padding-top: 12px; }
|
||||
.weather-body { gap: 12px; }
|
||||
.weather-attribution { padding-top: 12px; }
|
||||
.routes-info .schedule-explanation { display: none; }
|
||||
.qr-content p { font-size: 16px; margin: 12px 0; }
|
||||
.passenger-qr { width: min(200px, 25vh); }
|
||||
.route-cloud { gap: 7px; }
|
||||
.route-cloud > span { height: 35px; min-width: 43px; padding: 0 7px; font-size: 19px; }
|
||||
.info-status { font-size: 13px; padding-top: 12px; }
|
||||
.info-status { font-size: 12px; padding-top: 10px; }
|
||||
.notice { grid-template-columns: 44px minmax(0, 1fr); gap: 14px; padding: 18px 14px; border-radius: 20px; }
|
||||
.notice-signal { width: 44px; height: 44px; border-radius: 14px; }
|
||||
.notice-signal svg { width: 28px; height: 28px; }
|
||||
.notice-meta { margin-bottom: 12px; }
|
||||
.notice-routes { margin-bottom: 12px; }
|
||||
.notice-routes .route-badge { height: 35px; min-width: 42px; padding: 0 10px; font-size: 23px; }
|
||||
.notice h3 { font-size: 25px; }
|
||||
.notice p { font-size: 17px; }
|
||||
.transport-notice, .offline-banner { font-size: 12px; }
|
||||
.settings-heading, .settings-body, .settings-actions { padding-left: 20px; padding-right: 20px; }
|
||||
}
|
||||
@media (max-height: 620px) {
|
||||
.board { --hero-height: 90px; padding-top: 10px; }
|
||||
.board[data-row-count='5'], .board[data-row-count='6'] { --hero-height: 76px; }
|
||||
@media (max-height: 620px) and (min-width: 701px) {
|
||||
.board { --hero-height: 110px; --hero-gap: 18px; padding-top: 10px; }
|
||||
.board[data-row-count='5'], .board[data-row-count='6'] { --hero-height: 95px; }
|
||||
.board-header { padding-bottom: 10px; }
|
||||
.clock { font-size: 37px; }
|
||||
.clock { font-size: 40px; }
|
||||
h1 { font-size: 32px; }
|
||||
.stop-direction { font-size: 15px; }
|
||||
.official-mark { width: 35px; }
|
||||
.brand { font-size: 26px; }
|
||||
.today { font-size: 12px; }
|
||||
.info-card { padding: 16px; }
|
||||
.card-icon { display: none; }
|
||||
.info-screen { padding: 10px 0; }
|
||||
.info-intro h2 { font-size: 28px; }
|
||||
.info-card { padding: 16px 20px; }
|
||||
.info-card h3 { font-size: 25px; margin-bottom: 10px; }
|
||||
.info-screen[data-compact='true'][data-cards='true'] { position: relative; }
|
||||
.info-screen[data-compact='true'][data-cards='true'] .info-intro { position: absolute; top: 27px; right: 20px; z-index: 1; margin: 0; }
|
||||
.info-screen[data-compact='true'][data-cards='true'] .info-intro h2 { display: none; }
|
||||
.info-screen[data-compact='true'][data-cards='true'] .info-card > h3 { padding-right: 290px; min-height: 36px; text-align: left; display: flex; align-items: center; }
|
||||
.info-screen[data-compact='true'] .weather-now-label { margin-bottom: 8px; }
|
||||
.info-screen[data-compact='true'] .weather-description { font-size: 20px; margin: 8px 0; }
|
||||
.info-screen[data-compact='true'] .weather-details { font-size: 15px; }
|
||||
.info-screen[data-compact='true'] .weather-hours strong { font-size: 26px; }
|
||||
.info-screen[data-compact='true'] .weather-hours time { font-size: 15px; }
|
||||
.info-screen[data-compact='true'] .weather-hours svg { width: 36px !important; height: 36px !important; }
|
||||
.info-screen[data-compact='true'] .weather-hour-rain { font-size: 12px; }
|
||||
.info-status { font-size: 13px; padding-top: 10px; }
|
||||
.persistent-next > .eyebrow { display: none; }
|
||||
.persistent-next { padding: 10px 0; }
|
||||
}
|
||||
@media (orientation: portrait) and (max-height: 850px) {
|
||||
.routes-info .card-icon, .routes-info p { display: none; }
|
||||
.routes-info .route-cloud { margin-bottom: 0; gap: 6px; }
|
||||
.routes-info .route-cloud > span { height: 32px; min-width: 40px; font-size: 18px; }
|
||||
}
|
||||
@media (orientation: portrait) and (max-height: 700px) {
|
||||
.weather-info { padding: 14px; }
|
||||
.info-intro h2 { font-size: 26px; }
|
||||
@media (max-width: 700px) and (max-height: 740px) {
|
||||
.info-intro h2 { font-size: 25px; }
|
||||
.info-intro { gap: 8px; }
|
||||
.info-status { font-size: 12px; padding-top: 8px; line-height: 1.25; }
|
||||
.weather-info h3 { margin-bottom: 6px; }
|
||||
.weather-current strong { font-size: 48px; }
|
||||
.weather-current svg { width: 40px; height: 40px; }
|
||||
.weather-info .weather-description { font-size: 18px; margin: 4px 0 8px; }
|
||||
.weather-hours { margin-top: 8px; padding-top: 6px; }
|
||||
.weather-hours > div { gap: 5px; }
|
||||
.weather-current strong { font-size: 46px; }
|
||||
.weather-current svg { width: 44px !important; height: 44px !important; }
|
||||
.weather-info .weather-description { font-size: 18px; margin: 8px 0; }
|
||||
.weather-body { gap: 8px; }
|
||||
.weather-hours { padding-top: 8px; }
|
||||
.weather-hours > div { gap: 6px; }
|
||||
.precipitation-chance { margin-top: 8px; }
|
||||
.weather-attribution { padding-top: 6px; font-size: 11px; }
|
||||
.passenger-qr { width: 150px; }
|
||||
.qr-info h3 { font-size: 23px; }
|
||||
.weather-attribution { padding-top: 8px; font-size: 10px; }
|
||||
.weather-info { padding: 16px; }
|
||||
.passenger-qr { width: clamp(108px, calc(100cqh - 130px), 190px); }
|
||||
.qr-content { gap: 14px; }
|
||||
.qr-description { gap: 9px; }
|
||||
}
|
||||
::view-transition { pointer-events: none; }
|
||||
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 320ms; }
|
||||
@@ -372,4 +443,9 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-delay: 0ms !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
||||
.numeric-old { visibility: hidden; }
|
||||
.numeric-current { transform: none !important; }
|
||||
.destination-old { visibility: hidden; }
|
||||
.destination-current { transform: none !important; }
|
||||
.route-track { animation: none !important; }
|
||||
.route-lane { transform: none; overflow: auto; }
|
||||
.route-group:nth-child(2) { display: none; }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export type BoardData = { cityId: number; cityName: string; stop: Stop; departur
|
||||
alerts: Alert[]; alertsAvailable: boolean; partial: boolean; updatedAt: string; serverTime?: string; serviceDate: string; mode: 'live' | 'demo' };
|
||||
export type Config = { cityId: number; cityName: string; stopName: string; defaultStopId: number; configured: boolean; refreshSeconds: number; timeZone: string; passengerBaseUrl?: string };
|
||||
export type Settings = { stopId: number; demo: boolean; mainSeconds: number; infoSeconds: number; holdNear: boolean;
|
||||
rowCount: number; routeFilter: string; colorScheme: string; adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
|
||||
rowCount: number; routeFilter: string; theme: 'dark' | 'light'; colorScheme: string; lightColorScheme: string; adsEnabled: boolean; adSeconds: number; adTitle: string; adText: string };
|
||||
export type Scene = 'departures' | 'info' | 'ad';
|
||||
export type Timetable = { version: 1; cityId: number; cityName: string; stop: Stop; routes: (Route & { schedules: {
|
||||
dayType: string; validFrom: string | null; validUntil: string | null; trips: { arrivalSeconds: number; destination: string; note: string; daysOfWeek: number[]; exceptDaysOfWeek: number[] }[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { colorScheme, colorSchemes, departurePage, numericFrame, stopDirection } from '../src/lib/presentation.mjs';
|
||||
import { colorScheme, colorSchemes, lightColorSchemes, departurePage, durationParts, numericFrame, stopDirection } from '../src/lib/presentation.mjs';
|
||||
|
||||
test('all accent schemes remain legible against black and with black route numbers', () => {
|
||||
for (const scheme of colorSchemes) {
|
||||
@@ -11,6 +11,23 @@ test('all accent schemes remain legible against black and with black route numbe
|
||||
}
|
||||
assert.equal(colorScheme('unknown').id, 'lime');
|
||||
});
|
||||
test('light accents have at least 7:1 contrast against white and retain a separate default', () => {
|
||||
for (const scheme of lightColorSchemes) {
|
||||
const rgb = scheme.color.slice(1).match(/../g).map(value => parseInt(value, 16) / 255);
|
||||
const channels = rgb.map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
|
||||
const luminance = .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2];
|
||||
assert.ok(1.05 / (luminance + .05) >= 7, scheme.id);
|
||||
}
|
||||
assert.equal(colorScheme('lime', 'light').id, 'blue');
|
||||
assert.equal(colorScheme('cyan').id, 'cyan');
|
||||
});
|
||||
test('waiting time crosses hour boundaries without displaying hundreds of minutes', () => {
|
||||
assert.deepEqual(durationParts(59), [{ value: 59, unit: 'мин' }]);
|
||||
assert.deepEqual(durationParts(60), [{ value: 1, unit: 'ч' }]);
|
||||
assert.deepEqual(durationParts(121), [{ value: 2, unit: 'ч' }, { value: 1, unit: 'мин' }]);
|
||||
assert.deepEqual(durationParts(255), [{ value: 4, unit: 'ч' }, { value: 15, unit: 'мин' }]);
|
||||
assert.deepEqual(durationParts(302), [{ value: 5, unit: 'ч' }, { value: 2, unit: 'мин' }]);
|
||||
});
|
||||
test('clock carry rolls changed positions while keeping separators and unchanged digits still', () => {
|
||||
const frame = numericFrame('09:59', '10:00', 959, 1000);
|
||||
assert.equal(frame.direction, 'up');
|
||||
|
||||
23
test/qr.test.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import jsQR from 'jsqr';
|
||||
import { createPassengerQr, passengerLink } from '../src/lib/passenger.mjs';
|
||||
|
||||
test('rounded QR modules and finders remain decodable for multiple city and platform links', () => {
|
||||
for (const [cityId, stopId] of [[1, 1], [1, 107], [2, 123456]]) {
|
||||
const url = passengerLink('https://pwa.ridebus.by/', cityId, stopId), qr = createPassengerQr(url);
|
||||
const scale = 6, width = qr.size * scale, pixels = new Uint8ClampedArray(width * width * 4).fill(255);
|
||||
for (const rect of qr.rectangles) {
|
||||
for (let y = rect.y * scale; y < (rect.y + rect.height) * scale; y++) for (let x = rect.x * scale; x < (rect.x + rect.width) * scale; x++) {
|
||||
const px = (x + .5) / scale, py = (y + .5) / scale;
|
||||
const cx = Math.max(rect.x + rect.rx, Math.min(rect.x + rect.width - rect.rx, px));
|
||||
const cy = Math.max(rect.y + rect.rx, Math.min(rect.y + rect.height - rect.rx, py));
|
||||
if ((px - cx) ** 2 + (py - cy) ** 2 > rect.rx ** 2) continue;
|
||||
const index = (y * width + x) * 4, value = rect.fill === '#ffffff' ? 255 : 16;
|
||||
pixels[index] = pixels[index + 1] = pixels[index + 2] = value;
|
||||
}
|
||||
}
|
||||
assert.equal(jsQR(pixels, width, width)?.data, url);
|
||||
assert.ok(qr.rectangles.every(rect => rect.x >= 4 && rect.y >= 4 && rect.x + rect.width <= qr.size - 4 && rect.y + rect.height <= qr.size - 4));
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readSnapshot, writeSnapshot, readTimetable, writeTimetable, readWeather, writeWeather } from '../src/lib/storage.ts';
|
||||
import { readSnapshot, writeSnapshot, readTimetable, writeTimetable, readWeather, writeWeather, readSettings, saveSettings } from '../src/lib/storage.ts';
|
||||
|
||||
const data = { cityId: 1, stop: { id: 1, name: 'Швейная фабрика' }, mode: 'live', serviceDate: '2026-10-08', updatedAt: '2026-10-08T12:00:00+03:00', routes: [], alerts: [],
|
||||
departures: [{ id: '1:12:30', scheduledAt: '2026-10-08T12:30:00+03:00' }] };
|
||||
@@ -30,3 +30,14 @@ test('full timetable and weather survive storage fallback and stay isolated by c
|
||||
assert.deepEqual(await readTimetable(1, 1), timetable); assert.deepEqual(await readWeather(1, 1), weather); assert.deepEqual(await readSnapshot(1, 1), data);
|
||||
assert.equal(await readTimetable(1, 2), null); assert.equal(await readWeather(2, 1), null);
|
||||
});
|
||||
test('old theme preferences migrate safely and each theme retains its own accent', () => {
|
||||
const values = new Map([['ridebus-board-settings-v1', JSON.stringify({ colorScheme: 'cyan' })]]);
|
||||
globalThis.localStorage = { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value) };
|
||||
const old = readSettings();
|
||||
assert.equal(old.theme, 'dark'); assert.equal(old.colorScheme, 'cyan'); assert.equal(old.lightColorScheme, 'blue');
|
||||
saveSettings({ ...old, theme: 'light', lightColorScheme: 'crimson' });
|
||||
const saved = readSettings();
|
||||
assert.equal(saved.theme, 'light'); assert.equal(saved.lightColorScheme, 'crimson'); assert.equal(saved.colorScheme, 'cyan');
|
||||
values.set('ridebus-board-settings-v1', JSON.stringify({ theme: 'light', lightColorScheme: 'lime' }));
|
||||
assert.equal(readSettings().lightColorScheme, 'blue');
|
||||
});
|
||||
|
||||