docs: Add verification-info-simple.md detailing the simplified information screen changes as of October 9, 2026
@@ -21,8 +21,8 @@ npm start
|
||||
- Главный экран целиком отдан рейсам: ближайший крупно и следующие 2–6 рейсов. Строки рассчитываются по фактической доступной высоте, нижняя панель занимает отдельное место. Если все строки не помещаются с читаемым размером текста, список сменяется страницами каждые 7 секунд с явным счётчиком. На 879×593 и 390×667 шесть рейсов показаны страницами по три; на 855×723 четыре помещаются одновременно. В узкой компоновке время отправления находится под названием и не пересекается с ожиданием.
|
||||
- В шапке — название и направление конкретной площадки из API, часы и дата в `Europe/Minsk`. Поле «основная» не выдаётся за направление; если направления нет, оно не придумывается. Город, подпись «Остановка», «RideBus API» и «Все направления» убраны из шапки.
|
||||
- Информационный экран: маршруты, погода, QR-код и предупреждения. Часы и ближайший рейс с цветным номером остаются видимыми. В вертикальной и короткой компоновке показывается одна карточка; при следующих посещениях информационного экрана карточки сменяются. Их также можно выбрать кнопками «Маршруты», «Погода», «RideBus».
|
||||
- В широком блоке маршрутов — несколько слегка наклонённых дорожек с плавным встречным движением, номерами, названиями, типом транспорта и заметным временем следующего отправления. Зазоры учитывают наклон на краях. В узком блоке, включая компоновку с тремя карточками, — вертикальные карточки, сменяющиеся каждые 6,5 секунды с прокруткой названий и чисел. Пауза останавливает оба варианта. Технический статус полного кеша пассажирам не показывается.
|
||||
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены с крупными иконками, цветными номерами маршрутов, сроками действия и ненавязчивой графикой. Исходные заголовки, тексты и детали отмен сохраняются.
|
||||
- Блок маршрутов показывает только номер и название. На широком экране список занимает 2–3 колонки, в узкой карточке — одну. Вместимость определяется доступной высотой; остальные маршруты сменяются страницами каждые 6,5 секунды с плавным переходом названий и чисел. Пауза останавливает смену страниц. Тип транспорта, повторные времена, наклонённые дорожки, фоновые рисунки и технический статус кеша убраны.
|
||||
- Уведомления об отменах, объездах, задержках и другие сообщения оформлены простой карточкой с иконкой, номерами маршрутов и сроком действия. Исходные заголовки, тексты и детали отмен сохраняются. Статус предупреждений отображается только при их недоступности.
|
||||
- Цикл 25 секунд / 8 секунд, настраиваемый. До ближайшего рейса ≤2 минут главный экран удерживается; можно отключить в настройках. Отмены и объезды исключают рекламную сцену из цикла.
|
||||
- Опциональная рекламная сцена с текстом и длительностью; по умолчанию выключена, помечена как предпросмотр. Загрузка медиа и кампании в MVP не входят.
|
||||
- Настройки остановки, маршрута, числа строк, интервалов и деморежима сохраняются на устройстве. Новая остановка загружает свои данные и свой кеш.
|
||||
@@ -46,9 +46,9 @@ npm start
|
||||
|
||||
При сбое появляется пометка сохранённых данных. Истёкшие рейсы удаляются. Если сохранённые рейсы закончились, табло честно сообщает об этом и продолжает переподключение. Предупреждения фильтруются по городу, маршрутам, срокам действия; сохраняются полный текст и детали отмены. Полный кеш расписания не подтверждает актуальность предупреждений — без свежего ответа API это явно указано.
|
||||
|
||||
QR-код создаётся локально, без стороннего сервиса генерации, и открывает `https://pwa.ridebus.by/?city=1&stop=1` для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной `BOARD_PWA_URL`. Карточка оформлена как приглашение забрать расписание: большой неподвижный QR на акцентной панели с плавно движущейся линией маршрута, название остановки, шаги открытия и заметная ссылка. В узкой и короткой карточке используется упрощённая компоновка, размер кода рассчитывается по свободному месту. Декоративное движение выключено при `prefers-reduced-motion`. QR использует скруглённые чёрные модули, белый фон и свободное поле шириной 4 модуля; рамка и внешняя подсветка следуют акценту темы. Декодирование нескольких ссылок и QR на снимках страницы проверено автоматически.
|
||||
QR-код создаётся локально, без стороннего сервиса генерации, и открывает `https://pwa.ridebus.by/?city=1&stop=1` для текущей остановки (параметры меняются вместе с площадкой). Адрес PWA можно переопределить серверной переменной `BOARD_PWA_URL`. В карточке — большой неподвижный QR, одна инструкция и ссылка. Размер кода рассчитывается по свободному месту. QR использует скруглённые чёрные модули, белый фон и свободное поле шириной 4 модуля. Декодирование нескольких ссылок и QR на снимках страницы проверено автоматически.
|
||||
|
||||
Погода запрашивается независимо через `/api/weather` у [Open-Meteo](https://open-meteo.com/en/docs) по публичным координатам остановки из API. В шапке — температура и состояние; на информационном экране — ощущаемая температура, ветер в м/с и три следующих часа. Процент осадков — максимальная почасовая вероятность в этих трёх часах. Это модельные погодные данные. Обновление раз в 10 минут; сохранённая погода помечается и показывается не дольше часа, затем скрывается. При отсутствии координат или сбое погоды транспорт продолжает работать. Данные сохраняются по городу и остановке; RideBus-ключ не передаётся поставщику погоды. Указаны источник и CC BY 4.0.
|
||||
Погода запрашивается независимо через `/api/weather` у [Open-Meteo](https://open-meteo.com/en/docs) по публичным координатам остановки из API. В шапке — температура и состояние; на информационном экране — текущая температура, состояние и прогноз температуры с крупными иконками на три следующих часа. Это модельные погодные данные. Обновление раз в 10 минут; сохранённая погода помечается и показывается не дольше часа, затем скрывается. При отсутствии координат или сбое погоды транспорт продолжает работать. Данные сохраняются по городу и остановке; RideBus-ключ не передаётся поставщику погоды. Указаны источник и CC BY 4.0.
|
||||
|
||||
Бесплатный Open-Meteo подходит для прототипа; [условия сервиса](https://open-meteo.com/en/terms) требуют коммерческую подписку для приложения с рекламой. Сервер уже поддерживает `OPEN_METEO_API_KEY` и автоматически выбирает коммерческий endpoint, ключ остаётся на сервере. Панель оператора планируется отдельно в `polotsk-transit-api`.
|
||||
|
||||
|
||||
BIN
artifacts/info-simple-alerts.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
artifacts/info-simple-light.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
artifacts/info-simple-qr-390x667.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
artifacts/info-simple-qr-879x593.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
artifacts/info-simple-routes-wide.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
artifacts/info-simple-three-cards.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
artifacts/info-simple-weather-390x667.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
artifacts/info-simple-weather-879x593.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
21
artifacts/verification-info-simple.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Упрощение информационного экрана — 9 октября 2026
|
||||
|
||||
Оставлены номер и название маршрута; текущая температура, состояние и прогноз на три часа; QR-код, одна инструкция и ссылка. Удалены дорожки с непрерывным движением, фоновые рисунки, водяные знаки, тип транспорта и повторные времена в каталоге маршрутов, пошаговая инструкция QR и дополнительные показатели погоды. Вместо «Пока вы ждёте» используется «Информация». Строка предупреждений появляется при недоступности данных, без постоянной подписи об отсутствии активных уведомлений.
|
||||
|
||||
Маршруты используют адаптивную сетку из 1–3 колонок, сбалансированные страницы и прежнюю анимацию смены чисел и названий. Уведомления оформлены проще, но исходные сообщения, номера маршрутов и сроки сохранены. Сервер, кеш расписаний, правила актуальности погоды и предупреждений не изменены.
|
||||
|
||||
Проверено:
|
||||
|
||||
- `npm run build`: TypeScript и Vite проходят. `npm test`: 48/48. `git diff --check` проходит.
|
||||
- В браузере загружена итоговая production-сборка `/assets/index-CvnZ7b-g.js`.
|
||||
- 1280×900, тёмная и светлая темы: все три карточки видимы одновременно. У каждой clientHeight и scrollHeight равны 535 px; вертикальной обрезки нет. Дополнительные подписи и декоративные узлы отсутствуют в DOM.
|
||||
- 879×593: одиночные карточки переключаются кнопками «Маршруты / Погода / RideBus». Погода и QR имеют clientHeight=scrollHeight=348 px. Основная погодная иконка ≈81 px, прогнозные ≈51 px. QR ≈262 px, ссылка указывает на текущую остановку.
|
||||
- 390×667: QR ≈196 px, карточка clientHeight=scrollHeight=354 px; подпись и ссылка полностью видны. Погода использует компактные строки «иконка + температура», иконки прогноза 40 px. Прогноз заканчивается на y≈474,3, блок источника начинается на y=500,5; пересечения нет. Карточка маршрутов имеет ту же высоту без обрезки.
|
||||
- 1500×600: каталог маршрутов занимает три колонки по 440 px. Карточка clientHeight=scrollHeight=344 px. После включения смены экранов номера, названия и счётчик страниц обновились с страницы 3/4 на 1/4; после паузы счётчик оставался 1/4. Распределение страниц не оставляет последнюю страницу с одним маршрутом.
|
||||
- QR на снимках тёмной компоновки с тремя карточками, вертикального экрана и светлой темы декодирован через jsQR в `https://pwa.ridebus.by/?city=1&stop=1`.
|
||||
- Уведомления проверены на существующей отдельной визуальной странице с тестовыми отменой, объездом и переносом посадки. Тексты, даты и номера сохранены; декоративные линии и водяные знаки отсутствуют. Это проверка примеров, а не активных сообщений API. Временная сборка страницы удалена повторной production-сборкой, временная вкладка закрыта.
|
||||
- Ошибок и предупреждений в консоли не обнаружено. После проверки восстановлены тёмная тема, остановка 1, четыре следующих рейса, интервалы 25/8 и удержание ближайшего рейса. Пауза выключена, viewport сброшен.
|
||||
|
||||
Снимки: `info-simple-three-cards.jpg`, `info-simple-light.jpg`, `info-simple-routes-wide.jpg`, `info-simple-weather-879x593.jpg`, `info-simple-weather-390x667.jpg`, `info-simple-qr-879x593.jpg`, `info-simple-qr-390x667.jpg`, `info-simple-alerts.jpg`.
|
||||
|
||||
Проверка выполнена в браузере. Физическая Mi Box в этой проверке не использовалась.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowRight, Clock, Close, Info, Reset } from './Icons';
|
||||
import { Clock, Close, Info, Reset } from './Icons';
|
||||
import { RouteBadge } from './RouteBadge';
|
||||
import type { Alert } from './types';
|
||||
|
||||
@@ -12,13 +12,11 @@ export function AlertCard({ alert }: { alert: Alert }) {
|
||||
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-graphic" aria-hidden="true"><div className="notice-signal"><Icon size={32} /></div></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>;
|
||||
}
|
||||
|
||||
10
src/App.tsx
@@ -82,7 +82,7 @@ function DeparturesScreen({ data, departures, now, alerts, loading, rowCount, fr
|
||||
</div>;
|
||||
}
|
||||
|
||||
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;
|
||||
function InfoScreen({ data, alerts, fresh, seconds, paused, page, onPage, config, weather, now }: { data: BoardData | null; 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);
|
||||
@@ -104,14 +104,14 @@ function InfoScreen({ data, routeDepartures, alerts, fresh, seconds, paused, pag
|
||||
return () => { clearTimeout(start); clearInterval(timer); };
|
||||
}, [seconds, paused, page]);
|
||||
return <section ref={panel} className="info-screen" data-compact={compact} data-cards={!alerts.length} aria-label="Информация для пассажиров">
|
||||
<div className="info-intro"><h2>{alerts.length ? 'Изменения движения' : 'Пока вы ждёте.'}</h2>
|
||||
<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 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 routes-info" data-active={page % 3 === 0}><h3>Маршруты</h3><RouteCloud routes={data?.routes || []} paused={paused} /></section>
|
||||
<section className="info-card weather-info" data-active={page % 3 === 1}><WeatherCard data={weather.data} now={now} loading={weather.loading} failed={weather.failed} demo={data?.mode === 'demo'} /></section>
|
||||
<section className="info-card qr-info" data-active={page % 3 === 2}><PassengerQr config={config} stop={data?.stop} /></section>
|
||||
</div>}
|
||||
{!alerts.length && <div className="info-status"><Shield /><span>{data?.alertsAvailable === false || !fresh ? 'Актуальные предупреждения сейчас недоступны' : 'Нет активных предупреждений для этой остановки'}</span></div>}
|
||||
{!alerts.length && (data?.alertsAvailable === false || !fresh) && <div className="info-status"><Shield /><span>Предупреждения сейчас недоступны</span></div>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ 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} 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 === 'info' && <InfoScreen data={data} 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} 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>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ArrowUpRight, Camera, Smartphone } from './Icons';
|
||||
import { ArrowUpRight } from './Icons';
|
||||
import type { Config, Stop } from './types';
|
||||
import { createPassengerQr, passengerLink } from './lib/passenger.mjs';
|
||||
|
||||
@@ -8,14 +8,11 @@ export function PassengerQr({ config, stop }: { config: Config; stop: Stop | und
|
||||
const qr = useMemo(() => createPassengerQr(url), [url]);
|
||||
const stopName = stop?.name || config.stopName;
|
||||
return <><h3>Расписание в телефоне</h3><div className="qr-content"><div className="qr-stage">
|
||||
<div className="qr-route-backdrop" aria-hidden="true"><svg className="qr-route-art" viewBox="0 0 500 500"><path d="M-50 400H75Q130 400 130 340V140Q130 80 190 80H310Q370 80 370 140V340Q370 400 430 400H550" /><circle cx="130" cy="230" r="12" /><circle cx="370" cy="230" r="12" /></svg></div>
|
||||
<a className="qr-paper" href={url} target="_blank" rel="noreferrer" aria-label={`Открыть ${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><span className="qr-stage-corner" aria-hidden="true"><ArrowUpRight size={30} /></span></div>
|
||||
<div className="qr-description"><div className="qr-intro"><span className="qr-eyebrow">С ВАМИ ПО ПУТИ</span><h4 className="qr-stop">{stopName}</h4><p className="qr-summary">Заберите расписание этой остановки с собой.</p></div>
|
||||
<ol className="qr-steps"><li><span className="qr-step-icon"><Camera size={32} /></span><div><span className="qr-step-number">01</span><strong>Наведите камеру</strong><span className="qr-step-detail">на QR-код слева</span></div></li>
|
||||
<li><span className="qr-step-icon"><Smartphone size={32} /></span><div><span className="qr-step-number">02</span><strong>Откройте RideBus</strong><span className="qr-step-detail">в браузере телефона</span></div></li></ol>
|
||||
</svg></a></div>
|
||||
<div className="qr-description"><p>Наведите камеру на QR-код</p>
|
||||
<a className="passenger-link" href={url} target="_blank" rel="noreferrer"><span>{new URL(url).host}</span><ArrowUpRight size={24} /></a>
|
||||
</div></div></>;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { Departure, Route } from './types';
|
||||
import { BusFront } from './Icons';
|
||||
import type { Route } from './types';
|
||||
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) {
|
||||
export function RouteCloud({ routes, paused }: { routes: Route[]; paused: boolean }) {
|
||||
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 columns = area.width >= 1000 ? 3 : area.width >= 600 ? 2 : 1;
|
||||
const capacity = columns * Math.max(1, Math.floor((area.height - 28) / 96));
|
||||
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 pageSize = 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]);
|
||||
const rows = Math.max(1, Math.ceil(pageSize / columns));
|
||||
useLayoutEffect(() => {
|
||||
if (!element.current) return;
|
||||
const observer = new ResizeObserver(entries => {
|
||||
@@ -42,35 +28,13 @@ export function RouteCloud({ routes, departures, now, paused, wide }: Props) {
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (carousel || paused || pages < 2) return;
|
||||
if (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>}
|
||||
</>}
|
||||
}, [paused, pages]);
|
||||
return <div ref={element} className="route-cloud" style={{ '--route-columns': columns, '--route-rows': rows } as CSSProperties}>
|
||||
{routes.length ? <><div className="route-list">{routes.slice(pageStart, pageStart + pageSize).map((route, index) => <article className="route-tile" key={`position-${index}`}>
|
||||
<RouteBadge number={route.number} filled /><div className="route-card-name"><FittedDestination text={route.name} rowHeight={area.height / rows} /></div>
|
||||
</article>)}</div>{pages > 1 && <div className="route-list-pages" aria-label={`Страница маршрутов ${pageIndex + 1} из ${pages}`}><NumericText value={`${pageIndex + 1}/${pages}`} /></div>}</> : <p className="info-caption">Маршруты недоступны</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cloud, Moon, Rain, Snow, Storm, Sun, Wind } from './Icons';
|
||||
import { Cloud, Moon, Rain, Snow, Storm, Sun } from './Icons';
|
||||
import { NumericText } from './NumericText';
|
||||
import { weatherAvailable, weatherCondition } from './lib/weather.mjs';
|
||||
import type { Weather } from './types';
|
||||
@@ -19,12 +19,10 @@ export function WeatherSummary({ data, now, failed }: { data: Weather | null; no
|
||||
}
|
||||
export function WeatherCard({ data, now, loading, failed, demo }: { data: Weather | null; now: number; loading: boolean; failed: boolean; demo: boolean }) {
|
||||
const available = weatherAvailable(data, now);
|
||||
if (!available || !data) return <><div className="card-icon"><Cloud /></div><h3>Погода</h3><p>{demo ? 'Погода доступна с реальными данными остановки.' : loading ? 'Получаем прогноз…' : 'Прогноз сейчас недоступен.'}</p><div className="info-caption">{data ? 'Сохранённый прогноз устарел' : 'Погода обновится при подключении'}</div></>;
|
||||
if (!available || !data) return <><h3>Погода</h3><p className="weather-empty">{demo ? 'Прогноз доступен для реальной остановки' : loading ? 'Получаем прогноз…' : 'Прогноз недоступен'}</p></>;
|
||||
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-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></>;
|
||||
return <><h3>Погода</h3><div className="weather-body" data-forecast={!!hours.length}><div className="weather-now"><div className="weather-current"><WeatherIcon code={data.code} isDay={data.isDay} size={72} /><strong><NumericText value={degrees(data.temperature)} /></strong></div>
|
||||
<p className="weather-description">{weatherCondition(data.code, data.isDay).label}</p></div>
|
||||
{!!hours.length && <div className="weather-hours" aria-label="Прогноз на следующие три часа">{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={48} /><strong><NumericText value={degrees(hour.temperature)} /></strong></div>)}</div>}</div>
|
||||
<div className="weather-attribution">{failed && <span>Сохранённый прогноз</span>}<a href="https://open-meteo.com/" target="_blank" rel="noreferrer">Open-Meteo · CC BY 4.0</a></div></>;
|
||||
}
|
||||
|
||||
227
src/styles.css
@@ -95,122 +95,66 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.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, 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: 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); }
|
||||
.route-list { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: repeat(var(--route-columns), minmax(0, 1fr)); grid-template-rows: repeat(var(--route-rows), minmax(0, 1fr)); gap: 8px 24px; }
|
||||
.route-tile { min-width: 0; min-height: 0; display: flex; align-items: center; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--line); }
|
||||
.route-list .route-badge { width: 72px; height: 60px; font-size: 35px; border-radius: 16px; margin: 0; padding: 0 8px; }
|
||||
.route-card-name { flex: 1 1 0; min-width: 0; height: 100%; display: flex; flex-direction: column; justify-content: center; }
|
||||
.route-list .destination-heading { width: 100%; font-size: clamp(22px, 3cqw, 30px); line-height: 1.2; margin: 0; font-weight: 550; letter-spacing: -.025em; overflow-wrap: anywhere; }
|
||||
.route-list-pages { display: flex; justify-content: flex-end; color: var(--muted); font-size: 16px; padding-top: 10px; }
|
||||
.weather-info { container-name: weather-card; }
|
||||
.weather-body { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); align-content: center; align-items: center; justify-items: center; gap: 24px; }
|
||||
.weather-now { min-width: 0; display: flex; flex-direction: column; align-items: center; }
|
||||
.weather-current { display: flex; align-items: center; gap: .18em; color: var(--accent); font-size: clamp(64px, min(22cqw, 32cqh), 144px); }
|
||||
.weather-current strong { font-size: 1em; font-weight: 550; line-height: 1; }
|
||||
.weather-current svg { width: .8em !important; height: .8em !important; }
|
||||
.weather-info .weather-description { margin: 12px 0 0; font-size: clamp(22px, 3cqw, 34px); line-height: 1.2; text-align: center; }
|
||||
.weather-hours { width: 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; border-top: 1px solid var(--line); padding-top: 20px; }
|
||||
.weather-hours > div { min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 10px; }
|
||||
.weather-hours time { color: var(--secondary); font-size: clamp(16px, 3cqw, 22px); }
|
||||
.weather-hours svg { color: var(--accent); width: clamp(40px, min(12cqw, 16cqh), 64px) !important; height: clamp(40px, min(12cqw, 16cqh), 64px) !important; }
|
||||
.weather-hours strong { font-size: clamp(24px, 6cqw, 40px); font-weight: 550; }
|
||||
.weather-attribution { flex-shrink: 0; padding-top: 16px; display: flex; flex-wrap: wrap; justify-content: center; gap: 5px 12px; font-size: 12px; color: var(--muted); }
|
||||
.weather-attribution a { color: inherit; text-underline-offset: 3px; }
|
||||
.weather-now-label, .weather-hour-rain { display: none; }
|
||||
.qr-info { text-align: left; container-name: qr-card; }
|
||||
.qr-content { flex: 1 1 0; min-height: 0; width: 100%; display: grid; grid-template-columns: minmax(0, .95fr) minmax(0, 1.05fr); gap: clamp(16px, 2cqw, 36px); }
|
||||
.qr-stage { position: relative; isolation: isolate; min-width: 0; min-height: 0; display: grid; place-items: center; padding: clamp(16px, 2cqw, 32px); border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 24px; background: color-mix(in srgb, var(--accent) 8%, var(--background)); overflow: hidden; container-type: size; }
|
||||
.qr-route-backdrop { position: absolute; inset: 0; overflow: hidden; z-index: -1; }
|
||||
.qr-route-art { position: absolute; inset: 0; z-index: -1; width: 100%; height: 100%; color: var(--accent); opacity: .22; transform: rotate(-12deg) scale(1.3); }
|
||||
.qr-route-art path { fill: none; stroke: currentColor; stroke-width: 3; stroke-dasharray: 3 12; stroke-linecap: round; animation: qr-route-flow 12s linear infinite; }
|
||||
.qr-route-art circle { fill: var(--background); stroke: currentColor; stroke-width: 3; }
|
||||
.qr-stage-corner { position: absolute; right: 14px; top: 14px; color: var(--accent); }
|
||||
.qr-paper { display: block; position: relative; z-index: 1; width: min(100cqw, 100cqh, 440px); aspect-ratio: 1; 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); }
|
||||
.weather-info .weather-empty { margin-block: auto; text-align: center; }
|
||||
@container weather-card (min-width: 600px) {
|
||||
.weather-body[data-forecast='true'] { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 32px; }
|
||||
.weather-hours { border: 0; padding: 0; }
|
||||
}
|
||||
@container weather-card (max-height: 300px) {
|
||||
.weather-body { gap: 12px; }
|
||||
.weather-current { font-size: clamp(44px, min(18cqw, 20cqh), 84px); }
|
||||
.weather-info .weather-description { font-size: 20px; margin-top: 8px; }
|
||||
.weather-hours { gap: 8px; padding-top: 12px; }
|
||||
.weather-hours > div { display: grid; grid-template-columns: auto auto; align-items: center; justify-content: center; gap: 6px; }
|
||||
.weather-hours time { grid-column: 1 / -1; text-align: center; }
|
||||
.weather-hours svg { width: 40px !important; height: 40px !important; }
|
||||
.weather-hours strong { font-size: 24px; }
|
||||
.weather-attribution { padding-top: 10px; }
|
||||
}
|
||||
@container weather-card (max-width: 599px) and (max-height: 380px) {
|
||||
.weather-body { gap: 16px; }
|
||||
.weather-hours { padding-top: 14px; gap: 8px; }
|
||||
.weather-hours > div { display: grid; grid-template-columns: auto auto; align-items: center; justify-content: center; gap: 6px; }
|
||||
.weather-hours time { grid-column: 1 / -1; text-align: center; }
|
||||
.weather-hours svg { width: 40px !important; height: 40px !important; }
|
||||
.weather-hours strong { font-size: 24px; }
|
||||
}
|
||||
.qr-info { container-name: qr-card; }
|
||||
.qr-content { flex: 1 1 0; min-height: 0; min-width: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 16px; }
|
||||
.qr-stage { min-width: 0; min-height: 0; display: grid; place-items: center; padding: 4px; container-type: size; }
|
||||
.qr-paper { display: block; width: min(100cqw, 100cqh, 440px); aspect-ratio: 1; padding: 6px; border-radius: 20px; background: #fff; border: 1px solid var(--outline); }
|
||||
.passenger-qr { width: 100%; height: 100%; aspect-ratio: 1; }
|
||||
.qr-description { position: relative; min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: space-between; gap: 16px; padding: clamp(8px, 1cqw, 18px) 0; }
|
||||
.qr-eyebrow { display: block; color: var(--accent); font-size: clamp(12px, 1.4cqw, 20px); letter-spacing: .12em; margin-bottom: 12px; }
|
||||
.qr-stop { margin: 0; font-size: clamp(26px, 4.3cqw, 70px); line-height: 1.08; letter-spacing: -.04em; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.qr-info .qr-summary { font-size: clamp(18px, 2.2cqw, 32px); line-height: 1.3; margin: 14px 0 0; }
|
||||
.qr-steps { margin: 0; padding: 0; display: grid; gap: clamp(12px, 1.5cqw, 22px); list-style: none; }
|
||||
.qr-steps li { display: flex; align-items: center; gap: 14px; }
|
||||
.qr-step-icon { display: grid; place-items: center; width: clamp(42px, 5cqw, 68px); height: clamp(42px, 5cqw, 68px); color: var(--accent); border: 1px solid var(--line); border-radius: 16px; background: color-mix(in srgb, var(--accent) 5%, var(--background)); flex-shrink: 0; }
|
||||
.qr-steps li > div { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 5px 9px; }
|
||||
.qr-step-number { font-size: clamp(12px, 1.4cqw, 20px); color: var(--accent); align-self: center; }
|
||||
.qr-steps strong { font-size: clamp(17px, 2cqw, 29px); font-weight: 550; line-height: 1.15; }
|
||||
.qr-step-detail { grid-column: 2; color: var(--muted); font-size: clamp(13px, 1.5cqw, 21px); }
|
||||
.passenger-link { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--accent); text-decoration: none; font-size: clamp(18px, 2.1cqw, 30px); font-weight: 550; border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); background: color-mix(in srgb, var(--accent) 8%, var(--background)); border-radius: 16px; padding: 14px 18px; }
|
||||
.qr-description { min-width: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 12px; text-align: center; }
|
||||
.qr-info .qr-description p { font-size: clamp(18px, 3cqw, 40px); line-height: 1.25; margin: 0; }
|
||||
.passenger-link { display: inline-flex; align-items: center; gap: 8px; color: var(--accent); text-decoration: none; font-size: clamp(18px, 2.5cqw, 30px); font-weight: 550; }
|
||||
.passenger-link span { min-width: 0; overflow-wrap: anywhere; }
|
||||
@keyframes qr-route-flow { to { stroke-dashoffset: -150; } }
|
||||
@container qr-card (max-width: 600px) {
|
||||
.qr-content { display: flex; flex-direction: column; gap: 12px; }
|
||||
.qr-stage { flex: 1 1 0; min-height: 0; padding: 12px; border-radius: 20px; }
|
||||
.qr-stage-corner { display: none; }
|
||||
.qr-paper { max-width: 100%; border-radius: 20px; padding: 6px; }
|
||||
.qr-description { padding: 0; gap: 12px; flex-shrink: 0; }
|
||||
.qr-eyebrow { font-size: 11px; margin-bottom: 6px; }
|
||||
.qr-stop { font-size: clamp(22px, 6cqw, 30px); }
|
||||
.qr-info .qr-summary, .qr-step-detail { display: none; }
|
||||
.qr-steps { gap: 8px; }
|
||||
.qr-steps li { gap: 9px; }
|
||||
.qr-step-icon { width: 30px; height: 30px; border: 0; border-radius: 9px; }
|
||||
.qr-step-icon svg { width: 24px; height: 24px; }
|
||||
.qr-steps strong { font-size: 15px; }
|
||||
.qr-step-number { font-size: 11px; }
|
||||
.passenger-link { font-size: 18px; padding: 10px 12px; border-radius: 12px; }
|
||||
@container qr-card (min-width: 601px) {
|
||||
.qr-content { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); gap: 32px; }
|
||||
.qr-description { align-items: flex-start; text-align: left; }
|
||||
}
|
||||
@container qr-card (min-width: 601px) and (max-height: 420px) {
|
||||
.qr-description { gap: 12px; padding: 0; }
|
||||
.qr-eyebrow, .qr-step-detail { display: none; }
|
||||
.qr-stop { font-size: clamp(26px, 3.5cqw, 40px); }
|
||||
.qr-info .qr-summary { font-size: 18px; margin-top: 10px; }
|
||||
.qr-steps { gap: 10px; }
|
||||
.qr-step-icon { width: 42px; height: 42px; }
|
||||
.qr-steps strong { font-size: 20px; }
|
||||
.passenger-link { font-size: 22px; padding: 10px 14px; }
|
||||
}
|
||||
@container qr-card (min-width: 601px) and (max-height: 320px) {
|
||||
.qr-description { gap: 10px; padding: 0; }
|
||||
.qr-eyebrow, .qr-info .qr-summary, .qr-step-detail { display: none; }
|
||||
.qr-stop { font-size: clamp(26px, 3.5cqw, 40px); }
|
||||
.qr-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.qr-steps li { gap: 8px; }
|
||||
.qr-step-icon { width: 36px; height: 36px; border: 0; }
|
||||
.qr-step-icon svg { width: 28px; height: 28px; }
|
||||
.qr-steps li > div { display: flex; flex-direction: column; gap: 3px; }
|
||||
.qr-steps strong { font-size: 17px; }
|
||||
.qr-step-number { align-self: flex-start; font-size: 12px; }
|
||||
.passenger-link { font-size: 22px; padding: 10px 14px; }
|
||||
}
|
||||
@container qr-card (max-width: 600px) and (max-height: 380px) {
|
||||
.qr-content { display: flex; flex-direction: column; }
|
||||
.qr-stage { padding: 10px; }
|
||||
.qr-paper { padding: 5px; border-radius: 16px; }
|
||||
.qr-intro, .qr-steps li:nth-child(2) { display: none; }
|
||||
.qr-description { flex-direction: row; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.qr-step-number { display: none; }
|
||||
.qr-steps li > div { display: block; }
|
||||
.qr-step-icon { display: none; }
|
||||
.qr-steps strong { font-size: 14px; }
|
||||
.passenger-link { font-size: 14px; padding: 8px; gap: 4px; }
|
||||
@container qr-card (max-width: 600px) and (max-height: 300px) {
|
||||
.qr-description { gap: 6px; }
|
||||
.qr-info .qr-description p, .passenger-link { font-size: 16px; }
|
||||
.passenger-link svg { width: 18px; height: 18px; }
|
||||
}
|
||||
.info-screen[data-compact='true'] .info-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
@@ -220,14 +164,10 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.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 { --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 { --notice-color: var(--accent); display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 20px; padding: clamp(20px, 2vw, 32px); border: 1px solid var(--line); border-radius: 24px; background: 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-signal { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 14px; background: color-mix(in srgb, var(--notice-color) 10%, var(--background)); }
|
||||
.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); }
|
||||
@@ -235,8 +175,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.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; }
|
||||
@@ -346,33 +284,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.settings-scroll-button { min-width: 78px; padding-inline: 8px; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.brand-word { display: none; }
|
||||
.header-right { column-gap: 18px; }
|
||||
@@ -465,10 +376,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.information-tabs button { font-size: 12px; padding: 7px 10px; }
|
||||
.info-card { padding: 18px; }
|
||||
.info-card h3 { font-size: 24px; margin-bottom: 12px; }
|
||||
.weather-current strong { font-size: 58px; }
|
||||
.weather-info .weather-description { font-size: 20px; }
|
||||
.weather-hours { padding-top: 12px; }
|
||||
.weather-body { gap: 12px; }
|
||||
.weather-attribution { 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; }
|
||||
@@ -498,13 +405,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.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; }
|
||||
@@ -512,13 +412,6 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
@media (max-width: 700px) and (max-height: 740px) {
|
||||
.info-intro h2 { font-size: 25px; }
|
||||
.info-intro { gap: 8px; }
|
||||
.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: 8px; font-size: 10px; }
|
||||
.weather-info { padding: 16px; }
|
||||
}
|
||||
@@ -530,8 +423,4 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.numeric-current { transform: none !important; }
|
||||
.destination-old { visibility: hidden; }
|
||||
.destination-current { transform: none !important; }
|
||||
.route-track { animation: none !important; }
|
||||
.qr-route-art path { animation: none !important; }
|
||||
.route-lane { transform: none; overflow: auto; }
|
||||
.route-group:nth-child(2) { display: none; }
|
||||
}
|
||||
|
||||