From 823ab14367d35cef9a4fc87d220c8a434f05f464 Mon Sep 17 00:00:00 2001 From: Zakhar Timoshenko Date: Sun, 11 Oct 2026 15:13:05 +0300 Subject: [PATCH] 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. --- README.md | 4 ++ artifacts/journey-motion-960x540.jpg | Bin 0 -> 46440 bytes artifacts/verification-journey-motion.md | 37 +++++++++++++ src/NextStops.tsx | 63 ++++++++++++++++++----- src/styles.css | 60 ++++++++++----------- test/fixtures/journey-perf.js | 38 ++++++++++++++ test/fixtures/night-server.mjs | 8 ++- 7 files changed, 167 insertions(+), 43 deletions(-) create mode 100644 artifacts/journey-motion-960x540.jpg create mode 100644 artifacts/verification-journey-motion.md create mode 100644 test/fixtures/journey-perf.js diff --git a/README.md b/README.md index 80d48fc..589a3ec 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/artifacts/journey-motion-960x540.jpg b/artifacts/journey-motion-960x540.jpg new file mode 100644 index 0000000000000000000000000000000000000000..063402a17d7848460221306c1d3a790d78f15f55 GIT binary patch literal 46440 zcmeFY2T)W^w>COR5=kN%hM<7NA?F|>83aT!LsCE(hCF~oK@bEa4msyE77`Ks=@b-%xE{Z)V6I(urmpP63Wy>{>3y?d?o?AzJfRlq|vB~>K= z78U@2b@u_>E&${KcsRItxHx!txOn*Zc=sPXeDHvP-~j~*DbYh}3K|+}3Mwi(1~w)- zdKMrR6*CVr%TsnvE>2n|5I=~6pN)f)Z&4C}TV!0?xvxLAMb`}cu;?@nF(`&a~b#byrySlHND*m$@Paqr{f zlk5~PP8RN9UB)xqy!#8xWovSw#3~}7ye5|aW(3!C`RdUo-mp_DvQ|q4V{2^A zlAXz!J|?O89cc2wqGg2{`Kw@NY7H!3v?q};?}eb%&~A0}Y1k)`(!hyM26e%SMJn7P zMb;g>iX(rr3se>b(y}3Z)Ll*FeB^690psb}$8AZm#D3m&+Kc-r+s)k4u-b1YMuZ;8 zYcbB|u^nfnE0zbGOvQP(fT*C8(l*jT{qpym`t%A0-q&FHgNHhVK2_rsPo0;>9mU@i z*%11l>Ff<_7o6QRf6-b->~LBkm4$MHJMNwEWVhrM5o2%3b-T?xHEwuq?nsrUS8%Mm zu;nfTUOY!~SV5TUS~K9TqySCtL?_*2+KSW78uehk@&w^(;bsHvAvQM$pel%wN_rK@ zds1Fc?Emyn(H9wqRE@OhwXtUJiLq_E@DNFewTxmP2_Q2f`q8A!%ikp!Xe9^PtO)K% z?zXBaQHanhj|_?k$OrjmXL%ETPdDGS-czxs*LfzBdRm1J`=#Lt`&I?hWPLR%{pTaJ zJ78QJ=GMH4j;oBKmys5%p@>zV>Mh$3THRP?SsT5b#9s`KGaTwf>JFQ02;M+_$!HU^ zoUj=H5`B!!l%HAYSmMj2N~}sz-c3nTMjwZ^7BR+w+vyg!0FLq6C~3J9=A(?vPLI~* zIU6JxW5?_0Wg`kDnPn-mWr9Yvq8XqC!x)cE5-t|;hSw^GOXXc$v*t%`Xj#cql8;-W zn07spE)cv+e{gR0N$QgUG3Vz4lB8tFb<^!btuU{V;qQfw`P0rzP$!*f9onGnTxa?u z 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 D>PgBy literal 0 HcmV?d00001 diff --git a/artifacts/verification-journey-motion.md b/artifacts/verification-journey-motion.md new file mode 100644 index 0000000..97f88ef --- /dev/null +++ b/artifacts/verification-journey-motion.md @@ -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`. Для обновления серверного табло необходимо развернуть файлы и пересобрать образ. diff --git a/src/NextStops.tsx b/src/NextStops.tsx index ba2a79b..5d3986e 100644 --- a/src/NextStops.tsx +++ b/src/NextStops.tsx @@ -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(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('.journey-station'); + const heading = cell?.querySelector('.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
0 ? geometry.slot / geometry.focused : .4 } as CSSProperties}> +
; +}, (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 ?

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

: <>
{!journey ?

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

: !count ?

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

: <> -
0} data-loop={page > 0 && focus === 0}> +
0 && focus === 0}>
{settings.demo ? 'Демонстрация поездки' : 'После посадки'}{focus + 1}-я остановка
-
    - {journey.stops.map((stop, index) =>
  1. - - {stop.minutes === null ? '—' : stop.minutes === 0 ? <><1мин : durationParts(stop.minutes).map(part => {part.unit})} - -
    -
  2. )} - {journey.more > 0 &&
  3. Ещё {journey.more} остановок
  4. } -
+
Время пути по расписаниюПосле посадки · по расписанию{pages > 1 && {focus + 1} / {count}}
} diff --git a/src/styles.css b/src/styles.css index f2a6238..5a6d1ec 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/test/fixtures/journey-perf.js b/test/fixtures/journey-perf.js new file mode 100644 index 0000000..78e10ee --- /dev/null +++ b/test/fixtures/journey-perf.js @@ -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'] }); diff --git a/test/fixtures/night-server.mjs b/test/fixtures/night-server.mjs index 61cd9ab..010bd11 100644 --- a/test/fixtures/night-server.mjs +++ b/test/fixtures/night-server.mjs @@ -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(`

Тестовые сценарии ночного режима

Текущий: ${scenario}; уведомление: ${alertsOn}; истёкший кеш: ${expired}

${Object.keys(scenarios).map(s=>``).join('')}

Часы приложения обновятся при опросе (5 секунд). После смены полного расписания обновите страницу табло.

`)); -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('','')) + : 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');