diff --git a/tasks/enduro-trails/prototype/static/app.js b/tasks/enduro-trails/prototype/static/app.js
index f57ed70..41c4e99 100644
--- a/tasks/enduro-trails/prototype/static/app.js
+++ b/tasks/enduro-trails/prototype/static/app.js
@@ -1142,4 +1142,324 @@ function initRulerClicks(map) {
});
}
+// ─── Фаза 4: Разведка ───────────────────────────────────────────────────────
+let reconMode = false;
+let reconCenter = null;
+let reconRadius = 20;
+
+function toggleReconMode() {
+ reconMode = !reconMode;
+ const btn = document.getElementById('btn-recon');
+ if (reconMode) {
+ deactivateAllModes();
+ reconMode = true;
+ btn.classList.add('active');
+ window._map.getCanvas().style.cursor = 'crosshair';
+ } else {
+ btn.classList.remove('active');
+ window._map.getCanvas().style.cursor = '';
+ clearRecon();
+ }
+}
+
+function makeCircleGeoJSON(lon, lat, radiusKm) {
+ const coords = [];
+ for (let i = 0; i <= 64; i++) {
+ const a = (2 * Math.PI * i) / 64;
+ const dlat = (radiusKm / 111) * Math.cos(a);
+ const dlon = (radiusKm / (111 * Math.cos(lat * Math.PI / 180))) * Math.sin(a);
+ coords.push([lon + dlon, lat + dlat]);
+ }
+ return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] }, properties: {} };
+}
+
+async function doRecon(lon, lat) {
+ reconCenter = [lon, lat];
+ const map = window._map;
+ const panel = document.getElementById('recon-panel');
+ panel.style.display = 'block';
+ document.getElementById('recon-stats').innerHTML = 'Загружаю...';
+
+ // Draw circle
+ const circle = makeCircleGeoJSON(lon, lat, reconRadius);
+ if (map.getSource('recon-circle')) {
+ map.getSource('recon-circle').setData(circle);
+ } else {
+ map.addSource('recon-circle', { type: 'geojson', data: circle });
+ map.addLayer({
+ id: 'recon-circle-fill', type: 'fill', source: 'recon-circle',
+ paint: { 'fill-color': '#ff6600', 'fill-opacity': 0.08 }
+ });
+ map.addLayer({
+ id: 'recon-circle-stroke', type: 'line', source: 'recon-circle',
+ paint: { 'line-color': '#ff6600', 'line-width': 2, 'line-opacity': 0.5 }
+ });
+ }
+
+ // API call
+ const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
+ try {
+ const resp = await fetch(`${basePath}/api/recon`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ lon, lat, radius_km: reconRadius })
+ });
+ const data = await resp.json();
+ const t = data.trails || {};
+ const p = data.poi || {};
+ document.getElementById('recon-stats').innerHTML = `
+
🛤 ${t.total_count || 0} грунтовок · ${t.total_km || 0} км
+ 🟡 Lev1-2: ${t.lev12_count || 0} шт · ${t.lev12_km || 0} км
+ 🔴 Lev3-5: ${t.lev345_count || 0} шт · ${t.lev345_km || 0} км
+ 🔴 Тропы: ${t.path_count || 0} шт · ${t.path_km || 0} км
+
+ 💧 Озёра: ${p['natural=water'] || 0} · 👁 Виды: ${p['tourism=viewpoint'] || 0}
+ 🌊 Броды: ${p['ford=yes'] || 0} · 🏚 Руины: ${p['historic=ruins'] || 0}
+
+ `;
+ } catch(e) {
+ document.getElementById('recon-stats').innerHTML = 'Ошибка загрузки';
+ }
+}
+
+function setReconRadius(km) {
+ reconRadius = km;
+ document.querySelectorAll('.recon-radius-btn').forEach(b => {
+ b.classList.toggle('active', +b.dataset.km === km);
+ });
+ if (reconCenter) doRecon(reconCenter[0], reconCenter[1]);
+}
+
+function clearRecon() {
+ const map = window._map;
+ if (map.getLayer('recon-circle-fill')) map.removeLayer('recon-circle-fill');
+ if (map.getLayer('recon-circle-stroke')) map.removeLayer('recon-circle-stroke');
+ if (map.getSource('recon-circle')) map.removeSource('recon-circle');
+ document.getElementById('recon-panel').style.display = 'none';
+ reconCenter = null;
+}
+
+// ─── Фаза 4: Связка ───────────────────────────────────────────────────────────
+let linkMode = false;
+let linkPoints = [];
+let linkMarkers = [];
+
+function toggleLinkMode() {
+ linkMode = !linkMode;
+ const btn = document.getElementById('btn-link');
+ if (linkMode) {
+ deactivateAllModes();
+ linkMode = true;
+ btn.classList.add('active');
+ window._map.getCanvas().style.cursor = 'crosshair';
+ document.getElementById('route-panel').style.display = 'block';
+ document.querySelector('#route-panel > div:first-child').textContent = '🔗 Связка';
+ document.getElementById('route-status').textContent = 'Кликни первую точку';
+ document.getElementById('route-info').style.display = 'none';
+ document.getElementById('route-actions').style.display = 'none';
+ } else {
+ btn.classList.remove('active');
+ window._map.getCanvas().style.cursor = '';
+ clearLink();
+ }
+}
+
+function addLinkPoint(lng, lat) {
+ const map = window._map;
+ linkPoints.push({ lon: lng, lat: lat });
+ const idx = linkPoints.length;
+ const el = document.createElement('div');
+ el.style.cssText = 'width:16px;height:16px;background:#ff6600;border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff;';
+ el.textContent = idx;
+ const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: true }).setLngLat([lng, lat]).addTo(map);
+ linkMarkers.push(marker);
+
+ if (idx === 1) {
+ document.getElementById('route-status').textContent = 'Кликни вторую точку';
+ } else if (idx >= 2) {
+ buildLinkRoute();
+ }
+}
+
+async function buildLinkRoute() {
+ const map = window._map;
+ document.getElementById('route-status').textContent = '⏳ Ищу связку...';
+ const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
+ try {
+ const resp = await fetch(`${basePath}/api/route`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ waypoints: linkPoints, alternatives: 3 })
+ });
+ if (!resp.ok) throw new Error('Не найдена');
+ const data = await resp.json();
+ if (data.routes && data.routes.length > 0) {
+ renderRouteCards(data.routes, 'link');
+ document.getElementById('route-status').textContent = '✅ Связка найдена';
+ } else {
+ document.getElementById('route-status').textContent = '❌ Грунтовая связка не найдена';
+ }
+ } catch(e) {
+ document.getElementById('route-status').textContent = '❌ ' + e.message;
+ }
+}
+
+function clearLink() {
+ linkPoints = [];
+ linkMarkers.forEach(m => m.remove());
+ linkMarkers = [];
+ const map = window._map;
+ // Remove route layers
+ for (let i = 0; i < 5; i++) {
+ const lid = `link-line-${i}`;
+ if (map.getLayer(lid)) map.removeLayer(lid);
+ const sid = `link-src-${i}`;
+ if (map.getSource(sid)) map.removeSource(sid);
+ }
+ document.getElementById('route-panel').style.display = 'none';
+ document.getElementById('route-cards').innerHTML = '';
+}
+
+// ─── Фаза 4: Красивый маршрут ────────────────────────────────────────────────
+let scenicMode = false;
+let scenicStart = null;
+let scenicStartMarker = null;
+let scenicTargetKm = 100;
+let scenicRoutes = [];
+let activeScenicIdx = 0;
+
+function toggleScenicMode() {
+ scenicMode = !scenicMode;
+ const btn = document.getElementById('btn-scenic');
+ if (scenicMode) {
+ deactivateAllModes();
+ scenicMode = true;
+ btn.classList.add('active');
+ window._map.getCanvas().style.cursor = 'crosshair';
+ document.getElementById('scenic-panel').style.display = 'block';
+ document.getElementById('scenic-status').textContent = 'Кликни точку старта на карте';
+ } else {
+ btn.classList.remove('active');
+ window._map.getCanvas().style.cursor = '';
+ clearScenic();
+ }
+}
+
+function setScenicDist(km) {
+ scenicTargetKm = km;
+ document.querySelectorAll('.scenic-dist-btn').forEach(b => {
+ b.classList.toggle('active', +b.textContent === km);
+ });
+ const inp = document.getElementById('scenic-dist-input');
+ if (inp) inp.value = km;
+}
+
+async function buildScenicRoute() {
+ if (!scenicStart) return;
+ const map = window._map;
+ document.getElementById('scenic-status').textContent = '⏳ Строю красивый маршрут...';
+ document.getElementById('btn-build-scenic').textContent = '⏳ Строю...';
+ document.getElementById('btn-build-scenic').disabled = true;
+
+ const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
+ try {
+ const resp = await fetch(`${basePath}/api/scenic`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ lon: scenicStart.lon, lat: scenicStart.lat, target_km: scenicTargetKm })
+ });
+ if (!resp.ok) {
+ const err = await resp.json().catch(() => ({}));
+ throw new Error(err.detail || 'Ошибка');
+ }
+ const data = await resp.json();
+ scenicRoutes = data.routes || [];
+
+ if (scenicRoutes.length === 0) throw new Error('Маршрут не найден');
+
+ // Draw routes on map
+ const colors = ['#0066ff', '#00aa44', '#9933cc'];
+ scenicRoutes.forEach((r, i) => {
+ const geojson = { type: 'Feature', geometry: r.geometry, properties: {} };
+ const sid = `scenic-src-${i}`;
+ const lid = `scenic-line-${i}`;
+ if (map.getSource(sid)) map.removeSource(sid);
+ if (map.getLayer(lid)) map.removeLayer(lid);
+ map.addSource(sid, { type: 'geojson', data: geojson });
+ map.addLayer({
+ id: lid, type: 'line', source: sid,
+ paint: {
+ 'line-color': colors[i % colors.length],
+ 'line-width': i === activeScenicIdx ? 5 : 3,
+ 'line-opacity': i === activeScenicIdx ? 0.9 : 0.5,
+ },
+ layout: { 'line-cap': 'round', 'line-join': 'round' }
+ });
+ });
+
+ // Show cards
+ const cardsEl = document.getElementById('scenic-cards');
+ if (cardsEl) {
+ cardsEl.innerHTML = scenicRoutes.map((r, i) => {
+ const col = colors[i % colors.length];
+ const km = (r.distance_m / 1000).toFixed(0);
+ const time = formatDuration(r.duration_s);
+ const dirt = r.stats?.dirt_total_pct || '?';
+ const pois = (r.scenic_pois || []).map(p => {
+ const icon = {'natural=water':'💧','tourism=viewpoint':'👁','historic=ruins':'🏚','natural=peak':'🔺','natural=cave_entrance':'🕳','ford=yes':'🌊'}[p.type] || '📍';
+ return `${icon} ${p.name || p.type}
`;
+ }).join('');
+ return `
+
+
${dirt}% грунт · score=${r.scenic_score||0}
+ ${pois ? '
'+pois+'
' : ''}
+
`;
+ }).join('');
+ }
+
+ document.getElementById('scenic-status').textContent = `✅ ${scenicRoutes.length} маршрут(ов)`;
+ } catch(e) {
+ document.getElementById('scenic-status').textContent = '❌ ' + e.message;
+ }
+ document.getElementById('btn-build-scenic').textContent = '🎨 Построить маршрут';
+ document.getElementById('btn-build-scenic').disabled = false;
+}
+
+function selectScenicRoute(idx) {
+ activeScenicIdx = idx;
+ const map = window._map;
+ scenicRoutes.forEach((_, i) => {
+ const lid = `scenic-line-${i}`;
+ if (map.getLayer(lid)) {
+ map.setLayoutProperty(lid, 'visibility', 'visible');
+ map.setPaintProperty(lid, 'line-width', i === idx ? 5 : 3);
+ map.setPaintProperty(lid, 'line-opacity', i === idx ? 0.9 : 0.5);
+ }
+ });
+ document.querySelectorAll('#scenic-cards .route-card').forEach((c, i) => {
+ c.classList.toggle('active', i === idx);
+ });
+}
+
+function clearScenic() {
+ const map = window._map;
+ scenicRoutes.forEach((_, i) => {
+ const lid = `scenic-line-${i}`;
+ const sid = `scenic-src-${i}`;
+ if (map.getLayer(lid)) map.removeLayer(lid);
+ if (map.getSource(sid)) map.removeSource(sid);
+ });
+ if (scenicStartMarker) { scenicStartMarker.remove(); scenicStartMarker = null; }
+ scenicStart = null;
+ scenicRoutes = [];
+ document.getElementById('scenic-panel').style.display = 'none';
+ const cardsEl = document.getElementById('scenic-cards');
+ if (cardsEl) cardsEl.innerHTML = '';
+}
+
initMap();