// ═══════════════════════════════════════════════════════════════════ // gps_tracks.js — ET-008: Публичные GPS-треки // ═══════════════════════════════════════════════════════════════════ // ─── Константы ──────────────────────────────────────────────────── const GPS_TRACKS_ZOOM_CUTOFF = 12; // ниже — MVT, выше — GeoJSON const GPS_TRACKS_MIN_ZOOM = 8; // ниже — слой скрыт const GPS_SOURCE_COLORS = { osm: '#3cb44b', enduro_russia: '#e6194b', wikiloc: '#4363d8', ttrails: '#911eb4', offmaps: '#f58231', nakarte: '#f032e6', }; const GPS_FALLBACK_COLORS = ['#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', '#dcbeff', '#9A6324', '#fffac8']; // ET-009: атрибуция для каждого источника. Используется при сборке // MapLibre attribution control: к строке source-attribution добавляются // все источники, у которых tracks_by_source > 0. const GPS_SOURCE_ATTRIBUTIONS = { osm: '© OpenStreetMap contributors (ODbL)', enduro_russia: 'EnduroRussia.ru', wikiloc: '© Wikiloc contributors', ttrails: 'ttrails.ru', }; // ET-009 (ADR-013 §3 Решение D, опция D2): маппинг source_id → human label. // Используется для построения списка чекбоксов в фильтре источников. // Источники подтягиваются динамически из /api/gps-tracks/health, а лейбл // берётся отсюда; при отсутствии source_id в этом маппинге используется сам id. const GPS_SOURCE_LABELS = { osm: 'OSM', enduro_russia: 'EnduroRussia', wikiloc: 'Wikiloc', ttrails: 'Тропинки.ру', }; // Fallback-список источников при сетевой ошибке /health (показываем все // потенциально доступные источники, чтобы UI не оставался пустым). const GPS_FALLBACK_SOURCES = ['osm', 'enduro_russia', 'wikiloc', 'ttrails']; const GPS_ACTIVITY_COLORS = { enduro: '#e6194b', moto: '#f58231', offroad: '#ffe119', bicycle: '#3cb44b', hike: '#4363d8', ski: '#42d4f4', other: '#808080', }; const GPS_ACTIVITY_ICONS = { enduro: '🏍', moto: '🛵', offroad: '🚙', bicycle: '🚵', hike: '🥾', ski: '⛷️', other: '📍', }; const GPS_ACTIVITY_LABELS = { enduro: 'Эндуро', moto: 'Мото', offroad: 'Off-road', bicycle: 'Велосипед', hike: 'Пешком', ski: 'Лыжи', other: 'Другое', }; // ─── Состояние ─────────────────────────────────────────────────── window.gpsTracksLayer = { enabled: false, filters: { activities: ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'], sources: ['osm', 'enduro_russia', 'wikiloc', 'ttrails'], colorMode: 'source' }, sourceId: 'gps-tracks-tiles', sourceGeoId: 'gps-tracks-geo', layerId: 'gps-tracks-layer-mvt', layerGeoId: 'gps-tracks-layer-geo', layerHaloId: 'gps-tracks-halo-mvt-satellite', layerHaloGeoId: 'gps-tracks-halo-geo-satellite', geojsonAbortController: null, geojsonReqDebounceTimer: null, stats: { total: 0, shown: 0 }, // ET-009 (F-01/F-02 fix): cached /api/gps-tracks/health response. // Populated by _fetchGpsHealth; используется и для атрибуции (передаётся // в addSource), и для построения динамического списка чекбоксов источников. _healthCache: null, _healthFetchPromise: null, }; // ─── Цветовые выражения MapLibre ────────────────────────────────── function _buildColorExpression(mode) { if (mode === 'activity') { const expr = ['match', ['get', 'activity']]; for (const [act, color] of Object.entries(GPS_ACTIVITY_COLORS)) { expr.push(act, color); } expr.push('#808080'); // fallback return expr; } else { // по источнику const expr = ['match', ['get', 'source']]; for (const [src, color] of Object.entries(GPS_SOURCE_COLORS)) { expr.push(src, color); } expr.push('#808080'); // fallback return expr; } } // ─── Layer definitions ──────────────────────────────────────────── function _gpsLayerDef(id, source, sourceLayer) { const colorExpr = _buildColorExpression(window.gpsTracksLayer.filters.colorMode); return { id, type: 'line', source, 'source-layer': sourceLayer || undefined, paint: { 'line-color': colorExpr, 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1.0, 12, 2.0, 16, 3.0], 'line-opacity': 0.75, }, layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'none' } }; } function _gpsHaloDef(id, source, sourceLayer) { return { id, type: 'line', source, 'source-layer': sourceLayer || undefined, paint: { 'line-color': '#ffffff', 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 2.5, 12, 4.0, 16, 6.0], 'line-opacity': 0.6, }, layout: { visibility: 'none' } }; } // ─── Создание/удаление sources и layers ────────────────────────── /** * Добавляет vector- и geojson-источники для GPS-треков, если их ещё нет. * * ET-009 (F-02 fix): attribution передаётся параметром и фиксируется в * момент addSource. Это единственный надёжный способ заставить MapLibre * AttributionControl показать строку: мутация `source.attribution` после * addSource не вызывает обновления control'а. Вызывающий код обязан * сначала получить /api/gps-tracks/health (через _fetchGpsHealth) и * собрать строку через _buildGpsAttributionString, а уж потом передавать * её сюда. * * @param {object} map MapLibre map instance * @param {string} attribution Готовая строка атрибуции (joined по ", ") */ function _ensureGpsSources(map, attribution) { const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; const attr = attribution || GPS_SOURCE_ATTRIBUTIONS.osm; if (!map.getSource(window.gpsTracksLayer.sourceId)) { map.addSource(window.gpsTracksLayer.sourceId, { type: 'vector', tiles: [`${window.location.origin}${basePath}/api/gps-tracks/tiles/{z}/{x}/{y}.mvt`], minzoom: GPS_TRACKS_MIN_ZOOM, maxzoom: 11, attribution: attr, }); } if (!map.getSource(window.gpsTracksLayer.sourceGeoId)) { map.addSource(window.gpsTracksLayer.sourceGeoId, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, attribution: attr, }); } } function _ensureGpsLayers(map) { if (!map.getLayer(window.gpsTracksLayer.layerId)) { const def = _gpsLayerDef( window.gpsTracksLayer.layerId, window.gpsTracksLayer.sourceId, 'gps_tracks' ); // Добавить поверх trails, ниже route (если есть) const before = _findGpsInsertPosition(map); map.addLayer(def, before); } if (!map.getLayer(window.gpsTracksLayer.layerGeoId)) { const def = _gpsLayerDef( window.gpsTracksLayer.layerGeoId, window.gpsTracksLayer.sourceGeoId, null ); delete def['source-layer']; const before = _findGpsInsertPosition(map); map.addLayer(def, before); } if (!map.getLayer(window.gpsTracksLayer.layerHaloId)) { const def = _gpsHaloDef( window.gpsTracksLayer.layerHaloId, window.gpsTracksLayer.sourceId, 'gps_tracks' ); const before = window.gpsTracksLayer.layerId; map.addLayer(def, before); } if (!map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) { const def = _gpsHaloDef( window.gpsTracksLayer.layerHaloGeoId, window.gpsTracksLayer.sourceGeoId, null ); delete def['source-layer']; const before = window.gpsTracksLayer.layerGeoId; map.addLayer(def, before); } } /** * ET-009 (F-01/F-02 fix): получает /api/gps-tracks/health и кэширует * результат в `window.gpsTracksLayer._healthCache`. Многократные параллельные * вызовы переиспользуют один in-flight Promise (`_healthFetchPromise`), * чтобы не плодить дублирующих запросов при включении слоя + одновременном * открытии sheet'а фильтров. * * При сетевой ошибке/не-2xx — возвращает null, кэш не обновляется (но и не * затирается); вызывающий код должен fallback'ить на дефолты. * * @param {object} [opts] * @param {boolean} [opts.force=false] — игнорировать кэш и сходить заново * @returns {Promise} */ async function _fetchGpsHealth(opts) { const force = !!(opts && opts.force); if (!force && window.gpsTracksLayer._healthCache) { return window.gpsTracksLayer._healthCache; } if (!force && window.gpsTracksLayer._healthFetchPromise) { return window.gpsTracksLayer._healthFetchPromise; } const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; const promise = (async () => { try { const resp = await fetch(`${basePath}/api/gps-tracks/health`); if (!resp.ok) return null; const data = await resp.json(); window.gpsTracksLayer._healthCache = data; return data; } catch (_) { return null; } finally { window.gpsTracksLayer._healthFetchPromise = null; } })(); window.gpsTracksLayer._healthFetchPromise = promise; return promise; } /** * ET-009 (F-02 fix): собирает строку атрибуции из ответа /health. * Для каждого известного источника (osm, enduro_russia, wikiloc, ttrails), * у которого `tracks_by_source[id] > 0`, добавляет соответствующую запись * из GPS_SOURCE_ATTRIBUTIONS. Если данных нет или все нули — fallback на * OSM-атрибуцию (она всегда обязательна по лицензии). * * @param {object|null} healthData ответ /api/gps-tracks/health или null * @returns {string} строка атрибуции, готовая к передаче в addSource */ function _buildGpsAttributionString(healthData) { const counts = healthData && healthData.tracks_by_source ? healthData.tracks_by_source : {}; const labels = []; for (const src of Object.keys(GPS_SOURCE_ATTRIBUTIONS)) { if (counts[src] && counts[src] > 0) { labels.push(GPS_SOURCE_ATTRIBUTIONS[src]); } } if (labels.length === 0) { labels.push(GPS_SOURCE_ATTRIBUTIONS.osm); } return labels.join(', '); } /** * ET-009 (F-01 fix): возвращает список source_id, по которым в БД есть * треки, согласно ответу /health. Если ответ отсутствует / не содержит * tracks_by_source — fallback на GPS_FALLBACK_SOURCES (статический список * потенциально доступных источников), чтобы UI фильтра не оставался пустым. * * @param {object|null} healthData ответ /api/gps-tracks/health или null * @returns {string[]} список source_id для отрисовки чекбоксов */ function _getAvailableGpsSources(healthData) { const counts = healthData && healthData.tracks_by_source ? healthData.tracks_by_source : null; if (!counts) return GPS_FALLBACK_SOURCES.slice(); const ids = Object.keys(counts).filter(s => counts[s] > 0); if (ids.length === 0) return GPS_FALLBACK_SOURCES.slice(); return ids; } function _findGpsInsertPosition(map) { /** * Returns the id of the first layer that GPS tracks should be inserted * below, using priority order: * 1. gpx-layer-* — ET-006 GPX file layers (highest priority) * 2. route-* — ET-002 routing layers * Returns undefined if neither is present (GPS tracks go on top). */ const style = map.getStyle && map.getStyle(); if (!style || !style.layers) return undefined; // Priority 1: gpx-layer-* (ET-006 GPX file layers) const gpxLayer = style.layers.find(l => l.id.startsWith('gpx-layer-')); if (gpxLayer) return gpxLayer.id; // Priority 2: route-* (ET-002 routing layers) const routeLayer = style.layers.find(l => l.id.startsWith('route-')); if (routeLayer) return routeLayer.id; return undefined; } // ─── Управление видимостью ──────────────────────────────────────── function _syncGpsLayersVisibility(map) { const enabled = window.gpsTracksLayer.enabled; const zoom = map.getZoom ? map.getZoom() : 0; const mvtVisible = enabled && zoom >= GPS_TRACKS_MIN_ZOOM && zoom < GPS_TRACKS_ZOOM_CUTOFF; const geoVisible = enabled && zoom >= GPS_TRACKS_ZOOM_CUTOFF; const setVis = (layerId, visible) => { if (map.getLayer(layerId)) { map.setLayoutProperty(layerId, 'visibility', visible ? 'visible' : 'none'); } }; setVis(window.gpsTracksLayer.layerId, mvtVisible); setVis(window.gpsTracksLayer.layerGeoId, geoVisible); // Hint «Зум 8+» const hint = document.getElementById('public-tracks-zoom-hint'); if (hint) { hint.style.display = (enabled && zoom < GPS_TRACKS_MIN_ZOOM) ? 'inline' : 'none'; } // Halo обновляется через applyGpsHaloVisibility applyGpsHaloVisibility(map); } // ─── Halo ────────────────────────────────────────────────────────── function applyGpsHaloVisibility(map) { if (!map) return; const zoom = map.getZoom ? map.getZoom() : 0; const isSatellite = document.body.classList.contains('satellite-active'); const enabled = window.gpsTracksLayer.enabled; const mvtHaloOn = enabled && isSatellite && zoom >= GPS_TRACKS_MIN_ZOOM && zoom < GPS_TRACKS_ZOOM_CUTOFF; const geoHaloOn = enabled && isSatellite && zoom >= GPS_TRACKS_ZOOM_CUTOFF; if (map.getLayer(window.gpsTracksLayer.layerHaloId)) { map.setLayoutProperty(window.gpsTracksLayer.layerHaloId, 'visibility', mvtHaloOn ? 'visible' : 'none'); } if (map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) { map.setLayoutProperty(window.gpsTracksLayer.layerHaloGeoId, 'visibility', geoHaloOn ? 'visible' : 'none'); } } // ─── Фильтрация ─────────────────────────────────────────────────── function applyGpsFilter() { const map = window._map; if (!map) return; const { activities, sources } = window.gpsTracksLayer.filters; const filter = ['all', ['in', ['get', 'activity'], ['literal', activities]], ['in', ['get', 'source'], ['literal', sources]] ]; if (map.getLayer(window.gpsTracksLayer.layerId)) { map.setFilter(window.gpsTracksLayer.layerId, filter); } if (map.getLayer(window.gpsTracksLayer.layerGeoId)) { map.setFilter(window.gpsTracksLayer.layerGeoId, filter); } if (map.getLayer(window.gpsTracksLayer.layerHaloId)) { map.setFilter(window.gpsTracksLayer.layerHaloId, filter); } if (map.getLayer(window.gpsTracksLayer.layerHaloGeoId)) { map.setFilter(window.gpsTracksLayer.layerHaloGeoId, filter); } _updateGpsStatsUI(); } // ─── GeoJSON загрузка ───────────────────────────────────────────── function onGpsMapMoveEnd() { const map = window._map; if (!map || !window.gpsTracksLayer.enabled) return; if (map.getZoom() < GPS_TRACKS_ZOOM_CUTOFF) return; clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer); window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => { fetchAndUpdateGpsGeoJson(map.getBounds()); }, 500); } async function fetchAndUpdateGpsGeoJson(bounds) { const map = window._map; if (!map) return; if (window.gpsTracksLayer.geojsonAbortController) { window.gpsTracksLayer.geojsonAbortController.abort(); } const ctrl = new AbortController(); window.gpsTracksLayer.geojsonAbortController = ctrl; const { activities, sources } = window.gpsTracksLayer.filters; const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; const bbox = `${bounds.getWest()},${bounds.getSouth()},${bounds.getEast()},${bounds.getNorth()}`; const url = `${basePath}/api/gps-tracks?bbox=${bbox}&activity=${activities.join(',')}&source=${sources.join(',')}&limit=500`; try { const resp = await fetch(url, { signal: ctrl.signal }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const json = await resp.json(); if (map.getSource(window.gpsTracksLayer.sourceGeoId)) { map.getSource(window.gpsTracksLayer.sourceGeoId).setData(json); } window.gpsTracksLayer.stats = { total: json.total_in_bbox || 0, shown: json.returned || 0 }; if (json.truncated) { // показываем toast один раз if (typeof showToast === 'function') { showToast(`Показаны ${json.returned} треков из ${json.total_in_bbox}. Увеличьте zoom для полной выборки`); } } _updateGpsStatsUI(); } catch (e) { if (e.name === 'AbortError') return; if (typeof showToast === 'function') showToast('Не удалось загрузить треки'); } } // ─── Popup при клике ────────────────────────────────────────────── function _renderTrackPopupHtml(props) { const name = props.name || 'Без названия'; const activity = props.activity_type || props.activity || 'other'; const icon = GPS_ACTIVITY_ICONS[activity] || '📍'; const actLabel = GPS_ACTIVITY_LABELS[activity] || activity; const lengthKm = typeof props.length_km === 'number' ? props.length_km.toFixed(1) : '—'; const points = props.points_count || '—'; const dateStr = props.created_at ? new Date(props.created_at).toLocaleDateString('ru-RU', {day:'numeric',month:'long',year:'numeric'}) : null; const user = props.user || null; let sourcesHtml = ''; try { let srcs = props.sources; let urls = props.external_urls; if (typeof srcs === 'string') srcs = srcs.split(',').filter(Boolean); if (typeof urls === 'string') urls = urls.split(',').filter(Boolean); if (Array.isArray(srcs) && srcs.length) { sourcesHtml = '
Источники: ' + srcs.map((s, i) => { const url = Array.isArray(urls) && urls[i] ? urls[i] : null; const label = s; return url ? `${label} ↗` : `${label}`; }).join(' · ') + '
'; } } catch(e) {} return `
${name}
${icon} ${actLabel}
📏 ${lengthKm} км · ${points} точек
${dateStr ? `
📅 ${dateStr}
` : ''} ${user ? `
👤 ${user}
` : ''} ${sourcesHtml}
`; } function _setupGpsClickHandler(map) { const layerIds = [window.gpsTracksLayer.layerId, window.gpsTracksLayer.layerGeoId]; layerIds.forEach(layerId => { map.on('click', layerId, (e) => { // Не открывать popup если активен другой режим if (window._routeMode || window._reconMode || window._scenicMode || window._rulerMode) return; const feature = e.features && e.features[0]; if (!feature) return; new maplibregl.Popup({ closeOnClick: true, maxWidth: '300px' }) .setLngLat(e.lngLat) .setHTML(_renderTrackPopupHtml(feature.properties)) .addTo(map); }); map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; }); }); } // ─── Включение/выключение слоя ──────────────────────────────────── async function onPublicTracksCheckbox() { const cb = document.getElementById('public-tracks-cb'); const filterBtn = document.getElementById('public-tracks-filters-btn'); if (!cb) return; window.gpsTracksLayer.enabled = cb.checked; localStorage.setItem('gps-tracks-enabled', cb.checked ? 'true' : 'false'); const map = window._map; if (!map) return; if (cb.checked) { // ET-009 (F-02 fix): обязательно дождаться /health ДО addSource — // иначе attribution зафиксируется на дефолтном «© OSM» и // AttributionControl никогда не обновится (см. ADR-013 §3 Решение D, // F-02 в 12-review.md). const health = await _fetchGpsHealth(); const attribution = _buildGpsAttributionString(health); _ensureGpsSources(map, attribution); _ensureGpsLayers(map); _setupGpsClickHandler(map); // Убедиться, что moveend listener есть map.off('moveend', onGpsMapMoveEnd); map.on('moveend', onGpsMapMoveEnd); map.off('zoomend', onGpsZoomEnd); map.on('zoomend', onGpsZoomEnd); } _syncGpsLayersVisibility(map); applyGpsFilter(); // Фильтры btn if (filterBtn) filterBtn.style.display = cb.checked ? 'block' : 'none'; // Если включили и zoom >= 12 — загрузить GeoJSON if (cb.checked && map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) { fetchAndUpdateGpsGeoJson(map.getBounds()); } } function onGpsZoomEnd() { const map = window._map; if (!map) return; _syncGpsLayersVisibility(map); // При переходе на z>=12 загрузить GeoJSON if (window.gpsTracksLayer.enabled && map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) { clearTimeout(window.gpsTracksLayer.geojsonReqDebounceTimer); window.gpsTracksLayer.geojsonReqDebounceTimer = setTimeout(() => { fetchAndUpdateGpsGeoJson(map.getBounds()); }, 500); } } // ─── Sheet фильтров ─────────────────────────────────────────────── function togglePublicTracksFiltersSheet() { const sheet = document.getElementById('sheet-gps-filters'); if (!sheet) return; const isOpen = sheet.classList.contains('open'); if (!isOpen) { // ET-009 (F-01 fix): _buildGpsFiltersUI асинхронно подтянет /health // для динамического списка источников. Sheet можно открывать сразу — // чекбоксы источников появятся как только промис разрешится. _buildGpsFiltersUI(); openSheet('sheet-gps-filters'); } else { closeAllSheets(); } } /** * ET-009 (F-01 fix): строит UI фильтра. Активности — статический список; * источники подтягиваются из /api/gps-tracks/health (ADR-013 §3 Решение D, * опция D2): чекбокс отображается для каждого source_id с tracks_by_source > 0. * Маппинг id → label берётся из GPS_SOURCE_LABELS. Активация четвёртого * источника не требует правки этого кода — нужен только новый ключ в * GPS_SOURCE_LABELS (для красивого названия) или fallback к самому id. * * При сетевой ошибке /health список источников fallback'ит на * GPS_FALLBACK_SOURCES (см. _getAvailableGpsSources). */ async function _buildGpsFiltersUI() { // Активности const actGrid = document.getElementById('gps-activity-grid'); if (actGrid) { const all = ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other']; actGrid.innerHTML = all.map(act => { const checked = window.gpsTracksLayer.filters.activities.includes(act); return ` `; }).join(''); } // Color mode (синхронная часть — обновляем до await чтобы UI отозвался // максимально быстро при открытии sheet'а) const colorMode = window.gpsTracksLayer.filters.colorMode; const btnSrc = document.getElementById('gps-color-by-source'); const btnAct = document.getElementById('gps-color-by-activity'); if (btnSrc) btnSrc.classList.toggle('active', colorMode === 'source'); if (btnAct) btnAct.classList.toggle('active', colorMode === 'activity'); _updateGpsStatsUI(); // Источники — динамически из /health (ADR-013 §3 Решение D, опция D2) const srcGrid = document.getElementById('gps-source-grid'); if (srcGrid) { const health = await _fetchGpsHealth(); const allSources = _getAvailableGpsSources(health); srcGrid.innerHTML = allSources.map(src => { const checked = window.gpsTracksLayer.filters.sources.includes(src); const label = GPS_SOURCE_LABELS[src] || src; return ` `; }).join(''); } } function onGpsActivityFilterChange() { const checked = []; document.querySelectorAll('#gps-activity-grid input[type=checkbox]:checked').forEach(cb => checked.push(cb.value)); window.gpsTracksLayer.filters.activities = checked; localStorage.setItem('gps-tracks-activities', JSON.stringify(checked)); applyGpsFilter(); } function onGpsSourceFilterChange() { const checked = []; document.querySelectorAll('#gps-source-grid input[type=checkbox]:checked').forEach(cb => checked.push(cb.value)); window.gpsTracksLayer.filters.sources = checked; localStorage.setItem('gps-tracks-sources', JSON.stringify(checked)); applyGpsFilter(); } function onGpsColorModeChange(mode) { window.gpsTracksLayer.filters.colorMode = mode; localStorage.setItem('gps-tracks-color-mode', mode); const btnSrc = document.getElementById('gps-color-by-source'); const btnAct = document.getElementById('gps-color-by-activity'); if (btnSrc) btnSrc.classList.toggle('active', mode === 'source'); if (btnAct) btnAct.classList.toggle('active', mode === 'activity'); // Перестроить color expression const map = window._map; if (!map) return; const colorExpr = _buildColorExpression(mode); [window.gpsTracksLayer.layerId, window.gpsTracksLayer.layerGeoId].forEach(layerId => { if (map.getLayer(layerId)) { map.setPaintProperty(layerId, 'line-color', colorExpr); } }); } function _updateGpsStatsUI() { const totalEl = document.getElementById('gps-stat-total'); const shownEl = document.getElementById('gps-stat-shown'); if (totalEl) totalEl.textContent = window.gpsTracksLayer.stats.total || '—'; if (shownEl) shownEl.textContent = window.gpsTracksLayer.stats.shown || '—'; } // ─── restorePublicTracksState ────────────────────────────────────── /** * Восстанавливает состояние слоя публичных треков из localStorage. * Вызывается из rebuildMapOverlays() в app.js. * * ET-009 (F-02 fix): теперь async, потому что при `enabled=true` нужно * сначала дождаться /api/gps-tracks/health и только потом вызвать * addSource с корректным attribution — иначе AttributionControl * зафиксируется на дефолтной OSM-строке. */ async function restorePublicTracksState() { const enabled = localStorage.getItem('gps-tracks-enabled') === 'true'; const cb = document.getElementById('public-tracks-cb'); const filterBtn = document.getElementById('public-tracks-filters-btn'); const activitiesRaw = localStorage.getItem('gps-tracks-activities'); if (activitiesRaw) { try { window.gpsTracksLayer.filters.activities = JSON.parse(activitiesRaw); } catch(e) {} } const sourcesRaw = localStorage.getItem('gps-tracks-sources'); if (sourcesRaw) { try { window.gpsTracksLayer.filters.sources = JSON.parse(sourcesRaw); } catch(e) {} } const colorMode = localStorage.getItem('gps-tracks-color-mode') || 'source'; window.gpsTracksLayer.filters.colorMode = colorMode; if (cb) cb.checked = enabled; if (filterBtn) filterBtn.style.display = enabled ? 'block' : 'none'; window.gpsTracksLayer.enabled = enabled; const map = window._map; if (!map) return; if (enabled) { const health = await _fetchGpsHealth(); const attribution = _buildGpsAttributionString(health); _ensureGpsSources(map, attribution); _ensureGpsLayers(map); _setupGpsClickHandler(map); map.off('moveend', onGpsMapMoveEnd); map.on('moveend', onGpsMapMoveEnd); map.off('zoomend', onGpsZoomEnd); map.on('zoomend', onGpsZoomEnd); _syncGpsLayersVisibility(map); applyGpsFilter(); if (map.getZoom() >= GPS_TRACKS_ZOOM_CUTOFF) { fetchAndUpdateGpsGeoJson(map.getBounds()); } } }