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>
746 lines
30 KiB
JavaScript
746 lines
30 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════
|
||
// 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<object|null>}
|
||
*/
|
||
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 = '<div class="track-popup-sources">Источники: ' +
|
||
srcs.map((s, i) => {
|
||
const url = Array.isArray(urls) && urls[i] ? urls[i] : null;
|
||
const label = s;
|
||
return url
|
||
? `<a href="${url}" target="_blank" rel="noopener">${label} ↗</a>`
|
||
: `<span>${label}</span>`;
|
||
}).join(' · ') + '</div>';
|
||
}
|
||
} catch(e) {}
|
||
|
||
return `
|
||
<div class="track-popup">
|
||
<div class="track-popup-name">${name}</div>
|
||
<div class="track-popup-row">${icon} ${actLabel}</div>
|
||
<div class="track-popup-row">📏 ${lengthKm} км · ${points} точек</div>
|
||
${dateStr ? `<div class="track-popup-row">📅 ${dateStr}</div>` : ''}
|
||
${user ? `<div class="track-popup-row">👤 ${user}</div>` : ''}
|
||
${sourcesHtml}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 `
|
||
<label class="gps-filter-chip">
|
||
<input type="checkbox" value="${act}" ${checked ? 'checked' : ''} onchange="onGpsActivityFilterChange()">
|
||
<span>${GPS_ACTIVITY_ICONS[act]} ${GPS_ACTIVITY_LABELS[act]}</span>
|
||
</label>`;
|
||
}).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 `
|
||
<label class="gps-filter-chip">
|
||
<input type="checkbox" value="${src}" ${checked ? 'checked' : ''} onchange="onGpsSourceFilterChange()">
|
||
<span>${label}</span>
|
||
</label>`;
|
||
}).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());
|
||
}
|
||
}
|
||
}
|