feat: add performance monitoring for journey transitions and optimize rendering
- Introduced a new markdown file documenting the optimizations made to the journey motion rendering, including changes to CSS and React components to improve performance on low-end devices. - Implemented a test-only JavaScript file to log layout work during automatic stop changes, tracking metrics such as name resizes, font writes, and heading replacements. - Enhanced the journey rendering by memoizing components and reducing unnecessary re-renders, aligning with web.dev recommendations for animations and layout thrashing prevention.
This commit is contained in:
@@ -108,3 +108,7 @@ docker compose up --build -d
|
||||
Если API записывает ранний рейс 00:xx в новый календарный день, ночная логика дополнительно проверяет короткое продолжение предыдущего блока движения и первый длинный перерыв после него. Это позволяет включить режим после реального рейса 00:40 перед первым утренним в 05:44, не скрывая непрерывное ночное движение или дневные паузы. Даты рейсов и условия календаря при этом не изменяются.
|
||||
|
||||
Схема маршрута использует порядок и временные смещения остановок из полного кеша (`routes[].stops`) и конечную конкретного рейса (`endStopId`). Метаданные загружаются вместе с расписаниями, дополнительных запросов при смене рейса нет. Старые кеши без этих полей остаются пригодны для табло, но схема появляется после обновления полного расписания. Переключатель «Дальше по маршруту» сохраняет прежний ключ `infoServiceEnabled`, поэтому существующие настройки локального и удалённого управления совместимы; миграция БД не требуется.
|
||||
|
||||
В обоих профилях схема анимирует только `transform` и `opacity`: ячейки имеют постоянную ширину, крупный и компактный варианты названия подбираются заранее и не пересоздаются при каждом переходе. В уличном профиле движение спокойнее. Схема не перерисовывается из-за тиков часов, если её данные не изменились; на конечной линия заканчивается у последнего настоящего маркера.
|
||||
|
||||
Для локального сравнения работы подбора шрифтов можно запустить `JOURNEY_PERF=1 node test/fixtures/night-server.mjs`. Параметр `BOARD_FIXTURE_DIST` позволяет использовать сохранённую старую сборку. Наблюдатели пишут `JOURNEY_PERF` в консоль: изменения размеров текстовых блоков, записи размера шрифта и замены заголовков за 1200 мс после смены остановки. Это диагностический стенд, а не замер FPS устройства; он не включён в рабочее табло и Docker runtime.
|
||||
|
||||
BIN
artifacts/journey-motion-960x540.jpg
Normal file
BIN
artifacts/journey-motion-960x540.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
37
artifacts/verification-journey-motion.md
Normal file
37
artifacts/verification-journey-motion.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Прокрутка остановок — 11 октября 2026
|
||||
|
||||
Сообщение пользователя: тормозит на Xiaomi Mi Box 4 в Hi Browser в обоих профилях. Устройство не подключено к ADB (список устройств пуст). На физическом Mi Box FPS не измерялся; результат ниже — исходный код, сравнительные счётчики в Codex in-app browser и проверка компоновки.
|
||||
|
||||
## Причина и изменение
|
||||
|
||||
В `src/styles.css` ширина ячеек анимировалась через `flex-basis`. Каждый промежуточный размер вызывал `ResizeObserver` в `FittedDestination`, измерение текста canvas и подбор шрифта с чтением/записью геометрии. Заголовки дополнительно пересоздавались при смене роли «крупный/компактный».
|
||||
|
||||
В `src/NextStops.tsx` выделен мемоизированный `JourneyRail`. Ячейки всегда одинаковой ширины, каждый заголовок имеет два постоянных слоя с заранее подобранным шрифтом. Положение ячеек, увеличение названия и длина линии меняются через `transform`, видимость слоёв — через `opacity`. Коэффициент увеличения согласован со свободным расстоянием до соседней остановки. Анимации ширины, цвета текста и тени маркера удалены. Во время обычного перехода размеры текста не меняются. Тики часов не запускают рендер схемы при неизменных данных; названия, минуты, количество остановок и продолжение сравниваются перед пропуском рендера.
|
||||
|
||||
Это соответствует [рекомендациям web.dev по анимациям](https://web.dev/articles/animations-guide) и [предотвращению повторных синхронных перерасчётов layout](https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing). Фактический эффект на GPU конкретного браузера требует проверки на устройстве.
|
||||
|
||||
## Сравнение
|
||||
|
||||
Одинаковый стенд: 960×540, тёмная тема, расстояние «Близко», Автовокзал, фильтр №4. Сохранённое публичное расписание, тестовые часы/погода. Старая производственная сборка сохранена до правки; новая сравнивалась с ней через те же наблюдатели. По три перехода в каждом профиле, окно 1200 мс после смены остановки. Скрипт не меняет прототипы и состояние приложения, не передаёт данные наружу. Счётчики относятся только к названиям остановок, а не ко всему layout или FPS.
|
||||
|
||||
| Профиль / переход | Изменения размеров до → после | Записи шрифта до → после | Замены заголовков до → после |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| ТВ, 1 → 2 | 110 → 0 | 842 → 0 | 2 → 0 |
|
||||
| ТВ, 2 → 3 | 110 → 0 | 632 → 0 | 2 → 0 |
|
||||
| ТВ, 3 → 4 | 110 → 0 | 838 → 0 | 2 → 0 |
|
||||
| Уличное табло, 1 → 2 | 38 → 0 | 82 → 0 | 2 → 0 |
|
||||
| Уличное табло, 2 → 3 | 38 → 0 | 160 → 0 | 2 → 0 |
|
||||
| Уличное табло, 3 → 4 | 38 → 0 | 241 → 0 | 2 → 0 |
|
||||
|
||||
## Поведение
|
||||
|
||||
- Сохранены интервалы 4/6 секунд и движение 900/300 мс для ТВ/уличного профиля. Пауза прекращает смену остановок.
|
||||
- Переход 4 → 1 выполняет мягкое появление, без обратного движения всей схемы.
|
||||
- В промежуточных кадрах ТВ: ширина ячейки постоянна **189,29 px**, высота области названия **51 px**, размер шрифта **41,91 px**. Матрица масштаба меняется 0,871 → 0,984 → 1, зазор до соседней остановки — 24,38 → 27,55 → 28,00 px. Наложения нет.
|
||||
- На конечной демонстрационного маршрута без продолжения: длина линии **0 px**, её начало и центр последнего маркера совпадают (65,797 / 65,796 px). Ложного продолжения нет.
|
||||
- 390×667: длинное название «Больница (Новка)» целиком, высота 72,42 px при доступных 76 px; кружок 22 px.
|
||||
- 1920×1080, три карточки: тот же текст высотой 129,17 px при доступных 239 px; кружок 24 px.
|
||||
- Accessibility показывает по одному названию остановки: второй визуальный слой скрыт через `aria-hidden`.
|
||||
- Настройки и API не расширялись. Режим reduced motion отключает переходы и освобождает подсказки слоёв. Счётчики тестового стенда отсутствуют в production bundle и Docker runtime.
|
||||
|
||||
Проверки: `npm run build` (TypeScript + Vite), 10 тестов маршрута/информационных блоков, синтаксис двух файлов стенда и `git diff --check` прошли. Снимок — `journey-motion-960x540.jpg`. Для обновления серверного табло необходимо развернуть файлы и пересобрать образ.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { NumericText } from './NumericText';
|
||||
import { RouteBadge } from './RouteBadge';
|
||||
import { FittedDestination } from './FittedDestination';
|
||||
@@ -9,6 +9,54 @@ import type { OnwardJourney } from './lib/journey';
|
||||
import type { Departure, Settings } from './types';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
// Fixed-size cells move on the compositor. Both text sizes are fitted once,
|
||||
// so moving to the next stop never resizes a cell or remounts its heading.
|
||||
const JourneyRail = memo(function JourneyRail({ journey, focus }: { journey: OnwardJourney; focus: number }) {
|
||||
const rail = useRef<HTMLDivElement>(null);
|
||||
const [geometry, setGeometry] = useState({ slot: 0, focused: 0, width: 0 });
|
||||
useLayoutEffect(() => {
|
||||
const element = rail.current;
|
||||
if (!element) return;
|
||||
const measure = () => {
|
||||
const cell = element.querySelector<HTMLElement>('.journey-station');
|
||||
const heading = cell?.querySelector<HTMLElement>('.journey-name-focus');
|
||||
const next = { slot: cell?.getBoundingClientRect().width || 0,
|
||||
focused: heading ? parseFloat(getComputedStyle(heading).width) : 0, width: element.getBoundingClientRect().width };
|
||||
setGeometry(previous => previous.slot === next.slot && previous.focused === next.focused && previous.width === next.width ? previous : next);
|
||||
};
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element); measure();
|
||||
return () => observer.disconnect();
|
||||
}, [journey.stops.length]);
|
||||
// With no continuation the line ends at the last real stop, including a
|
||||
// single-stop journey. Otherwise it meets the moving dashed continuation.
|
||||
const remaining = journey.stops.length - 1 - focus;
|
||||
const lineEnd = !journey.more && !remaining ? 0
|
||||
: (remaining + (journey.more ? 1 : 0)) * geometry.slot + geometry.focused - geometry.slot;
|
||||
const scale = geometry.width > 24 && geometry.slot > 0 ? Math.max(0, lineEnd / (geometry.width - 24)) : 1;
|
||||
return <div ref={rail} className="journey-rail-window" style={{ '--journey-focus': focus,
|
||||
'--journey-name-scale': geometry.focused > 0 ? geometry.slot / geometry.focused : .4 } as CSSProperties}>
|
||||
<span className="journey-line" aria-hidden="true" style={{ transform: `scaleX(${scale})` }} />
|
||||
<ol className="journey-rail" aria-label="Следующие остановки после посадки">
|
||||
{journey.stops.map((stop, index) => <li className="journey-station" key={stop.id} data-focused={index === focus} data-before={index < focus} data-after={index > focus} aria-current={index === focus ? 'step' : undefined}>
|
||||
<span className="journey-duration" aria-label={stop.minutes === null ? 'Время пути неизвестно' : `В пути ${stop.minutes} мин по расписанию`}>
|
||||
{stop.minutes === null ? '—' : stop.minutes === 0 ? <><span><1</span><small>мин</small></> : durationParts(stop.minutes).map(part => <span key={part.unit}><NumericText value={part.value} /><small>{part.unit}</small></span>)}
|
||||
</span>
|
||||
<div className="journey-station-name">
|
||||
<div className="journey-name-layer journey-name-context" aria-hidden={index === focus}><FittedDestination text={stop.name} /></div>
|
||||
<div className="journey-name-layer journey-name-focus" aria-hidden={index !== focus}><FittedDestination text={stop.name} /></div>
|
||||
</div>
|
||||
<span className="journey-node" aria-hidden="true" />
|
||||
</li>)}
|
||||
{journey.more > 0 && <li className="journey-continuation"><span className="journey-continuation-label">Ещё {journey.more} остановок</span><span className="journey-continuation-line" aria-hidden="true" /></li>}
|
||||
</ol>
|
||||
</div>;
|
||||
}, (previous, next) => previous.focus === next.focus && previous.journey.more === next.journey.more
|
||||
&& previous.journey.stops.length === next.journey.stops.length && previous.journey.stops.every((stop, index) => {
|
||||
const other = next.journey.stops[index];
|
||||
return stop.id === other.id && stop.name === other.name && stop.minutes === other.minutes;
|
||||
}));
|
||||
|
||||
export function NextStops({ departure, journey, loading, now, paused, settings, active, onPages }: {
|
||||
departure?: Departure; journey: OnwardJourney | null; loading: boolean; now: number; paused: boolean; settings: Settings;
|
||||
active: boolean; onPages: (pages: number) => void;
|
||||
@@ -30,18 +78,9 @@ export function NextStops({ departure, journey, loading, now, paused, settings,
|
||||
{!departure ? <p className="journey-empty">{loading ? 'Получаем расписание' : 'Нет доступного рейса'}</p> : <>
|
||||
<div className="journey-route"><RouteBadge number={departure.routeNumber} filled /><div className="journey-trip"><div className="journey-route-name"><FittedDestination text={departure.destination} /></div><time dateTime={departure.scheduledAt}><NumericText value={departure.scheduledTime} />{tomorrow && <span className="journey-day">завтра</span>}</time></div></div>
|
||||
{!journey ? <p className="journey-empty">{loading ? 'Загружаем остановки' : 'Схема маршрута пока недоступна'}</p> : !count ? <p className="journey-empty">Здесь конечная этого рейса</p> : <>
|
||||
<div className="journey-metro" data-calm={reading.calm} data-more={journey.more > 0} data-loop={page > 0 && focus === 0}>
|
||||
<div className="journey-metro" data-active={active} data-calm={reading.calm} data-loop={page > 0 && focus === 0}>
|
||||
<div className="journey-tour-heading"><span>{settings.demo ? 'Демонстрация поездки' : 'После посадки'}</span><span className="journey-order">{focus + 1}-я остановка</span></div>
|
||||
<div className="journey-map-space"><div className="journey-rail-window"><ol className="journey-rail" aria-label="Следующие остановки после посадки" style={{ '--journey-focus': focus } as CSSProperties}>
|
||||
{journey.stops.map((stop, index) => <li className="journey-station" key={stop.id} data-focused={index === focus} data-before={index < focus} aria-current={index === focus ? 'step' : undefined}>
|
||||
<span className="journey-duration" aria-label={stop.minutes === null ? 'Время пути неизвестно' : `В пути ${stop.minutes} мин по расписанию`}>
|
||||
{stop.minutes === null ? '—' : stop.minutes === 0 ? <><span><1</span><small>мин</small></> : durationParts(stop.minutes).map(part => <span key={part.unit}><NumericText value={part.value} /><small>{part.unit}</small></span>)}
|
||||
</span>
|
||||
<div className="journey-station-name"><FittedDestination key={index === focus ? 'focus' : 'context'} text={stop.name} /></div>
|
||||
<span className="journey-node" aria-hidden="true" />
|
||||
</li>)}
|
||||
{journey.more > 0 && <li className="journey-continuation"><span className="journey-continuation-label">Ещё {journey.more} остановок</span><span className="journey-continuation-line" aria-hidden="true" /></li>}
|
||||
</ol></div></div>
|
||||
<div className="journey-map-space"><JourneyRail journey={journey} focus={focus} /></div>
|
||||
</div>
|
||||
<div className="journey-caption"><span className="journey-caption-full">Время пути по расписанию</span><span className="journey-caption-compact">После посадки · по расписанию</span>{pages > 1 && <span className="journey-page" aria-label={`${focus + 1} из ${count} остановок в обзоре`}>{focus + 1} / {count}</span>}</div>
|
||||
</>}
|
||||
|
||||
@@ -116,25 +116,27 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.journey-order { color: var(--accent); font-size: .85em; white-space: nowrap; }
|
||||
.journey-map-space { flex: 1 1 0; min-height: 0; display: flex; align-items: center; margin: 18px -8px 0; }
|
||||
.journey-rail-window { width: 100%; height: min(100%, 320px); position: relative; overflow: hidden; }
|
||||
.journey-rail-window::after { content: ''; position: absolute; inset: 0 0 0 auto; width: 36px; background: linear-gradient(90deg, transparent, var(--background)); pointer-events: none; }
|
||||
.journey-rail { display: flex; height: 100%; margin: 0; padding: 0 0 0 24px; list-style: none; transform: translateX(calc(-1 * var(--journey-focus) * var(--station-width))); transition: transform 900ms cubic-bezier(.22,.72,.18,1); }
|
||||
.journey-station { flex: 0 0 var(--station-width); min-width: 0; position: relative; display: grid; grid-template-rows: auto minmax(0, 1fr) 40px; gap: 10px; padding-right: 28px; transition: flex-basis 900ms cubic-bezier(.22,.72,.18,1); }
|
||||
.journey-station[data-focused=true] { flex-basis: var(--focused-width); }
|
||||
.journey-station::after { content: ''; position: absolute; bottom: 17.5px; left: 0; width: 100%; height: 5px; background: var(--accent); transition: opacity 700ms ease; }
|
||||
.journey-station:last-child::after { width: calc(var(--station-width) - 36px); }
|
||||
.journey-metro[data-more=false] .journey-station:last-child::after { display: none; }
|
||||
.journey-station[data-before=true]::after { opacity: .25; }
|
||||
.journey-station-name { min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: flex-end; padding-bottom: 8px; transition: opacity 500ms ease; }
|
||||
.journey-station[data-before=true] .journey-station-name, .journey-station[data-before=true] .journey-duration { opacity: 0; }
|
||||
.journey-rail-window::after { content: ''; position: absolute; inset: 0 0 0 auto; width: 36px; z-index: 3; background: linear-gradient(90deg, transparent, var(--background)); pointer-events: none; }
|
||||
.journey-rail { position: relative; display: flex; height: 100%; margin: 0; padding: 0 0 0 24px; list-style: none; }
|
||||
.journey-line { position: absolute; left: 24px; bottom: 17.5px; width: calc(100% - 24px); height: 5px; background: var(--accent); transform-origin: left center; transition: transform 900ms cubic-bezier(.22,.72,.18,1); }
|
||||
.journey-station, .journey-continuation { --journey-space: 0px; transform: translate3d(calc(-1 * var(--journey-focus) * var(--station-width) + var(--journey-space)), 0, 0); transition: transform 900ms cubic-bezier(.22,.72,.18,1); }
|
||||
.journey-station { flex: 0 0 var(--station-width); min-width: 0; position: relative; display: grid; grid-template-rows: auto minmax(0, 1fr) 40px; gap: 10px; }
|
||||
.journey-station[data-after=true], .journey-continuation { --journey-space: calc(var(--focused-width) - var(--station-width)); }
|
||||
.journey-metro[data-active=true] .journey-station, .journey-metro[data-active=true] .journey-continuation, .journey-metro[data-active=true] .journey-line { will-change: transform; }
|
||||
.journey-station-name { min-width: 0; min-height: 0; position: relative; }
|
||||
.journey-name-layer { position: absolute; inset: 0 auto 0 0; display: flex; flex-direction: column; justify-content: flex-end; padding: 0 28px 8px 0; opacity: 0; transition: opacity 300ms ease-out, transform 900ms cubic-bezier(.22,.72,.18,1); }
|
||||
.journey-name-context { width: var(--station-width); }
|
||||
.journey-name-focus { width: var(--focused-width); transform: translate3d(0, 0, 0) scale(var(--journey-name-scale, .4)); transform-origin: left bottom; }
|
||||
.journey-station[data-focused=true] .journey-name-focus { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
|
||||
.journey-station[data-focused=false][data-before=false] .journey-name-context { opacity: 1; }
|
||||
.journey-station[data-before=true] .journey-duration { opacity: 0; }
|
||||
.service-info .journey-station-name .destination-heading { flex-shrink: 0; margin: 0; font-size: calc(clamp(22px, 4cqw, 32px) * var(--reading-scale, 1)); line-height: 1.13; font-weight: 600; letter-spacing: -.035em; overflow-wrap: anywhere; transition: color 600ms ease; }
|
||||
.service-info .journey-station[data-focused=true] .destination-heading { font-size: calc(clamp(48px, 11cqw, 96px) * var(--reading-scale, 1)); font-weight: 650; letter-spacing: -.055em; color: var(--foreground); }
|
||||
.journey-station[data-focused=true] .destination-current { transform-origin: left bottom; animation: journey-name-in 900ms cubic-bezier(.22,.72,.18,1) both; }
|
||||
@keyframes journey-name-in { from { opacity: 0; transform: translateX(20px) scale(.84); } to { opacity: 1; transform: translateX(0) scale(1); } }
|
||||
.journey-duration { align-self: start; display: flex; align-items: baseline; gap: 8px; color: var(--muted); font-size: clamp(18px, 3.4cqw, 26px); font-weight: 550; line-height: 1; white-space: nowrap; transition: color 600ms ease, opacity 500ms ease; }
|
||||
.service-info .journey-name-focus .destination-heading { font-size: calc(clamp(48px, 11cqw, 96px) * var(--reading-scale, 1)); font-weight: 650; letter-spacing: -.055em; color: var(--foreground); }
|
||||
.journey-duration { align-self: start; display: flex; align-items: baseline; gap: 8px; color: var(--muted); font-size: clamp(24px, 4.5cqw, 36px); font-weight: 550; line-height: 1; white-space: nowrap; transition: opacity 300ms ease-out; }
|
||||
.journey-duration > span { display: inline-flex; align-items: baseline; gap: 5px; }
|
||||
.journey-duration small { font-size: .72em; font-weight: 500; }
|
||||
.journey-station[data-focused=true] .journey-duration { color: var(--accent); font-size: clamp(24px, 4.5cqw, 36px); }
|
||||
.journey-node { align-self: center; display: block; width: 24px; height: 24px; border: 4px solid var(--accent); border-radius: 50%; background: var(--background); margin-left: -12px; z-index: 1; transition: background 600ms ease, box-shadow 600ms ease; }
|
||||
.journey-station[data-focused=true] .journey-duration { color: var(--accent); }
|
||||
.journey-node { align-self: center; display: block; width: 24px; height: 24px; border: 4px solid var(--accent); border-radius: 50%; background: var(--background); margin-left: -12px; z-index: 1; }
|
||||
.journey-station[data-focused=true] .journey-node { background: var(--accent); box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 18%, transparent); }
|
||||
.journey-station[data-before=true] .journey-node { border-color: var(--outline); }
|
||||
.journey-continuation { flex: 0 0 var(--station-width); min-width: 0; display: flex; align-items: center; position: relative; padding: 34px 20px 40px 0; }
|
||||
@@ -143,19 +145,18 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.journey-caption { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: space-between; color: var(--muted); font-size: 16px; line-height: 1.3; padding-top: 16px; }
|
||||
.journey-caption-compact { display: none; }
|
||||
.journey-page { color: var(--accent); margin-left: auto; }
|
||||
.journey-metro[data-calm=true] .journey-rail { transition: transform 300ms ease-out; }
|
||||
.journey-metro[data-calm=true] .journey-station { transition: flex-basis 300ms ease-out; }
|
||||
.journey-metro[data-calm=true] .destination-heading { transition: color 250ms ease-out; }
|
||||
.journey-metro[data-calm=true] .destination-current { animation: none; }
|
||||
.journey-metro[data-loop=true] .journey-rail { transition: none; animation: journey-overview-reset 600ms ease-out both; }
|
||||
.journey-metro[data-loop=true] .journey-station { transition: none; }
|
||||
.journey-metro[data-calm=true] .journey-station, .journey-metro[data-calm=true] .journey-continuation, .journey-metro[data-calm=true] .journey-line { transition: transform 300ms ease-out; }
|
||||
.journey-metro[data-calm=true] .journey-name-layer { transition: opacity 180ms ease-out, transform 300ms ease-out; }
|
||||
.journey-metro[data-loop=true] .journey-rail-window { animation: journey-overview-reset 600ms ease-out both; }
|
||||
.journey-metro[data-calm=true][data-loop=true] .journey-rail-window { animation-duration: 180ms; }
|
||||
.journey-metro[data-loop=true] .journey-station, .journey-metro[data-loop=true] .journey-continuation, .journey-metro[data-loop=true] .journey-line, .journey-metro[data-loop=true] .journey-name-layer { transition: none; }
|
||||
@keyframes journey-overview-reset { from { opacity: 0; } to { opacity: 1; } }
|
||||
@container journey-card (max-width: 380px) {
|
||||
.journey-trip { display: block; }
|
||||
.journey-trip time { margin-top: 7px; }
|
||||
.journey-metro { --station-width: 140px; }
|
||||
.service-info .journey-station[data-focused=true] .destination-heading { font-size: calc(clamp(34px, 11cqw, 46px) * var(--reading-scale, 1)); }
|
||||
.journey-station { padding-right: 22px; }
|
||||
.service-info .journey-name-focus .destination-heading { font-size: calc(clamp(34px, 11cqw, 46px) * var(--reading-scale, 1)); }
|
||||
.journey-name-layer { padding-right: 22px; }
|
||||
.journey-tour-heading { font-size: 17px; }
|
||||
.journey-caption { font-size: 14px; gap: 4px 10px; }
|
||||
}
|
||||
@@ -167,10 +168,9 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.journey-metro { padding-top: 12px; }
|
||||
.journey-map-space { margin-top: 10px; }
|
||||
.journey-station { grid-template-rows: auto minmax(0, 1fr) 32px; gap: 6px; }
|
||||
.journey-station-name { padding-bottom: 4px; }
|
||||
.journey-duration { font-size: 22px; }
|
||||
.journey-station[data-focused=true] .journey-duration { font-size: 26px; }
|
||||
.journey-station::after { bottom: 14px; height: 4px; }
|
||||
.journey-name-layer { padding-bottom: 4px; }
|
||||
.journey-duration { font-size: 26px; }
|
||||
.journey-line { bottom: 14px; height: 4px; }
|
||||
.journey-node { width: 22px; height: 22px; margin-left: -11px; border-width: 3px; }
|
||||
.journey-continuation { padding-top: 26px; padding-bottom: 32px; }
|
||||
.journey-continuation-line { bottom: 14px; border-top-width: 4px; }
|
||||
@@ -189,8 +189,8 @@ h1 { font-size: clamp(30px, 3.8vw, 76px); letter-spacing: -.05em; line-height: 1
|
||||
.journey-route { display: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.journey-rail, .service-info .journey-station .destination-heading, .journey-station, .journey-station-name, .journey-duration, .journey-station::after, .journey-node { transition: none; }
|
||||
.journey-metro .destination-current, .journey-metro[data-loop=true] .journey-rail { animation: none; }
|
||||
.journey-station, .journey-continuation, .journey-line, .journey-name-layer, .journey-duration, .service-info .journey-station .destination-heading { transition: none !important; will-change: auto !important; }
|
||||
.journey-metro .destination-current, .journey-metro[data-loop=true] .journey-rail-window { animation: none; }
|
||||
}
|
||||
.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; }
|
||||
|
||||
38
test/fixtures/journey-perf.js
vendored
Normal file
38
test/fixtures/journey-perf.js
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
// Test-only observers: no prototype patches, production telemetry or state changes.
|
||||
// Logs layout work over 1200 ms after each automatic stop change, not target FPS.
|
||||
let focus = null, run = null;
|
||||
const boxes = new Set();
|
||||
const resize = new ResizeObserver(entries => {
|
||||
if (run) run.nameResizes += entries.filter(entry => boxes.has(entry.target)).length;
|
||||
});
|
||||
const mutations = new MutationObserver(records => {
|
||||
const cells = Array.from(document.querySelectorAll('.service-info[data-active="true"] .journey-station'));
|
||||
const next = cells.findIndex(cell => cell.getAttribute('data-focused') === 'true');
|
||||
if (next < 0) { focus = null; return; }
|
||||
if (focus !== null && focus !== next) {
|
||||
if (run) { clearTimeout(run.timer); finish(); }
|
||||
run = { from: focus + 1, to: next + 1, profile: document.querySelector('.board')?.getAttribute('data-profile'),
|
||||
nameResizes: 0, fontWrites: 0, headingReplacements: 0, started: performance.now(), timer: null };
|
||||
run.timer = setTimeout(finish, 1200);
|
||||
}
|
||||
focus = next;
|
||||
for (const box of document.querySelectorAll('.service-info[data-active="true"] .journey-station-name')) {
|
||||
if (!boxes.has(box)) { boxes.add(box); resize.observe(box); }
|
||||
}
|
||||
for (const box of boxes) if (!document.contains(box)) { resize.unobserve(box); boxes.delete(box); }
|
||||
if (!run) return;
|
||||
for (const record of records) {
|
||||
if (record.type === 'attributes' && record.attributeName === 'style'
|
||||
&& record.target.matches('.journey-station-name .destination-heading')) run.fontWrites++;
|
||||
if (record.type === 'childList' && record.target.matches('.journey-station-name')) {
|
||||
run.headingReplacements += Array.from(record.removedNodes).filter(node => node.nodeType === 1 && node.matches('.destination-heading')).length;
|
||||
}
|
||||
}
|
||||
});
|
||||
function finish() {
|
||||
if (!run) return;
|
||||
const { timer, started, ...result } = run;
|
||||
run = null;
|
||||
console.info('JOURNEY_PERF ' + JSON.stringify({ ...result, windowMs: Math.round(performance.now() - started) }));
|
||||
}
|
||||
mutations.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-focused', 'style'] });
|
||||
8
test/fixtures/night-server.mjs
vendored
8
test/fixtures/night-server.mjs
vendored
@@ -30,5 +30,11 @@ app.use(createApp(client,{weather:async(cityId,stopId)=>({cityId,stopId,updatedA
|
||||
app.use('/__fixture',express.urlencoded({extended:false}));
|
||||
app.post('/__fixture',(req,res)=>{if(scenarios[req.body.scenario]){scenario=req.body.scenario;started=Date.now();}if(req.body.alerts!==undefined)alertsOn=req.body.alerts==='true';if(req.body.expired!==undefined)expired=req.body.expired==='true';res.redirect(303,'/__fixture');});
|
||||
app.get('/__fixture',(_req,res)=>res.type('html').send(`<html lang="ru"><body style="font:20px system-ui;padding:32px"><h1>Тестовые сценарии ночного режима</h1><p>Текущий: ${scenario}; уведомление: ${alertsOn}; истёкший кеш: ${expired}</p><form method="post">${Object.keys(scenarios).map(s=>`<button style="padding:14px;margin:6px" name="scenario" value="${s}">${s}</button>`).join('')}<button name="alerts" value="true">Показать отмену</button><button name="alerts" value="false">Убрать отмену</button><button name="expired" value="true">Истёкший кеш</button><button name="expired" value="false">Свежий кеш</button></form><p>Часы приложения обновятся при опросе (5 секунд). После смены полного расписания обновите страницу табло.</p></body></html>`));
|
||||
const dist=fileURLToPath(new URL('../../dist/',import.meta.url));app.use(express.static(dist));app.get('/{*path}',(_req,res)=>res.sendFile(`${dist}/index.html`));
|
||||
// Optional local-only counters compare the saved and current production builds.
|
||||
const perf=process.env.JOURNEY_PERF==='1';
|
||||
const dist=process.env.BOARD_FIXTURE_DIST || fileURLToPath(new URL('../../dist/',import.meta.url));
|
||||
if(perf)app.get('/__journey-perf.js',(_req,res)=>res.type('js').send(readFileSync(fileURLToPath(new URL('./journey-perf.js',import.meta.url)),'utf8')));
|
||||
app.use(express.static(dist,{index:!perf}));app.get('/{*path}',(_req,res)=>perf
|
||||
? res.type('html').send(readFileSync(`${dist}/index.html`,'utf8').replace('</head>','<script defer src="/__journey-perf.js"></script></head>'))
|
||||
: res.sendFile(`${dist}/index.html`));
|
||||
await new Promise(resolve=>app.listen(5193,'127.0.0.1',resolve));console.log('Night fixture http://127.0.0.1:5193/ ; controls /__fixture');
|
||||
|
||||
Reference in New Issue
Block a user