feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 4s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 4s
CI / build (pull_request) Has been skipped

Backend:
- Миграция gps_tracks_001_init.sql: таблицы tracks + pipeline_runs
- Пакет src/api/gps_tracks/: models, db (WAL+upsert с dedup), dedup
  (bbox+length+date bucket-hash), mvt (LRU-кэш 1024 тайла), endpoint
  (GET /api/gps-tracks, GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt,
   GET /api/gps-tracks/health, POST /api/gps-tracks/cache/clear), config
- Парсеры: osm (split_bbox, haversine, defusedxml XXE-защита),
  enduro_russia + ttrails — заглушки (ADR-010/011 proposed, блокированы)
- Licensing guard: pipeline проверяет status ADR-файла до запуска источника
- scripts/gps_collect.py: CLI с --region/--source/--dry-run/--gc

Frontend:
- src/web/gps_tracks.js: двухрежимный слой (MVT z≤11, GeoJSON z≥12),
  debounced fetch + AbortController, фильтры активности/источника,
  цветовая палитра by-source/by-activity, halo на спутнике, popup трека,
  restorePublicTracksState(), localStorage persistence
- index.html: чекбокс «Публичные треки» в terrain-popup, #sheet-gps-filters
- app.css: .terrain-link-btn, .gps-filter-grid, .track-popup
- app.js: вызов restorePublicTracksState() в rebuildMapOverlays(),
  applyGpsHaloVisibility() в applyBaseLayer()

Конфиги:
- config/gps_sources.yaml: osm (enabled), enduro_russia/ttrails (disabled)
- config/gps_regions.yaml: ЦФО+Чувашия (enabled), Кавказ (disabled)

Docker:
- gps-collector service с profiles: [batch]

Тесты: 48 новых тестов (unit + integration), 125/125 pass

Refs: ET-008

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 12:28:54 +00:00
parent a0284e046b
commit 0060003f28
30 changed files with 3300 additions and 0 deletions

View File

@@ -1227,3 +1227,76 @@ body.satellite-active #btn-basemap {
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
}
/* ─── ET-008: GPS-треки ──────────────────────────── */
.terrain-link-btn {
display: block;
margin: 4px 0 0 24px;
background: none;
border: none;
color: var(--accent, #ff8c1a);
font-size: 12px;
cursor: pointer;
padding: 2px 0;
text-decoration: underline;
}
.gps-filter-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 12px;
}
.gps-filter-chip {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 13px;
color: var(--text);
}
.gps-filter-chip input[type=checkbox] {
accent-color: var(--accent, #ff8c1a);
width: 14px;
height: 14px;
}
.gps-stats-row {
font-size: 12px;
color: var(--text2);
margin-top: 8px;
}
/* Track popup */
.track-popup {
font-size: 13px;
color: var(--text, #fff);
min-width: 220px;
}
.track-popup-name {
font-weight: 700;
font-size: 14px;
margin-bottom: 6px;
}
.track-popup-row {
margin: 3px 0;
color: var(--text2, #ccc);
}
.track-popup-sources {
margin-top: 8px;
font-size: 12px;
}
.track-popup-sources a {
color: var(--accent, #ff8c1a);
text-decoration: none;
}
.track-popup-sources a:hover {
text-decoration: underline;
}

View File

@@ -134,6 +134,10 @@ function rebuildMapOverlays() {
restoreTerrainState();
restoreTrailsState();
restorePoiState();
// ET-008: публичные GPS-треки
if (typeof restorePublicTracksState === 'function') {
restorePublicTracksState();
}
// Re-apply recon circle if active
if (reconMode && reconCenter) {
@@ -3041,6 +3045,10 @@ function applyBaseLayer(base) {
// ET-007 P1-6: halo синхронизирован с состоянием чекбоксов
// «Грунтовки» / «Тропы», а не безусловно включён.
_applyTrailHaloVisibility(map, 'satellite');
// ET-008: halo публичных треков на спутнике
if (typeof applyGpsHaloVisibility === 'function') {
applyGpsHaloVisibility(map);
}
_applyPoiSatellitePaint(map, true);
_applyBackgroundForSatellite(map, true);
} else {
@@ -3057,6 +3065,10 @@ function applyBaseLayer(base) {
_setBodyClass('satellite-active', false);
// На «Схеме» halo всегда скрыт независимо от чекбоксов.
_applyTrailHaloVisibility(map, 'schematic');
// ET-008: halo публичных треков выключить
if (typeof applyGpsHaloVisibility === 'function') {
applyGpsHaloVisibility(map);
}
_applyPoiSatellitePaint(map, false);
_applyBackgroundForSatellite(map, false);
}

573
src/web/gps_tracks.js Normal file
View File

@@ -0,0 +1,573 @@
// ═══════════════════════════════════════════════════════════════════
// 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',
ttrails: '#4363d8',
offmaps: '#f58231',
nakarte: '#911eb4',
};
const GPS_FALLBACK_COLORS = ['#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', '#dcbeff', '#9A6324', '#fffac8'];
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', '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 }
};
// ─── Цветовые выражения 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 ──────────────────────────
function _ensureGpsSources(map) {
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
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: '© OpenStreetMap contributors (ODbL)',
});
}
if (!map.getSource(window.gpsTracksLayer.sourceGeoId)) {
map.addSource(window.gpsTracksLayer.sourceGeoId, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
}
}
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);
}
}
function _findGpsInsertPosition(map) {
const style = map.getStyle && map.getStyle();
if (!style || !style.layers) return undefined;
const routeLayer = style.layers.find(l => l.id === 'route-line' || l.id.startsWith('route-'));
return routeLayer ? routeLayer.id : 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 = ''; });
});
}
// ─── Включение/выключение слоя ────────────────────────────────────
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) {
_ensureGpsSources(map);
_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) {
_buildGpsFiltersUI();
openSheet('sheet-gps-filters');
} else {
closeAllSheets();
}
}
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('');
}
// Источники (из localStorage или дефолт)
const srcGrid = document.getElementById('gps-source-grid');
if (srcGrid) {
const allSources = ['osm', 'enduro_russia', 'ttrails'];
const sourceLabels = { osm: 'OSM', enduro_russia: 'EnduroRussia.ru', 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 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();
}
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.
*/
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) {
_ensureGpsSources(map);
_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());
}
}
}

View File

@@ -72,6 +72,17 @@
<span>Тропы</span>
</label>
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
<!-- ET-008: публичные GPS-треки -->
<label class="terrain-checkbox">
<input type="checkbox" id="public-tracks-cb" onchange="onPublicTracksCheckbox()">
<span>Публичные треки</span>
</label>
<span class="terrain-hint" id="public-tracks-zoom-hint" style="display:none">Зум 8+</span>
<button class="terrain-link-btn" id="public-tracks-filters-btn"
onclick="togglePublicTracksFiltersSheet()" style="display:none">
Фильтры…
</button>
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
<label class="terrain-checkbox">
<input type="checkbox" id="poi-visible-cb" onchange="onPoiCheckbox()" checked>
<span>POI</span>
@@ -463,6 +474,31 @@
</div>
</div>
<!-- ── ET-008: Sheet «GPS-фильтры» ───────────────────────────────── -->
<div class="bottom-sheet" id="sheet-gps-filters">
<div class="sheet-handle"></div>
<div class="sheet-header">
<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"><path d="M3 6h18M7 12h10M11 18h2"/></svg>
<h2>Фильтры публичных треков</h2>
<button class="sheet-close" onclick="closeAllSheets()"></button>
</div>
<div class="sheet-body">
<div class="section-label">ТИП АКТИВНОСТИ</div>
<div id="gps-activity-grid" class="gps-filter-grid"></div>
<div class="section-label">ИСТОЧНИК</div>
<div id="gps-source-grid" class="gps-filter-grid"></div>
<div class="section-label">ЦВЕТ ЛИНИЙ</div>
<div class="seg-control">
<button class="seg-btn active" id="gps-color-by-source" onclick="onGpsColorModeChange('source')">По источнику</button>
<button class="seg-btn" id="gps-color-by-activity" onclick="onGpsColorModeChange('activity')">По активности</button>
</div>
<div class="gps-stats-row" id="gps-stats-row" style="margin-top:12px">
<span>Всего в области: <b id="gps-stat-total"></b></span>
<span style="margin-left:12px">Видны (фильтр): <b id="gps-stat-shown"></b></span>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/maplibre-gl@4.7.0/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/suncalc@1.9.0/suncalc.min.js"></script>
@@ -471,5 +507,7 @@
<script src="app.js"></script>
<!-- ET-006: gpx.js подключается после app.js — потребляет его глобали (ADR-002) -->
<script src="gpx.js"></script>
<!-- ET-008: публичные GPS-треки -->
<script src="gps_tracks.js"></script>
</body>
</html>