fix(gpx): устранить падение статистики на больших треках, учесть все треки файла
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 5s
CI / lint (pull_request) Successful in 3s
CI / test (pull_request) Successful in 5s
CI / build (push) Successful in 3s
CI / build (pull_request) Successful in 2s

Правки по код-ревью ET-006 (docs/work-items/ET-006/12-review.md):

- P1-1: trackStats считал min/max высот через Math.min/max.apply — на
  треках в сотни тысяч точек это бросало RangeError и валило загрузку
  файла (нарушение REQ-NF-01). Расчёт переписан на однопроходный
  аккумулятор (makeStatsAccumulator/accumulatePoint/finalizeStats)
  без apply.
- P2-1: статистика и профиль высот учитывали только tracks[0].
  Добавлены aggregateStats() и buildFileProfileSamples() — сводка и
  профиль теперь охватывают все треки файла (REQ-F-09, AC-02).
- P2-2: расчёт статистики на async-пути парсинга вынесен в чанковый
  trackStatsChunked() — соответствие букве ADR-003 §2.
- P3-1: ось и tooltip профиля высот форматируют расстояние через
  formatKm() — согласование с выбором км/мили из ET-005.
- P3-2: childText() переименована в firstTagText() — имя соответствует
  фактическому поведению (поиск по всем потомкам).
- P3-4: убран дублирующийся 'use strict'.

Добавлены регрессионные unit-тесты: большой трек без падения,
эквивалентность trackStatsChunked синхронному trackStats (в т.ч. на
треке длиннее размера чанка), агрегация статистики и профиля по
многотрековому файлу.

Refs: ET-006

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 06:01:51 +00:00
parent 19354ed905
commit 25e4476cf7
2 changed files with 271 additions and 62 deletions

View File

@@ -1,5 +1,3 @@
'use strict';
/**
* gpx.js — ET-006: загрузка и визуализация GPX-треков.
*
@@ -15,8 +13,9 @@
* `removeGpxTrack`), хелпер `showToast()` и хук `rebuildGpxOverlays()`
* (вызывается из `rebuildMapOverlays()` app.js — REQ-F-13).
*
* Парсинг — `DOMParser` в основном потоке, конвертация DOM → модель
* выполняется чанками с отдачей управления event loop (ADR-003).
* Парсинг — `DOMParser` в основном потоке; конвертация DOM → модель и
* расчёт статистики выполняются чанками с отдачей управления event loop
* (ADR-003).
*
* Для unit-тестов модуль дополнительно экспортируется через
* `module.exports` (среда Node) — публикуются чистые функции и парсер.
@@ -90,57 +89,141 @@
}
/**
* Считает статистику трека по массиву точек [lon, lat, ele, time].
* Создаёт аккумулятор статистики трека для однопроходного расчёта.
*
* Однопроходный обход (вместо `Math.min/max.apply` по массиву высот)
* обязателен для больших треков: `Function.prototype.apply` на сотнях
* тысяч аргументов бросает `RangeError: Maximum call stack size
* exceeded` и валит загрузку файла (ревью P1-1, REQ-NF-01).
*
* @returns {{distanceKm:number, elevGain:number, elevLoss:number,
* eleMin:number, eleMax:number, hasEle:boolean, ref:?number}}
*/
function makeStatsAccumulator() {
return {
distanceKm: 0, elevGain: 0, elevLoss: 0,
eleMin: Infinity, eleMax: -Infinity, hasEle: false, ref: null,
};
}
/**
* Учитывает точку `points[i]` в аккумуляторе: длина (Haversine от
* предыдущей точки), мин/макс высот и набор/сброс с фильтрацией шума
* < 2 м — мелкие колебания не сдвигают опорную высоту (TRZ §5.2,
* тест U-12).
* @param {object} acc - аккумулятор `makeStatsAccumulator()`.
* @param {Array<number[]>} points - точки трека.
* @param {number} i - индекс обрабатываемой точки.
*/
function accumulatePoint(acc, points, i) {
if (i > 0) acc.distanceKm += haversineKm(points[i - 1], points[i]);
var e = points[i][2];
if (e === null || e === undefined || isNaN(e)) return;
acc.hasEle = true;
if (e < acc.eleMin) acc.eleMin = e;
if (e > acc.eleMax) acc.eleMax = e;
if (acc.ref === null) { acc.ref = e; return; }
var d = e - acc.ref;
if (Math.abs(d) >= ELE_NOISE_M) {
if (d > 0) acc.elevGain += d; else acc.elevLoss += -d;
acc.ref = e;
}
}
/**
* Сворачивает аккумулятор в итоговый объект статистики. При отсутствии
* данных высот поля высот — `null` (TRZ REQ-F-11; тесты U-05, U-14).
* @param {object} acc
* @returns {{distanceKm:number, elevGain:?number, elevLoss:?number,
* eleMin:?number, eleMax:?number}}
*/
function finalizeStats(acc) {
if (!acc.hasEle) {
return {
distanceKm: acc.distanceKm, elevGain: null, elevLoss: null,
eleMin: null, eleMax: null,
};
}
return {
distanceKm: acc.distanceKm,
elevGain: Math.round(acc.elevGain),
elevLoss: Math.round(acc.elevLoss),
eleMin: Math.round(acc.eleMin),
eleMax: Math.round(acc.eleMax),
};
}
/**
* Считает статистику трека по массиву точек [lon, lat, ele, time]
* однопроходно (синхронно).
*
* Длина — сумма Haversine-сегментов. Набор/сброс высот — сумма дельт
* `ele` с фильтрацией шума: колебания < 2 м не сбрасывают опорную высоту
* (TRZ §5.2). При отсутствии данных высот поля высот возвращают `null`
* (TRZ REQ-F-11; тесты U-05, U-14).
* `ele` с фильтрацией шума < 2 м (TRZ §5.2). При отсутствии данных
* высот поля высот возвращают `null` (TRZ REQ-F-11; тесты U-05, U-14).
*
* @param {Array<number[]>} points - точки трека.
* @returns {{distanceKm:number, elevGain:?number, elevLoss:?number,
* eleMin:?number, eleMax:?number}}
*/
function trackStats(points) {
var distanceKm = 0;
var i;
for (i = 1; i < points.length; i++) {
distanceKm += haversineKm(points[i - 1], points[i]);
}
var acc = makeStatsAccumulator();
for (var i = 0; i < points.length; i++) accumulatePoint(acc, points, i);
return finalizeStats(acc);
}
var eles = [];
for (i = 0; i < points.length; i++) {
var e = points[i][2];
if (e !== null && e !== undefined && !isNaN(e)) eles.push(e);
}
if (eles.length === 0) {
return {
distanceKm: distanceKm, elevGain: null, elevLoss: null,
eleMin: null, eleMax: null,
};
}
var elevGain = 0;
var elevLoss = 0;
var ref = null;
for (i = 0; i < eles.length; i++) {
if (ref === null) { ref = eles[i]; continue; }
var d = eles[i] - ref;
// Шум < 2 м не сдвигает опорную высоту: мелкие колебания вокруг
// одного уровня не накапливаются в набор/сброс (тест U-12).
if (Math.abs(d) >= ELE_NOISE_M) {
if (d > 0) elevGain += d; else elevLoss += -d;
ref = eles[i];
/**
* Считает статистику трека чанками, отдавая управление event loop между
* порциями. Реализует ADR-003 §2 («расчёт статистики — чанками»);
* применяется на пути асинхронного парсинга больших файлов (ревью P2-2).
*
* @param {Array<number[]>} points - точки трека.
* @returns {Promise<{distanceKm:number, elevGain:?number,
* elevLoss:?number, eleMin:?number, eleMax:?number}>}
*/
function trackStatsChunked(points) {
return new Promise(function (resolve) {
var acc = makeStatsAccumulator();
var i = 0;
function run() {
var end = Math.min(i + CHUNK_SIZE, points.length);
for (; i < end; i++) accumulatePoint(acc, points, i);
if (i < points.length) { setTimeout(run, 0); return; }
resolve(finalizeStats(acc));
}
}
run();
});
}
/**
* Агрегирует статистику всех треков файла в одну сводку.
*
* Активная сущность панели — файл (TRZ REQ-F-09, AC-02), но файл может
* содержать несколько `<trk>` (REQ-F-02). Длина и набор/сброс — суммы
* по трекам; мин/макс — экстремумы по трекам. Набор/сброс считаются в
* каждом треке отдельно и суммируются (а не по сквозному потоку точек):
* так скачок высоты на стыке треков не даёт ложный набор/сброс. Если
* ни у одного трека нет высот — поля высот `null` (ревью P2-1).
*
* @param {Array<{stats:object}>} tracks - треки файла.
* @returns {{distanceKm:number, elevGain:?number, elevLoss:?number,
* eleMin:?number, eleMax:?number}}
*/
function aggregateStats(tracks) {
var distanceKm = 0;
var elevGain = null, elevLoss = null, eleMin = null, eleMax = null;
tracks.forEach(function (t) {
var s = t.stats;
if (!s) return;
distanceKm += s.distanceKm || 0;
if (s.elevGain === null || s.elevGain === undefined) return;
elevGain = (elevGain === null ? 0 : elevGain) + s.elevGain;
elevLoss = (elevLoss === null ? 0 : elevLoss) + s.elevLoss;
eleMin = (eleMin === null) ? s.eleMin : Math.min(eleMin, s.eleMin);
eleMax = (eleMax === null) ? s.eleMax : Math.max(eleMax, s.eleMax);
});
return {
distanceKm: distanceKm,
elevGain: Math.round(elevGain),
elevLoss: Math.round(elevLoss),
eleMin: Math.round(Math.min.apply(null, eles)),
eleMax: Math.round(Math.max.apply(null, eles)),
distanceKm: distanceKm, elevGain: elevGain, elevLoss: elevLoss,
eleMin: eleMin, eleMax: eleMax,
};
}
@@ -233,12 +316,14 @@
}
/**
* Возвращает текст первого дочернего элемента с указанным тегом.
* Возвращает текст первого элемента-потомка с указанным тегом.
* Поиск ведётся по всем потомкам (`getElementsByTagName`), не только
* по прямым детям — для структуры GPX это безопасно.
* @param {Element} parent
* @param {string} tag
* @returns {string}
*/
function childText(parent, tag) {
function firstTagText(parent, tag) {
var els = parent.getElementsByTagName(tag);
return els.length ? String(els[0].textContent || '').trim() : '';
}
@@ -264,7 +349,7 @@
* @returns {{lon:number, lat:number, name:?string, ele:?number}}
*/
function waypointFromEl(el) {
var name = childText(el, 'name');
var name = firstTagText(el, 'name');
var eleEls = el.getElementsByTagName('ele');
var ele = eleEls.length ? parseFloat(eleEls[0].textContent) : NaN;
return {
@@ -304,13 +389,13 @@
var tps = segs[j].getElementsByTagName('trkpt');
for (k = 0; k < tps.length; k++) ptEls.push(tps[k]);
}
result.push({ name: childText(trk, 'name') || ('Трек ' + (i + 1)), ptEls: ptEls });
result.push({ name: firstTagText(trk, 'name') || ('Трек ' + (i + 1)), ptEls: ptEls });
}
var rtes = doc.getElementsByTagName('rte');
for (i = 0; i < rtes.length; i++) {
var rte = rtes[i];
var rps = toArray(rte.getElementsByTagName('rtept'));
result.push({ name: childText(rte, 'name') || ('Маршрут ' + (i + 1)), ptEls: rps });
result.push({ name: firstTagText(rte, 'name') || ('Маршрут ' + (i + 1)), ptEls: rps });
}
return result;
}
@@ -375,8 +460,11 @@
}
return mapChunked(raw[idx].ptEls, pointFromEl).then(function (pts) {
var points = pts.filter(isValidPoint);
tracks.push({ name: raw[idx].name, points: points, stats: trackStats(points) });
return nextTrack(idx + 1);
// Расчёт статистики — тоже чанками (ADR-003 §2; ревью P2-2).
return trackStatsChunked(points).then(function (stats) {
tracks.push({ name: raw[idx].name, points: points, stats: stats });
return nextTrack(idx + 1);
});
});
}
@@ -880,14 +968,15 @@
}
/**
* Отрисовывает компактную сетку статистики активного трека
* (TRZ REQ-F-11; AC-08).
* Отрисовывает компактную сетку статистики активного файла —
* сводно по всем его трекам (TRZ REQ-F-11; AC-08; ревью P2-1).
* @param {object} file
*/
function renderStats(file) {
var el = document.getElementById('gpx-stats');
if (!el) return;
var st = file.tracks.length ? file.tracks[0].stats : null;
// Сводка по всем трекам файла, а не только tracks[0] (ревью P2-1).
var st = file.tracks.length ? aggregateStats(file.tracks) : null;
var hasEle = st && st.elevGain !== null;
var cells = [
@@ -908,8 +997,9 @@
// ─── Профиль высот (canvas) ──────────────────────────────────────────────
/**
* Отрисовывает canvas-профиль высот активного трека и подключает
* интерактивность (tooltip + маркер-курсор на карте) — TRZ REQ-F-10.
* Отрисовывает canvas-профиль высот активного файла (по всем его
* трекам) и подключает интерактивность (tooltip + маркер-курсор на
* карте) — TRZ REQ-F-10; ревью P2-1.
* @param {object} file
*/
function renderElevationProfile(file) {
@@ -918,8 +1008,7 @@
var axisEl = document.getElementById('gpx-elevation-axis');
if (!canvas) return;
var track = file.tracks.length ? file.tracks[0] : null;
var samples = track ? buildProfileSamples(track) : [];
var samples = buildFileProfileSamples(file);
if (samples.length < 2) {
canvas.style.display = 'none';
@@ -984,9 +1073,11 @@
if (axisEl) {
var spans = axisEl.getElementsByTagName('span');
if (spans.length >= 3) {
spans[0].textContent = '0 км';
spans[1].textContent = (totalKm / 2).toFixed(1) + ' км';
spans[2].textContent = totalKm.toFixed(1) + ' км';
// Единицы — через formatKm, чтобы ось согласовалась со
// статистикой при выборе миль в ET-005 (ревью P3-1).
spans[0].textContent = formatKm(0);
spans[1].textContent = formatKm(totalKm / 2);
spans[2].textContent = formatKm(totalKm);
}
}
@@ -1017,6 +1108,26 @@
return samples;
}
/**
* Строит точки профиля высот для всего файла: треки склеиваются
* последовательно, расстояние `d` — нарастающим итогом от старта
* первого трека. Покрывает многотрековые файлы — профиль показывает
* все треки файла, а не только tracks[0] (ревью P2-1).
* @param {object} file - элемент модели window.gpxTracks.
* @returns {Array<{d:number, e:number, lon:number, lat:number}>}
*/
function buildFileProfileSamples(file) {
var samples = [];
var offset = 0;
file.tracks.forEach(function (track) {
buildProfileSamples(track).forEach(function (s) {
samples.push({ d: s.d + offset, e: s.e, lon: s.lon, lat: s.lat });
});
offset += (track.stats && track.stats.distanceKm) || 0;
});
return samples;
}
/**
* Навешивает обработчики наведения/тапа на canvas профиля: tooltip с
* высотой и расстоянием + маркер-курсор на карте (TRZ REQ-F-10; AC-07).
@@ -1040,7 +1151,8 @@
var tip = document.getElementById('gpx-elevation-tip');
if (tip) {
tip.textContent = Math.round(nearest.e) + ' м · ' + nearest.d.toFixed(1) + ' км';
// Расстояние — через formatKm (учёт км/мили из ET-005, ревью P3-1).
tip.textContent = Math.round(nearest.e) + ' м · ' + formatKm(nearest.d);
tip.style.display = 'block';
tip.style.left = Math.max(0, Math.min(profileState.cssW - 90, x - 45)) + 'px';
}
@@ -1098,10 +1210,13 @@
MAX_FILE_BYTES: MAX_FILE_BYTES,
haversineKm: haversineKm,
trackStats: trackStats,
trackStatsChunked: trackStatsChunked,
aggregateStats: aggregateStats,
colorForIndex: colorForIndex,
tracksToGeoJSON: tracksToGeoJSON,
waypointsToGeoJSON: waypointsToGeoJSON,
fileBounds: fileBounds,
buildFileProfileSamples: buildFileProfileSamples,
extractGpxModel: extractGpxModel,
parseGpxText: parseGpxText,
parseGpxAsync: parseGpxAsync,