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:
2026-10-11 15:13:05 +03:00
parent a1bb3f1c9d
commit 823ab14367
7 changed files with 167 additions and 43 deletions

View File

@@ -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>&lt;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>&lt;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>
</>}

View File

@@ -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; }