/** * gpx.js — ET-006: загрузка и визуализация GPX-треков. * * Самодостаточный модуль фичи GPX: парсинг GPX 1.1, внутренняя модель * `window.gpxTracks`, управление слоями/маркерами карты, bottom sheet * `#sheet-gpx`, статистика трека и canvas-профиль высот. * * Подключается в index.html как классический скрипт СТРОГО после app.js * (ADR-002): модуль потребляет глобали app.js — `window._map`, * `openSheet()`, `closeSheet()`, `minimizeSheet()` — в момент события, * когда они уже определены. Публикует обработчики inline-`onclick` * (`onGpxFileSelected`, `toggleGpxSheet`, `selectGpxTrack`, * `removeGpxTrack`), хелпер `showToast()` и хук `rebuildGpxOverlays()` * (вызывается из `rebuildMapOverlays()` app.js — REQ-F-13). * * Парсинг — `DOMParser` в основном потоке; конвертация DOM → модель и * расчёт статистики выполняются чанками с отдачей управления event loop * (ADR-003). * * Для unit-тестов модуль дополнительно экспортируется через * `module.exports` (среда Node) — публикуются чистые функции и парсер. * * См. docs/work-items/ET-006/06-adr/ADR-002, ADR-003. */ (function (global) { 'use strict'; // ─── Константы ─────────────────────────────────────────────────────────── /** * Палитра треков — 8 цветов, назначаются циклически (TRZ REQ-F-04 §5.3). * Намеренно отличается от цветов роутинга (синий/зелёный/оранжевый). * @type {string[]} */ var PALETTE = [ '#e6194b', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#42d4f4', '#f032e6', ]; /** Максимальный размер GPX-файла — 50 МБ (TRZ REQ-F-03). */ var MAX_FILE_BYTES = 50 * 1024 * 1024; /** Порог фильтрации GPS-шума высот: дельты < 2 м игнорируются (TRZ §5.2). */ var ELE_NOISE_M = 2; /** Размер чанка конвертации точек DOM → модель (ADR-003). */ var CHUNK_SIZE = 8000; /** Высота canvas профиля высот, px (TRZ REQ-F-10). */ var PROFILE_HEIGHT = 120; /** id слоёв OSRM-маршрута, под которые вставляются GPX-слои (TRZ REQ-F-04). */ var ROUTE_BASE_LAYERS = ['route-line-0-outline', 'route-line-0']; // ─── Состояние ─────────────────────────────────────────────────────────── // Каноническая модель загруженных файлов (TRZ §4). Живёт на window, чтобы // переживать map.setStyle() — пересоздаются лишь объекты карты (REQ-F-13). if (!global.gpxTracks) global.gpxTracks = []; /** id активного (выбранного) файла — для статистики и профиля высот. */ var activeGpxId = null; /** Обработчики событий карты по id файла — чтобы корректно их снимать. */ var mapHandlers = {}; /** Маркер-курсор на карте при наведении на профиль высот. */ var cursorMarker = null; /** Состояние отрисованного профиля высот — для интерактивности canvas. */ var profileState = null; // ─── Чистые функции: гео и статистика ──────────────────────────────────── /** * Расстояние между двумя точками по формуле Haversine. * @param {number[]} a - точка [lon, lat, ...]. * @param {number[]} b - точка [lon, lat, ...]. * @returns {number} расстояние в километрах. */ function haversineKm(a, b) { var R = 6371; var dLat = (b[1] - a[1]) * Math.PI / 180; var dLon = (b[0] - a[0]) * Math.PI / 180; var s = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(a[1] * Math.PI / 180) * Math.cos(b[1] * Math.PI / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)); } /** * Создаёт аккумулятор статистики трека для однопроходного расчёта. * * Однопроходный обход (вместо `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} 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). * * @param {Array} points - точки трека. * @returns {{distanceKm:number, elevGain:?number, elevLoss:?number, * eleMin:?number, eleMax:?number}} */ function trackStats(points) { var acc = makeStatsAccumulator(); for (var i = 0; i < points.length; i++) accumulatePoint(acc, points, i); return finalizeStats(acc); } /** * Считает статистику трека чанками, отдавая управление event loop между * порциями. Реализует ADR-003 §2 («расчёт статистики — чанками»); * применяется на пути асинхронного парсинга больших файлов (ревью P2-2). * * @param {Array} 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), но файл может * содержать несколько `` (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: elevGain, elevLoss: elevLoss, eleMin: eleMin, eleMax: eleMax, }; } /** * Возвращает цвет палитры для файла с указанным индексом (циклически). * @param {number} index - порядковый индекс файла на момент добавления. * @returns {string} hex-цвет (TRZ §5.3; тесты U-20, U-21). */ function colorForIndex(index) { return PALETTE[((index % PALETTE.length) + PALETTE.length) % PALETTE.length]; } /** * Строит GeoJSON FeatureCollection из треков файла (по линии на трек). * @param {Array<{points:Array}>} tracks - треки файла. * @returns {object} GeoJSON FeatureCollection с LineString-фичами. */ function tracksToGeoJSON(tracks) { return { type: 'FeatureCollection', features: tracks.map(function (t, idx) { return { type: 'Feature', properties: { trackIndex: idx }, geometry: { type: 'LineString', coordinates: t.points.map(function (p) { return [p[0], p[1]]; }), }, }; }), }; } /** * Строит GeoJSON FeatureCollection из waypoints файла. * @param {Array<{lon:number, lat:number, name:?string}>} waypoints * @returns {object} GeoJSON FeatureCollection с Point-фичами. */ function waypointsToGeoJSON(waypoints) { return { type: 'FeatureCollection', features: waypoints.map(function (w) { return { type: 'Feature', properties: { name: w.name || '' }, geometry: { type: 'Point', coordinates: [w.lon, w.lat] }, }; }), }; } /** * Считает bbox всех точек файла (треки + waypoints). * @param {object} file - элемент модели window.gpxTracks. * @returns {?Array} [[minLon,minLat],[maxLon,maxLat]] или null. */ function fileBounds(file) { var minLon = Infinity, minLat = Infinity; var maxLon = -Infinity, maxLat = -Infinity; var seen = false; function extend(lon, lat) { seen = true; if (lon < minLon) minLon = lon; if (lat < minLat) minLat = lat; if (lon > maxLon) maxLon = lon; if (lat > maxLat) maxLat = lat; } file.tracks.forEach(function (t) { t.points.forEach(function (p) { extend(p[0], p[1]); }); }); file.waypoints.forEach(function (w) { extend(w.lon, w.lat); }); return seen ? [[minLon, minLat], [maxLon, maxLat]] : null; } // ─── Парсинг GPX ───────────────────────────────────────────────────────── /** * Преобразует HTMLCollection / NodeList в массив. * @param {*} collection * @returns {Array} */ function toArray(collection) { var arr = []; if (!collection) return arr; for (var i = 0; i < collection.length; i++) arr.push(collection[i]); return arr; } /** * Возвращает текст первого элемента-потомка с указанным тегом. * Поиск ведётся по всем потомкам (`getElementsByTagName`), не только * по прямым детям — для структуры GPX это безопасно. * @param {Element} parent * @param {string} tag * @returns {string} */ function firstTagText(parent, tag) { var els = parent.getElementsByTagName(tag); return els.length ? String(els[0].textContent || '').trim() : ''; } /** * Конвертирует элемент точки (`` / ``) в кортеж модели. * @param {Element} el * @returns {Array} [lon, lat, ele|null, time|null] */ function pointFromEl(el) { var lat = parseFloat(el.getAttribute('lat')); var lon = parseFloat(el.getAttribute('lon')); var eleEls = el.getElementsByTagName('ele'); var ele = eleEls.length ? parseFloat(eleEls[0].textContent) : NaN; var timeEls = el.getElementsByTagName('time'); var time = timeEls.length ? String(timeEls[0].textContent || '').trim() : ''; return [lon, lat, isNaN(ele) ? null : ele, time || null]; } /** * Конвертирует элемент `` в объект waypoint. * @param {Element} el * @returns {{lon:number, lat:number, name:?string, ele:?number}} */ function waypointFromEl(el) { var name = firstTagText(el, 'name'); var eleEls = el.getElementsByTagName('ele'); var ele = eleEls.length ? parseFloat(eleEls[0].textContent) : NaN; return { lon: parseFloat(el.getAttribute('lon')), lat: parseFloat(el.getAttribute('lat')), name: name || null, ele: isNaN(ele) ? null : ele, }; } /** Точка валидна, если координаты — конечные числа. */ function isValidPoint(p) { return isFinite(p[0]) && isFinite(p[1]); } /** Waypoint валиден, если координаты — конечные числа. */ function isValidWaypoint(w) { return isFinite(w.lon) && isFinite(w.lat); } /** * Собирает «сырые» треки документа: `` (со всеми ``) и * `` (трактуется как трек — TRZ REQ-F-02). Возвращает имена и * массивы элементов точек, не конвертируя их (для чанковой обработки). * @param {Document} doc * @returns {Array<{name:string, ptEls:Element[]}>} */ function collectRawTracks(doc) { var result = []; var trks = doc.getElementsByTagName('trk'); var i, j, k; for (i = 0; i < trks.length; i++) { var trk = trks[i]; var ptEls = []; var segs = trk.getElementsByTagName('trkseg'); for (j = 0; j < segs.length; j++) { var tps = segs[j].getElementsByTagName('trkpt'); for (k = 0; k < tps.length; k++) ptEls.push(tps[k]); } 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: firstTagText(rte, 'name') || ('Маршрут ' + (i + 1)), ptEls: rps }); } return result; } /** * Извлекает модель GPX из DOM-документа синхронно. * * Использует не-namespace-aware `getElementsByTagName`, поэтому * корректно обрабатывает и документы с `xmlns`, и без него * (TRZ REQ-F-02, тест U-08). * * @param {Document} doc - распарсенный XML-документ. * @returns {{tracks:Array, waypoints:Array}} */ function extractGpxModel(doc) { var tracks = collectRawTracks(doc).map(function (raw) { var points = raw.ptEls.map(pointFromEl).filter(isValidPoint); return { name: raw.name, points: points, stats: trackStats(points) }; }); var waypoints = toArray(doc.getElementsByTagName('wpt')) .map(waypointFromEl).filter(isValidWaypoint); return { tracks: tracks, waypoints: waypoints }; } /** * Обрабатывает массив порциями, отдавая управление event loop между * чанками (ADR-003) — сохраняет отзывчивость UI на больших файлах. * @param {Array} items * @param {Function} fn - преобразование элемента. * @returns {Promise} */ function mapChunked(items, fn) { return new Promise(function (resolve) { if (items.length === 0) { resolve([]); return; } var out = []; var i = 0; function run() { var end = Math.min(i + CHUNK_SIZE, items.length); for (; i < end; i++) out.push(fn(items[i])); if (i < items.length) setTimeout(run, 0); else resolve(out); } run(); }); } /** * Извлекает модель GPX из DOM-документа чанками (асинхронно). * @param {Document} doc * @returns {Promise<{tracks:Array, waypoints:Array}>} */ function extractGpxModelChunked(doc) { var raw = collectRawTracks(doc); var tracks = []; function nextTrack(idx) { if (idx >= raw.length) { return mapChunked(toArray(doc.getElementsByTagName('wpt')), waypointFromEl) .then(function (wpts) { return { tracks: tracks, waypoints: wpts.filter(isValidWaypoint) }; }); } return mapChunked(raw[idx].ptEls, pointFromEl).then(function (pts) { var points = pts.filter(isValidPoint); // Расчёт статистики — тоже чанками (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); }); }); } return nextTrack(0); } /** * Парсит XML-текст в документ, проверяя корректность и GPX-корень. * @param {string} text * @returns {Document} * @throws {Error} 'PARSE_ERROR' — невалидный XML или не-GPX документ. */ function parseXmlDoc(text) { var doc; try { doc = new DOMParser().parseFromString(text, 'application/xml'); } catch (e) { throw new Error('PARSE_ERROR'); } if (!doc || doc.getElementsByTagName('parsererror').length > 0) { throw new Error('PARSE_ERROR'); } var root = doc.documentElement; if (!root || String(root.tagName || root.nodeName || '').toLowerCase() !== 'gpx') { throw new Error('PARSE_ERROR'); } return doc; } /** * Синхронно парсит GPX-текст во внутреннюю модель. * @param {string} text - содержимое GPX-файла. * @returns {{tracks:Array, waypoints:Array}} * @throws {Error} 'PARSE_ERROR' — невалидный GPX; 'EMPTY' — нет данных. */ function parseGpxText(text) { var model = extractGpxModel(parseXmlDoc(text)); if (model.tracks.length === 0 && model.waypoints.length === 0) { throw new Error('EMPTY'); } return model; } /** * Асинхронно парсит GPX-текст во внутреннюю модель чанками (ADR-003). * * `DOMParser.parseFromString` атомарен и выполняется в основном потоке; * доминирующая по времени конвертация DOM → модель идёт чанками, что * сохраняет отзывчивость UI и анимацию индикатора загрузки. * * @param {string} text - содержимое GPX-файла. * @returns {Promise<{tracks:Array, waypoints:Array}>} */ function parseGpxAsync(text) { return new Promise(function (resolve, reject) { var raf = (typeof requestAnimationFrame !== 'undefined') ? requestAnimationFrame : function (cb) { return setTimeout(cb, 0); }; // Отдаём кадр, чтобы индикатор загрузки успел отрисоваться. raf(function () { var doc; try { doc = parseXmlDoc(text); } catch (e) { reject(e); return; } extractGpxModelChunked(doc).then(function (model) { if (model.tracks.length === 0 && model.waypoints.length === 0) { reject(new Error('EMPTY')); } else { resolve(model); } }).catch(function () { reject(new Error('PARSE_ERROR')); }); }); }); } // ─── Toast-уведомления (TRZ §3.4, ADR-002) ─────────────────────────────── /** * Показывает toast-уведомление вверху экрана (автоскрытие — 4 с). * Переиспользуемый хелпер: не alert/confirm (TRZ REQ-NF-03). * @param {string} message */ function showToast(message) { var el = document.getElementById('app-toast'); if (!el) return; el.textContent = message; el.classList.add('visible'); if (showToast._timer) clearTimeout(showToast._timer); showToast._timer = setTimeout(function () { el.classList.remove('visible'); }, 4000); } /** * Показывает/скрывает индикатор парсинга GPX (TRZ REQ-NF-01, AC-11). * @param {boolean} on */ function setLoading(on) { var el = document.getElementById('gpx-loading'); if (el) el.classList.toggle('visible', !!on); } // ─── Рендеринг на карте ────────────────────────────────────────────────── /** * Возвращает id слоя, ПЕРЕД которым вставлять GPX-слои, чтобы они были * ниже маршрута OSRM, но выше базовых слоёв (TRZ REQ-F-04; тест I-06). * @param {object} map - экземпляр MapLibre. * @returns {(string|undefined)} */ function gpxBeforeId(map) { for (var i = 0; i < ROUTE_BASE_LAYERS.length; i++) { if (map.getLayer(ROUTE_BASE_LAYERS[i])) return ROUTE_BASE_LAYERS[i]; } return undefined; } /** * Снимает обработчики событий карты, навешанные для файла. * @param {object} map * @param {string} fileId */ function clearMapHandlers(map, fileId) { var h = mapHandlers[fileId]; if (!h) return; try { map.off('click', h.layerId, h.click); } catch (e) { /* слой удалён */ } try { map.off('mouseenter', h.layerId, h.enter); } catch (e) { /* слой удалён */ } try { map.off('mouseleave', h.layerId, h.leave); } catch (e) { /* слой удалён */ } delete mapHandlers[fileId]; } /** * Удаляет с карты слои, источники и обработчики файла (TRZ REQ-F-08). * @param {object} file */ function removeFileLayers(file) { var map = global._map; if (!map) return; clearMapHandlers(map, file.id); var layers = [ file.layerId, file.waypointLayerId, file.waypointLayerId + '-label', ]; layers.forEach(function (id) { try { if (map.getLayer(id)) map.removeLayer(id); } catch (e) { /* нет слоя */ } }); [file.sourceId, file.sourceId + '-wpt'].forEach(function (id) { try { if (map.getSource(id)) map.removeSource(id); } catch (e) { /* нет источника */ } }); } /** * Добавляет (или пересоздаёт) слои файла на карте: линия трека, * waypoints-маркеры (circle + symbol). GPX-слои встают ниже маршрута * OSRM (TRZ REQ-F-04, REQ-F-05). * @param {object} file */ function addFileLayers(file) { var map = global._map; if (!map) return; removeFileLayers(file); var before = gpxBeforeId(map); map.addSource(file.sourceId, { type: 'geojson', data: tracksToGeoJSON(file.tracks), }); map.addLayer({ id: file.layerId, type: 'line', source: file.sourceId, layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': file.color, 'line-width': 4, 'line-opacity': 0.85, }, }, before); var onClick = function () { selectGpxTrack(file.id); }; var onEnter = function () { map.getCanvas().style.cursor = 'pointer'; }; var onLeave = function () { map.getCanvas().style.cursor = ''; }; map.on('click', file.layerId, onClick); map.on('mouseenter', file.layerId, onEnter); map.on('mouseleave', file.layerId, onLeave); mapHandlers[file.id] = { layerId: file.layerId, click: onClick, enter: onEnter, leave: onLeave, }; if (file.waypoints.length > 0) { map.addSource(file.sourceId + '-wpt', { type: 'geojson', data: waypointsToGeoJSON(file.waypoints), }); map.addLayer({ id: file.waypointLayerId, type: 'circle', source: file.sourceId + '-wpt', paint: { 'circle-radius': 5, 'circle-color': file.color, 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 2, 'circle-opacity': 0.95, }, }, before); map.addLayer({ id: file.waypointLayerId + '-label', type: 'symbol', source: file.sourceId + '-wpt', layout: { 'text-field': ['get', 'name'], 'text-font': ['Open Sans Regular'], 'text-size': 11, 'text-offset': [0, 1.1], 'text-anchor': 'top', 'text-optional': true, }, paint: { 'text-color': file.color, 'text-halo-color': '#ffffff', 'text-halo-width': 1.5, }, }, before); } } /** * Пересоздаёт все GPX-слои после смены стиля карты (TRZ REQ-F-13). * * Вызывается из `rebuildMapOverlays()` (app.js) по событию `idle` * после `map.setStyle()`. Данные (`window.gpxTracks`, активный трек) * не теряются — пересоздаются только объекты карты. */ function rebuildGpxOverlays() { if (!global._map) return; global.gpxTracks.forEach(addFileLayers); renderGpxList(); } // ─── Загрузка и обработка файлов ───────────────────────────────────────── /** * Обработчик `onchange` для `` (TRZ REQ-F-01). * @param {HTMLInputElement} input */ function onGpxFileSelected(input) { if (input && input.files && input.files.length) { handleGpxFiles(input.files); } if (input) input.value = ''; } /** * Загружает, валидирует и парсит выбранные GPX-файлы (TRZ REQ-F-01..F-07). * * Файлы обрабатываются последовательно; `fitBounds` выполняется только * по последнему успешно загруженному файлу (TRZ REQ-F-06). * * @param {FileList|File[]} fileList */ function handleGpxFiles(fileList) { var files = toArray(fileList); if (files.length === 0) return; var wasEmpty = global.gpxTracks.length === 0; setLoading(true); var lastAdded = null; var idx = 0; function done() { setLoading(false); renderGpxList(); if (wasEmpty && global.gpxTracks.length > 0) { if (typeof openSheet === 'function') openSheet('sheet-gpx'); syncToolbarButton(true); } if (lastAdded) { selectGpxTrack(lastAdded.id); fitToFile(lastAdded); } } function next() { if (idx >= files.length) { done(); return; } var file = files[idx++]; if (file.size > MAX_FILE_BYTES) { showToast('Файл слишком большой (макс. 50 МБ)'); next(); return; } var reader = new FileReader(); reader.onload = function () { parseGpxAsync(String(reader.result || '')).then(function (model) { var added = addParsedFile(file.name, model); lastAdded = added; next(); }).catch(function (err) { var msg = (err && err.message === 'EMPTY') ? 'GPX-файл не содержит данных' : 'Не удалось прочитать GPX-файл'; showToast(msg); next(); }); }; reader.onerror = function () { showToast('Не удалось прочитать GPX-файл'); next(); }; reader.readAsText(file); } next(); } /** * Создаёт элемент модели по распарсенному GPX и отрисовывает его на карте. * @param {string} filename - имя файла (с расширением). * @param {{tracks:Array, waypoints:Array}} model * @returns {object} созданный элемент window.gpxTracks. */ function addParsedFile(filename, model) { var ts = Date.now() + '-' + Math.floor(Math.random() * 1e6); var color = colorForIndex(global.gpxTracks.length); var file = { id: 'gpx-' + ts, filename: stripExtension(filename), color: color, tracks: model.tracks, waypoints: model.waypoints, sourceId: 'gpx-source-' + ts, layerId: 'gpx-layer-' + ts, waypointLayerId: 'gpx-wpt-' + ts, }; global.gpxTracks.push(file); addFileLayers(file); return file; } /** * Убирает расширение `.gpx` из имени файла. * @param {string} name * @returns {string} */ function stripExtension(name) { return String(name || 'track').replace(/\.gpx$/i, ''); } /** * Центрирует карту по bbox файла с отступом 50px (TRZ REQ-F-06). * @param {object} file */ function fitToFile(file) { var map = global._map; if (!map || typeof maplibregl === 'undefined') return; var b = fileBounds(file); if (!b) return; map.fitBounds(new maplibregl.LngLatBounds(b[0], b[1]), { padding: 50, duration: 800, }); } /** * Удаляет загруженный трек по id (TRZ REQ-F-08). * @param {string} id */ function removeGpxTrack(id) { var i = global.gpxTracks.findIndex(function (f) { return f.id === id; }); if (i === -1) return; removeFileLayers(global.gpxTracks[i]); global.gpxTracks.splice(i, 1); // Удаление активного трека скрывает статистику и профиль (REQ-F-08); // соседний трек НЕ выбирается автоматически (AC-05). if (activeGpxId === id) { activeGpxId = null; hideCursorMarker(); } renderGpxList(); } // ─── Bottom sheet: список треков ───────────────────────────────────────── /** * Переключает видимость панели управления GPX-треками (TRZ REQ-F-09). */ function toggleGpxSheet() { var sheet = document.getElementById('sheet-gpx'); if (!sheet) return; if (sheet.classList.contains('open')) { if (typeof closeSheet === 'function') closeSheet('sheet-gpx'); syncToolbarButton(false); } else { renderGpxList(); if (typeof openSheet === 'function') openSheet('sheet-gpx'); syncToolbarButton(true); } } /** * Синхронизирует подсветку кнопки «GPX» в нижнем тулбаре. * @param {boolean} active */ function syncToolbarButton(active) { var btn = document.getElementById('tb-gpx'); if (btn) btn.classList.toggle('active', !!active); } /** * Перерисовывает список загруженных треков и блок деталей (TRZ REQ-F-09). */ function renderGpxList() { var listEl = document.getElementById('gpx-list'); var emptyEl = document.getElementById('gpx-empty'); var detailEl = document.getElementById('gpx-detail'); if (!listEl) return; var tracks = global.gpxTracks; if (emptyEl) emptyEl.style.display = tracks.length ? 'none' : 'block'; listEl.innerHTML = tracks.map(function (f) { var isActive = f.id === activeGpxId; return '
' + '' + '' + escapeHtml(f.filename) + '' + (isActive ? '' : '') + '' + '
'; }).join(''); var active = getActiveFile(); if (detailEl) detailEl.style.display = active ? 'block' : 'none'; if (active) { renderStats(active); renderElevationProfile(active); } else { hideCursorMarker(); } } /** * Делает трек активным: подсветка в списке, статистика, профиль высот * (TRZ REQ-F-09, REQ-F-12; AC-06, AC-09). * @param {string} id */ function selectGpxTrack(id) { if (!global.gpxTracks.some(function (f) { return f.id === id; })) return; activeGpxId = id; renderGpxList(); var sheet = document.getElementById('sheet-gpx'); if (sheet && !sheet.classList.contains('open') && typeof openSheet === 'function') { openSheet('sheet-gpx'); syncToolbarButton(true); } } /** * Возвращает активный файл модели или null. * @returns {?object} */ function getActiveFile() { return global.gpxTracks.find(function (f) { return f.id === activeGpxId; }) || null; } /** * Экранирует HTML-спецсимволы в пользовательской строке. * @param {string} s * @returns {string} */ function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c]; }); } // ─── Статистика трека ──────────────────────────────────────────────────── /** * Форматирует расстояние (км). Если доступен модуль ET-005 `Units` — * делегирует ему (учёт выбора км/мили); иначе — километры. * @param {number} km * @returns {string} */ function formatKm(km) { if (typeof Units !== 'undefined' && Units && typeof Units.formatDistance === 'function') { return Units.formatDistance(km * 1000); } return km.toFixed(1) + ' км'; } /** * Отрисовывает компактную сетку статистики активного файла — * сводно по всем его трекам (TRZ REQ-F-11; AC-08; ревью P2-1). * @param {object} file */ function renderStats(file) { var el = document.getElementById('gpx-stats'); if (!el) return; // Сводка по всем трекам файла, а не только tracks[0] (ревью P2-1). var st = file.tracks.length ? aggregateStats(file.tracks) : null; var hasEle = st && st.elevGain !== null; var cells = [ { v: st ? formatKm(st.distanceKm) : '—', l: 'длина' }, { v: hasEle ? st.elevGain + ' м' : '—', l: 'набор' }, { v: hasEle ? st.elevLoss + ' м' : '—', l: 'сброс' }, { v: hasEle ? st.eleMin + ' м' : '—', l: 'мин' }, { v: hasEle ? st.eleMax + ' м' : '—', l: 'макс' }, ]; el.innerHTML = cells.map(function (c) { return '
' + '
' + c.v + '
' + '
' + c.l + '
'; }).join(''); } // ─── Профиль высот (canvas) ────────────────────────────────────────────── /** * Отрисовывает canvas-профиль высот активного файла (по всем его * трекам) и подключает интерактивность (tooltip + маркер-курсор на * карте) — TRZ REQ-F-10; ревью P2-1. * @param {object} file */ function renderElevationProfile(file) { var canvas = document.getElementById('gpx-elevation-canvas'); var emptyEl = document.getElementById('gpx-elevation-empty'); var axisEl = document.getElementById('gpx-elevation-axis'); if (!canvas) return; var samples = buildFileProfileSamples(file); if (samples.length < 2) { canvas.style.display = 'none'; if (axisEl) axisEl.style.display = 'none'; if (emptyEl) emptyEl.style.display = 'block'; profileState = null; return; } canvas.style.display = 'block'; if (emptyEl) emptyEl.style.display = 'none'; if (axisEl) axisEl.style.display = 'flex'; var wrap = canvas.parentElement; var cssW = (wrap && wrap.clientWidth) ? wrap.clientWidth : 320; var dpr = global.devicePixelRatio || 1; canvas.width = cssW * dpr; canvas.height = PROFILE_HEIGHT * dpr; canvas.style.width = cssW + 'px'; canvas.style.height = PROFILE_HEIGHT + 'px'; var ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, PROFILE_HEIGHT); var totalKm = samples[samples.length - 1].d; var minE = Infinity, maxE = -Infinity; samples.forEach(function (s) { if (s.e < minE) minE = s.e; if (s.e > maxE) maxE = s.e; }); var pad = 8; var spanE = (maxE - minE) || 1; function xOf(d) { return pad + (d / totalKm) * (cssW - 2 * pad); } function yOf(e) { return PROFILE_HEIGHT - pad - ((e - minE) / spanE) * (PROFILE_HEIGHT - 2 * pad); } // Заливка под линией — цвет трека с пониженной прозрачностью. ctx.beginPath(); ctx.moveTo(xOf(samples[0].d), PROFILE_HEIGHT - pad); samples.forEach(function (s) { ctx.lineTo(xOf(s.d), yOf(s.e)); }); ctx.lineTo(xOf(totalKm), PROFILE_HEIGHT - pad); ctx.closePath(); ctx.globalAlpha = 0.2; ctx.fillStyle = file.color; ctx.fill(); ctx.globalAlpha = 1; // Линия профиля — цвет трека. ctx.beginPath(); samples.forEach(function (s, i) { var x = xOf(s.d), y = yOf(s.e); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.strokeStyle = file.color; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.stroke(); if (axisEl) { var spans = axisEl.getElementsByTagName('span'); if (spans.length >= 3) { // Единицы — через formatKm, чтобы ось согласовалась со // статистикой при выборе миль в ET-005 (ревью P3-1). spans[0].textContent = formatKm(0); spans[1].textContent = formatKm(totalKm / 2); spans[2].textContent = formatKm(totalKm); } } profileState = { file: file, samples: samples, cssW: cssW, totalKm: totalKm, color: file.color, }; attachProfileInteraction(canvas); } /** * Строит точки профиля {d: км от старта, e: высота, lon, lat} — * только для точек, у которых есть данные высот. * @param {object} track * @returns {Array<{d:number, e:number, lon:number, lat:number}>} */ function buildProfileSamples(track) { var samples = []; var cum = 0; var pts = track.points; for (var i = 0; i < pts.length; i++) { if (i > 0) cum += haversineKm(pts[i - 1], pts[i]); var e = pts[i][2]; if (e !== null && e !== undefined && !isNaN(e)) { samples.push({ d: cum, e: e, lon: pts[i][0], lat: pts[i][1] }); } } 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). * @param {HTMLCanvasElement} canvas */ function attachProfileInteraction(canvas) { function handleMove(clientX) { if (!profileState) return; var rect = canvas.getBoundingClientRect(); var x = clientX - rect.left; var frac = Math.max(0, Math.min(1, x / profileState.cssW)); var targetD = frac * profileState.totalKm; var samples = profileState.samples; var nearest = samples[0]; var bestDelta = Infinity; for (var i = 0; i < samples.length; i++) { var delta = Math.abs(samples[i].d - targetD); if (delta < bestDelta) { bestDelta = delta; nearest = samples[i]; } } var tip = document.getElementById('gpx-elevation-tip'); if (tip) { // Расстояние — через 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'; } showCursorMarker(nearest.lon, nearest.lat, profileState.color); } function hide() { var tip = document.getElementById('gpx-elevation-tip'); if (tip) tip.style.display = 'none'; hideCursorMarker(); } canvas.onmousemove = function (e) { handleMove(e.clientX); }; canvas.onmouseleave = hide; canvas.ontouchstart = function (e) { if (e.touches.length) handleMove(e.touches[0].clientX); }; canvas.ontouchmove = function (e) { if (e.touches.length) { handleMove(e.touches[0].clientX); e.preventDefault(); } }; canvas.ontouchend = hide; } /** * Показывает на карте маркер-курсор в указанной точке трека. * @param {number} lon * @param {number} lat * @param {string} color */ function showCursorMarker(lon, lat, color) { var map = global._map; if (!map || typeof maplibregl === 'undefined') return; if (!cursorMarker) { var el = document.createElement('div'); el.className = 'gpx-cursor-marker'; cursorMarker = new maplibregl.Marker({ element: el, anchor: 'center' }); } cursorMarker.getElement().style.background = color; cursorMarker.setLngLat([lon, lat]).addTo(map); } /** Скрывает маркер-курсор профиля высот с карты. */ function hideCursorMarker() { if (cursorMarker) cursorMarker.remove(); } // ─── Экспорт ───────────────────────────────────────────────────────────── /** Публичный контракт модуля. */ var Gpx = { PALETTE: PALETTE, 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, }; // Браузер: глобальный неймспейс + inline-onclick-обработчики и хук // rebuildGpxOverlays / showToast (контракт интеграции — ADR-002). if (global) { global.Gpx = Gpx; global.onGpxFileSelected = onGpxFileSelected; global.handleGpxFiles = handleGpxFiles; global.toggleGpxSheet = toggleGpxSheet; global.selectGpxTrack = selectGpxTrack; global.removeGpxTrack = removeGpxTrack; global.rebuildGpxOverlays = rebuildGpxOverlays; if (typeof global.showToast !== 'function') global.showToast = showToast; } // Node: экспорт для изолированных unit-тестов. if (typeof module === 'object' && module.exports) { module.exports = Gpx; } })(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null));