All checks were successful
Калибровка существующих tier-таблиц `build_gps_mvt` / `_simplify_coords` (ADR-016), чтобы при первом открытии карты пользователь видел общее покрытие сети треков, а не пустую подложку. Backend (src/api/gps_tracks/mvt.py): - build_gps_mvt: добавлены тиры z<=5 (min_length=10 км, limit=1500) и z=6 (5 км / 2000); z=7+ — без изменений (регрессия). - _simplify_coords: tolerance для z=6 = 0.018° (~2 км), для z<=5 = 0.04° (~4 км); z=7+ не меняется. Frontend: - GPS_TRACKS_MIN_ZOOM понижен с 8 до 5; vector-source.minzoom подхватывает константу автоматически. - line-width / halo получили stop на z=5 (0.8 / 1.8 CSS-px), чтобы линия была читаема на любом DPR. - Hint #public-tracks-zoom-hint: «Зум 8+» → «Зум 5+». Тесты: - 8 unit zoom-tier (UT-Z5/6/7/8/12) — REQ-F-09. - 10 unit simplify (UT-SIMP-*) — REQ-F-10. - 9 integration endpoint z5-z7 (IT-Z5/6/7, CACHE, REGRESS) — REQ-F-11/12. - 2 perf (PERF-Z5-01/02; avg ~64 ms, p95 ~89 ms при 500 треках — ниже бюджета 200/500 ms по M-6) — REQ-F-13. Маркер @pytest.mark.perf, не в основном CI-gate. Контракт API /api/gps-tracks* не меняется (REQ-F-15); localStorage-ключи и конфиги тоже (REQ-F-16, F-18). Refs: ET-012 ADR: docs/work-items/ET-012/06-adr/ADR-016-z5-tiling-policy.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
900 lines
38 KiB
JavaScript
900 lines
38 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════
|
||
// gps_tracks.js — ET-008: Публичные GPS-треки
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
// ─── Константы ────────────────────────────────────────────────────
|
||
|
||
const GPS_TRACKS_ZOOM_CUTOFF = 12; // ниже — MVT, выше — GeoJSON
|
||
// ET-012 (ADR-016): порог понижен с 8 до 5, чтобы при обзорном зуме
|
||
// пользователь видел общее покрытие сети треков. Серверная сторона
|
||
// (build_gps_mvt z<=5 / z==6) даёт корректный размер MVT и читаемость.
|
||
const GPS_TRACKS_MIN_ZOOM = 5; // ниже — слой скрыт
|
||
|
||
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,
|
||
// ET-012 (REQ-F-05): stop на z=5 = 0.8 CSS-px. На 1×-дисплеях это
|
||
// даёт 1 физ.px (с округлением GPU), на 2× — 1.6, на 3× — 2.4.
|
||
// Линия гарантированно видна на любом DPR.
|
||
'line-width': ['interpolate', ['linear'], ['zoom'],
|
||
5, 0.8,
|
||
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',
|
||
// ET-012 (REQ-F-06): halo на z=5 = 1.8 CSS-px при основной линии 0.8 px
|
||
// (соотношение ~2.25×). Ореол не «съедает» линию: по 0.5 px с каждой
|
||
// стороны, остаётся видна цветная сердцевина.
|
||
'line-width': ['interpolate', ['linear'], ['zoom'],
|
||
5, 1.8,
|
||
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 «Зум 5+» (ET-012: порог переехал автоматически через GPS_TRACKS_MIN_ZOOM)
|
||
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 при клике ──────────────────────────────────────────────
|
||
|
||
// ET-011: SVG-иконка «download», копия из index.html sheet-route::downloadGPX
|
||
// (см. ADR-014 §3.a). Inline-SVG, чтобы popup не зависел от внешнего ассета.
|
||
const _GPS_DOWNLOAD_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||
|
||
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) {}
|
||
|
||
// ET-011 / REQ-F-01: кнопка «Скачать» в popup публичного трека.
|
||
// Безопасно используем числовой id (FastAPI Path int ge=1 на сервере),
|
||
// но всё равно делаем явный Number() — на случай, если MVT отдал строку.
|
||
const trackId = Number(props.id);
|
||
const actionsHtml = Number.isFinite(trackId) && trackId > 0
|
||
? `<div class="track-popup-actions">
|
||
<button type="button"
|
||
class="track-popup-download-btn"
|
||
aria-label="Скачать GPX"
|
||
title="Скачать GPX"
|
||
data-track-id="${trackId}">
|
||
${_GPS_DOWNLOAD_ICON_SVG}
|
||
</button>
|
||
</div>`
|
||
: '';
|
||
|
||
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>` : ''}
|
||
${actionsHtml}
|
||
${sourcesHtml}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ─── ET-011: Скачивание GPX из popup ─────────────────────────────
|
||
|
||
/**
|
||
* ET-011 (ADR-014 §3): парсит заголовок Content-Disposition и возвращает имя
|
||
* файла. Приоритет — `filename*=UTF-8''<percent-encoded>` (RFC 5987);
|
||
* fallback — `filename="…"`; при отсутствии обоих — null.
|
||
*
|
||
* @param {string|null} cd
|
||
* @returns {string|null}
|
||
*/
|
||
function _parseFilenameFromCD(cd) {
|
||
if (!cd) return null;
|
||
// RFC 5987: filename*=UTF-8''<encoded>
|
||
const ext = cd.match(/filename\*=UTF-8''([^;]+)/i);
|
||
if (ext && ext[1]) {
|
||
try {
|
||
return decodeURIComponent(ext[1].trim());
|
||
} catch (_) {
|
||
// битый percent-encoding — упадём в обычный filename
|
||
}
|
||
}
|
||
const plain = cd.match(/filename="([^"]+)"/i) || cd.match(/filename=([^;]+)/i);
|
||
if (plain && plain[1]) return plain[1].trim();
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* ET-011 (ADR-014 §3.b): человекочитаемое сообщение по HTTP-статусу.
|
||
*
|
||
* @param {number} status
|
||
* @param {object} body уже распарсенный JSON ответа (может быть пустым)
|
||
*/
|
||
function _handleDownloadError(status, body) {
|
||
if (typeof showToast !== 'function') return;
|
||
if (status === 403) {
|
||
// ADR-015 §G: backend отдаёт одноуровневый JSON
|
||
// {"detail":"source_forbidden","external_urls":[...]}
|
||
// Защитный fallback на старую форму {"detail":{"external_urls":[...]}}
|
||
// оставлен на случай legacy-обёрток (см. P2-01 в 12-review.md).
|
||
const urls = (body && body.external_urls)
|
||
|| (body && body.detail && body.detail.external_urls);
|
||
const firstUrl = Array.isArray(urls) && urls.length ? urls[0] : null;
|
||
if (firstUrl) {
|
||
showToast(`Источник запрещает скачивание. Откройте трек на сайте источника: ${firstUrl}`);
|
||
} else {
|
||
showToast('Источник запрещает скачивание. Откройте трек на сайте источника.');
|
||
}
|
||
} else if (status === 404) {
|
||
showToast('Трек не найден.');
|
||
} else if (status === 413) {
|
||
showToast('Трек слишком большой для скачивания.');
|
||
} else if (status === 400) {
|
||
showToast('Неподдерживаемый формат файла.');
|
||
} else {
|
||
showToast('Не удалось скачать. Попробуйте ещё раз.');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* ET-011: скачивает GPX для трека с публичного слоя.
|
||
* Использует тот же паттерн (fetch → Blob → URL.createObjectURL → a.download),
|
||
* что и app.js::downloadGPX(), — он уже отлажен на iOS Safari (BRD R-1).
|
||
*
|
||
* @param {number|string} trackId
|
||
* @param {HTMLElement|null} btnEl кнопка, на которой показываем индикатор
|
||
*/
|
||
async function _downloadPublicTrack(trackId, btnEl) {
|
||
if (btnEl) btnEl.classList.add('is-loading');
|
||
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
|
||
const url = `${basePath}/api/gps-tracks/${encodeURIComponent(trackId)}/download`;
|
||
try {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) {
|
||
let body = {};
|
||
try { body = await resp.json(); } catch (_) {}
|
||
_handleDownloadError(resp.status, body);
|
||
return;
|
||
}
|
||
const blob = await resp.blob();
|
||
const filename = _parseFilenameFromCD(resp.headers.get('Content-Disposition'))
|
||
|| `track-${trackId}.gpx`;
|
||
const objectUrl = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = objectUrl;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
// Освобождаем blob чуть позже — Safari иногда отменяет скачивание,
|
||
// если revoke сработал синхронно с click().
|
||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||
} catch (err) {
|
||
if (typeof showToast === 'function') {
|
||
showToast('Не удалось скачать. Попробуйте ещё раз.');
|
||
}
|
||
} finally {
|
||
if (btnEl) btnEl.classList.remove('is-loading');
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
const popup = new maplibregl.Popup({ closeOnClick: true, maxWidth: '300px' })
|
||
.setLngLat(e.lngLat)
|
||
.setHTML(_renderTrackPopupHtml(feature.properties))
|
||
.addTo(map);
|
||
|
||
// ET-011 / ADR-014 §3.b: делегированный обработчик клика на
|
||
// кнопку «Скачать». Popup в проекте перерисовывается при каждом
|
||
// открытии, так что листенер живёт ровно столько, сколько popup.
|
||
const popupEl = popup.getElement && popup.getElement();
|
||
if (popupEl) {
|
||
popupEl.addEventListener('click', (ev) => {
|
||
const btn = ev.target.closest && ev.target.closest('.track-popup-download-btn');
|
||
if (!btn) return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
const tid = btn.getAttribute('data-track-id');
|
||
if (!tid) return;
|
||
_downloadPublicTrack(tid, btn);
|
||
});
|
||
}
|
||
});
|
||
|
||
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());
|
||
}
|
||
}
|
||
}
|