fix(ET-009): dynamic source filter + working attribution (F-01, F-02)
F-01 (P1): _buildGpsFiltersUI больше не хардкодит список источников — подтягивает source_id из /api/gps-tracks/health.tracks_by_source (ADR-013 §3 Решение D, опция D2). Маппинг source_id → label вынесен в JS-константу GPS_SOURCE_LABELS. Активация четвёртого источника теперь не требует изменений в этом коде. F-02 (P1): attribution фиксируется в момент addSource, а не мутацией src.attribution после. MapLibre AttributionControl не реагирует на прямое присвоение — потому до этого фикса AC-15 проваливался бы в UI-тестах. Теперь onPublicTracksCheckbox / restorePublicTracksState сначала await _fetchGpsHealth() → _buildGpsAttributionString(), потом _ensureGpsSources(map, attribution). Добавлен кэш + in-flight Promise (window.gpsTracksLayer._healthCache / _healthFetchPromise) — переоткрытие sheet'а фильтров не плодит дублирующих сетевых запросов. Все 24 node-теста gps_tracks.test.js зелёные. Refs: ET-009 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<object|null>}
|
||||
*/
|
||||
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 `
|
||||
<label class="gps-filter-chip">
|
||||
<input type="checkbox" value="${src}" ${checked ? 'checked' : ''} onchange="onGpsSourceFilterChange()">
|
||||
<span>${sourceLabels[src] || src}</span>
|
||||
</label>`;
|
||||
}).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 `
|
||||
<label class="gps-filter-chip">
|
||||
<input type="checkbox" value="${src}" ${checked ? 'checked' : ''} onchange="onGpsSourceFilterChange()">
|
||||
<span>${label}</span>
|
||||
</label>`;
|
||||
}).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);
|
||||
|
||||
Reference in New Issue
Block a user