fix(gpx): устранить падение статистики на больших треках, учесть все треки файла
All checks were successful
All checks were successful
Правки по код-ревью 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:
237
src/web/gpx.js
237
src/web/gpx.js
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user