fix(ET-009): dynamic source filter + working attribution (F-01, F-02)
Some checks failed
CI / lint (push) Failing after 5s
CI / test (push) Failing after 6s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped

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:
2026-06-01 21:12:35 +00:00
parent 3577ff32ac
commit fc03746e4f

View File

@@ -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);