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

38
test/fixtures/journey-perf.js vendored Normal file
View 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'] });

View File

@@ -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');