diff --git a/src/web/gps_tracks.js b/src/web/gps_tracks.js index ac124af..a4a98d6 100644 --- a/src/web/gps_tracks.js +++ b/src/web/gps_tracks.js @@ -27,6 +27,21 @@ const GPS_SOURCE_ATTRIBUTIONS = { 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', @@ -74,7 +89,12 @@ window.gpsTracksLayer = { layerHaloGeoId: 'gps-tracks-halo-geo-satellite', geojsonAbortController: null, geojsonReqDebounceTimer: null, - stats: { total: 0, shown: 0 } + 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 ────────────────────────────────── @@ -133,8 +153,23 @@ function _gpsHaloDef(id, source, sourceLayer) { // ─── Создание/удаление sources и layers ────────────────────────── -function _ensureGpsSources(map) { +/** + * Добавляет 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, { @@ -142,7 +177,7 @@ function _ensureGpsSources(map) { tiles: [`${window.location.origin}${basePath}/api/gps-tracks/tiles/{z}/{x}/{y}.mvt`], minzoom: GPS_TRACKS_MIN_ZOOM, maxzoom: 11, - attribution: '© OpenStreetMap contributors (ODbL)', + attribution: attr, }); } @@ -150,6 +185,7 @@ function _ensureGpsSources(map) { map.addSource(window.gpsTracksLayer.sourceGeoId, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, + attribution: attr, }); } } @@ -200,41 +236,84 @@ function _ensureGpsLayers(map) { } /** - * ET-009: динамически обновляет attribution источника `gps-tracks-tiles` на - * основе ответа /api/gps-tracks/health.tracks_by_source. Включает в строку - * атрибуцию каждого источника, у которого > 0 треков в БД. Падение запроса — - * не блокирующее: остаётся последняя установленная атрибуция. + * 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 _updateGpsAttribution(map) { - if (!map || !map.getSource) return; - const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || ''; - try { - const resp = await fetch(`${basePath}/api/gps-tracks/health`); - if (!resp.ok) return; - const data = await resp.json(); - const counts = data && data.tracks_by_source ? data.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); - } - const attribution = labels.join(', '); - const src = map.getSource(window.gpsTracksLayer.sourceId); - if (src && typeof src.attribution !== 'undefined') { - src.attribution = attribution; - } - // MapLibre не перечитывает source.attribution автоматически — - // дергаем resize чтобы обновить AttributionControl - if (typeof map._controls !== 'undefined') { - try { map.resize(); } catch (_) { /* noop */ } - } - } catch (_) { - // network failure — оставляем дефолтную атрибуцию +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) { @@ -445,7 +524,7 @@ function _setupGpsClickHandler(map) { // ─── Включение/выключение слоя ──────────────────────────────────── -function onPublicTracksCheckbox() { +async function onPublicTracksCheckbox() { const cb = document.getElementById('public-tracks-cb'); const filterBtn = document.getElementById('public-tracks-filters-btn'); if (!cb) return; @@ -457,11 +536,15 @@ function onPublicTracksCheckbox() { if (!map) return; if (cb.checked) { - _ensureGpsSources(map); + // 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); - // ET-009: подтянуть актуальные атрибуции (osm, enduro_russia, wikiloc) - _updateGpsAttribution(map); // Убедиться, что moveend listener есть map.off('moveend', onGpsMapMoveEnd); @@ -502,6 +585,9 @@ function togglePublicTracksFiltersSheet() { 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 { @@ -509,7 +595,18 @@ function togglePublicTracksFiltersSheet() { } } -function _buildGpsFiltersUI() { +/** + * 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) { @@ -524,27 +621,8 @@ function _buildGpsFiltersUI() { }).join(''); } - // Источники (из localStorage или дефолт) - const srcGrid = document.getElementById('gps-source-grid'); - if (srcGrid) { - const allSources = ['osm', 'enduro_russia', 'wikiloc', 'ttrails']; - const sourceLabels = { - osm: 'OSM', - enduro_russia: 'EnduroRussia', - wikiloc: 'Wikiloc', - ttrails: 'Тропинки.ру', - }; - srcGrid.innerHTML = allSources.map(src => { - const checked = window.gpsTracksLayer.filters.sources.includes(src); - return ` - `; - }).join(''); - } - - // Color mode + // 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'); @@ -552,6 +630,22 @@ function _buildGpsFiltersUI() { 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() { @@ -601,8 +695,13 @@ function _updateGpsStatsUI() { /** * Восстанавливает состояние слоя публичных треков из localStorage. * Вызывается из rebuildMapOverlays() в app.js. + * + * ET-009 (F-02 fix): теперь async, потому что при `enabled=true` нужно + * сначала дождаться /api/gps-tracks/health и только потом вызвать + * addSource с корректным attribution — иначе AttributionControl + * зафиксируется на дефолтной OSM-строке. */ -function restorePublicTracksState() { +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'); @@ -628,7 +727,9 @@ function restorePublicTracksState() { if (!map) return; if (enabled) { - _ensureGpsSources(map); + const health = await _fetchGpsHealth(); + const attribution = _buildGpsAttributionString(health); + _ensureGpsSources(map, attribution); _ensureGpsLayers(map); _setupGpsClickHandler(map); map.off('moveend', onGpsMapMoveEnd);