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',
|
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 = {
|
const GPS_ACTIVITY_COLORS = {
|
||||||
enduro: '#e6194b',
|
enduro: '#e6194b',
|
||||||
moto: '#f58231',
|
moto: '#f58231',
|
||||||
@@ -74,7 +89,12 @@ window.gpsTracksLayer = {
|
|||||||
layerHaloGeoId: 'gps-tracks-halo-geo-satellite',
|
layerHaloGeoId: 'gps-tracks-halo-geo-satellite',
|
||||||
geojsonAbortController: null,
|
geojsonAbortController: null,
|
||||||
geojsonReqDebounceTimer: 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 ──────────────────────────────────
|
// ─── Цветовые выражения MapLibre ──────────────────────────────────
|
||||||
@@ -133,8 +153,23 @@ function _gpsHaloDef(id, source, sourceLayer) {
|
|||||||
|
|
||||||
// ─── Создание/удаление sources и layers ──────────────────────────
|
// ─── Создание/удаление 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 basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
|
||||||
|
const attr = attribution || GPS_SOURCE_ATTRIBUTIONS.osm;
|
||||||
|
|
||||||
if (!map.getSource(window.gpsTracksLayer.sourceId)) {
|
if (!map.getSource(window.gpsTracksLayer.sourceId)) {
|
||||||
map.addSource(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`],
|
tiles: [`${window.location.origin}${basePath}/api/gps-tracks/tiles/{z}/{x}/{y}.mvt`],
|
||||||
minzoom: GPS_TRACKS_MIN_ZOOM,
|
minzoom: GPS_TRACKS_MIN_ZOOM,
|
||||||
maxzoom: 11,
|
maxzoom: 11,
|
||||||
attribution: '© OpenStreetMap contributors (ODbL)',
|
attribution: attr,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +185,7 @@ function _ensureGpsSources(map) {
|
|||||||
map.addSource(window.gpsTracksLayer.sourceGeoId, {
|
map.addSource(window.gpsTracksLayer.sourceGeoId, {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
data: { type: 'FeatureCollection', features: [] },
|
data: { type: 'FeatureCollection', features: [] },
|
||||||
|
attribution: attr,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,19 +236,57 @@ function _ensureGpsLayers(map) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ET-009: динамически обновляет attribution источника `gps-tracks-tiles` на
|
* ET-009 (F-01/F-02 fix): получает /api/gps-tracks/health и кэширует
|
||||||
* основе ответа /api/gps-tracks/health.tracks_by_source. Включает в строку
|
* результат в `window.gpsTracksLayer._healthCache`. Многократные параллельные
|
||||||
* атрибуцию каждого источника, у которого > 0 треков в БД. Падение запроса —
|
* вызовы переиспользуют один in-flight Promise (`_healthFetchPromise`),
|
||||||
* не блокирующее: остаётся последняя установленная атрибуция.
|
* чтобы не плодить дублирующих запросов при включении слоя + одновременном
|
||||||
|
* открытии sheet'а фильтров.
|
||||||
|
*
|
||||||
|
* При сетевой ошибке/не-2xx — возвращает null, кэш не обновляется (но и не
|
||||||
|
* затирается); вызывающий код должен fallback'ить на дефолты.
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {boolean} [opts.force=false] — игнорировать кэш и сходить заново
|
||||||
|
* @returns {Promise<object|null>}
|
||||||
*/
|
*/
|
||||||
async function _updateGpsAttribution(map) {
|
async function _fetchGpsHealth(opts) {
|
||||||
if (!map || !map.getSource) return;
|
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 basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
|
||||||
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${basePath}/api/gps-tracks/health`);
|
const resp = await fetch(`${basePath}/api/gps-tracks/health`);
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return null;
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const counts = data && data.tracks_by_source ? data.tracks_by_source : {};
|
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 = [];
|
const labels = [];
|
||||||
for (const src of Object.keys(GPS_SOURCE_ATTRIBUTIONS)) {
|
for (const src of Object.keys(GPS_SOURCE_ATTRIBUTIONS)) {
|
||||||
if (counts[src] && counts[src] > 0) {
|
if (counts[src] && counts[src] > 0) {
|
||||||
@@ -222,19 +296,24 @@ async function _updateGpsAttribution(map) {
|
|||||||
if (labels.length === 0) {
|
if (labels.length === 0) {
|
||||||
labels.push(GPS_SOURCE_ATTRIBUTIONS.osm);
|
labels.push(GPS_SOURCE_ATTRIBUTIONS.osm);
|
||||||
}
|
}
|
||||||
const attribution = labels.join(', ');
|
return 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 — оставляем дефолтную атрибуцию
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
function _findGpsInsertPosition(map) {
|
||||||
@@ -445,7 +524,7 @@ function _setupGpsClickHandler(map) {
|
|||||||
|
|
||||||
// ─── Включение/выключение слоя ────────────────────────────────────
|
// ─── Включение/выключение слоя ────────────────────────────────────
|
||||||
|
|
||||||
function onPublicTracksCheckbox() {
|
async function onPublicTracksCheckbox() {
|
||||||
const cb = document.getElementById('public-tracks-cb');
|
const cb = document.getElementById('public-tracks-cb');
|
||||||
const filterBtn = document.getElementById('public-tracks-filters-btn');
|
const filterBtn = document.getElementById('public-tracks-filters-btn');
|
||||||
if (!cb) return;
|
if (!cb) return;
|
||||||
@@ -457,11 +536,15 @@ function onPublicTracksCheckbox() {
|
|||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
if (cb.checked) {
|
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);
|
_ensureGpsLayers(map);
|
||||||
_setupGpsClickHandler(map);
|
_setupGpsClickHandler(map);
|
||||||
// ET-009: подтянуть актуальные атрибуции (osm, enduro_russia, wikiloc)
|
|
||||||
_updateGpsAttribution(map);
|
|
||||||
|
|
||||||
// Убедиться, что moveend listener есть
|
// Убедиться, что moveend listener есть
|
||||||
map.off('moveend', onGpsMapMoveEnd);
|
map.off('moveend', onGpsMapMoveEnd);
|
||||||
@@ -502,6 +585,9 @@ function togglePublicTracksFiltersSheet() {
|
|||||||
if (!sheet) return;
|
if (!sheet) return;
|
||||||
const isOpen = sheet.classList.contains('open');
|
const isOpen = sheet.classList.contains('open');
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
|
// ET-009 (F-01 fix): _buildGpsFiltersUI асинхронно подтянет /health
|
||||||
|
// для динамического списка источников. Sheet можно открывать сразу —
|
||||||
|
// чекбоксы источников появятся как только промис разрешится.
|
||||||
_buildGpsFiltersUI();
|
_buildGpsFiltersUI();
|
||||||
openSheet('sheet-gps-filters');
|
openSheet('sheet-gps-filters');
|
||||||
} else {
|
} 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');
|
const actGrid = document.getElementById('gps-activity-grid');
|
||||||
if (actGrid) {
|
if (actGrid) {
|
||||||
@@ -524,27 +621,8 @@ function _buildGpsFiltersUI() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Источники (из localStorage или дефолт)
|
// Color mode (синхронная часть — обновляем до await чтобы UI отозвался
|
||||||
const srcGrid = document.getElementById('gps-source-grid');
|
// максимально быстро при открытии sheet'а)
|
||||||
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
|
|
||||||
const colorMode = window.gpsTracksLayer.filters.colorMode;
|
const colorMode = window.gpsTracksLayer.filters.colorMode;
|
||||||
const btnSrc = document.getElementById('gps-color-by-source');
|
const btnSrc = document.getElementById('gps-color-by-source');
|
||||||
const btnAct = document.getElementById('gps-color-by-activity');
|
const btnAct = document.getElementById('gps-color-by-activity');
|
||||||
@@ -552,6 +630,22 @@ function _buildGpsFiltersUI() {
|
|||||||
if (btnAct) btnAct.classList.toggle('active', colorMode === 'activity');
|
if (btnAct) btnAct.classList.toggle('active', colorMode === 'activity');
|
||||||
|
|
||||||
_updateGpsStatsUI();
|
_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() {
|
function onGpsActivityFilterChange() {
|
||||||
@@ -601,8 +695,13 @@ function _updateGpsStatsUI() {
|
|||||||
/**
|
/**
|
||||||
* Восстанавливает состояние слоя публичных треков из localStorage.
|
* Восстанавливает состояние слоя публичных треков из localStorage.
|
||||||
* Вызывается из rebuildMapOverlays() в app.js.
|
* Вызывается из 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 enabled = localStorage.getItem('gps-tracks-enabled') === 'true';
|
||||||
const cb = document.getElementById('public-tracks-cb');
|
const cb = document.getElementById('public-tracks-cb');
|
||||||
const filterBtn = document.getElementById('public-tracks-filters-btn');
|
const filterBtn = document.getElementById('public-tracks-filters-btn');
|
||||||
@@ -628,7 +727,9 @@ function restorePublicTracksState() {
|
|||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
_ensureGpsSources(map);
|
const health = await _fetchGpsHealth();
|
||||||
|
const attribution = _buildGpsAttributionString(health);
|
||||||
|
_ensureGpsSources(map, attribution);
|
||||||
_ensureGpsLayers(map);
|
_ensureGpsLayers(map);
|
||||||
_setupGpsClickHandler(map);
|
_setupGpsClickHandler(map);
|
||||||
map.off('moveend', onGpsMapMoveEnd);
|
map.off('moveend', onGpsMapMoveEnd);
|
||||||
|
|||||||
Reference in New Issue
Block a user