feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
573
src/web/gps_tracks.js
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user