auto-sync: 2026-05-06 00:40:01

This commit is contained in:
Stream
2026-05-06 00:40:01 +03:00
parent 9faa2270d7
commit 246c4f36ce
6 changed files with 304 additions and 84 deletions

View File

@@ -66,7 +66,15 @@
- Заменена на прямую кнопку «Скачать GPX» с download-иконкой (стрелка вниз)
- `onclick="downloadGPX()"` — без промежуточных диалогов
### ✅ Мини-бар маршрута
### ✅ Расстояние по маршруту между точками (добавлено 05.05.2026)
- Функция `getRouteSegmentDistances()` — snap каждого waypoint к ближайшей точке геометрии OSRM
- Первая точка → `snapIdx[0] = 0`, последняя → `snapIdx[n-1]` — гарантирует покрытие всей геометрии
- Сумма сегментов масштабируется к `route.distance_m` — точное совпадение (diff=0)
- Обновляется при смене варианта (`selectRoute`, `selectMiniRoute`)
- Обновляется после построения маршрута (`drawRouteResults``renderWaypointsList`)
- Fallback на haversine по прямой если маршрут ещё не построен
- Форматирование `toFixed(1)` везде (сегменты, карточки вариантов, мини-бар)
- `#mini-route-bar` — компактная полоска поверх карты когда sheet свёрнут
- Показывает активный маршрут (дистанция, % грунт)
@@ -83,6 +91,9 @@
| Web Share API не работал на HTTP | API требует HTTPS | Убран share dialog, оставлено только скачивание GPX |
| Drag-and-drop не работал на десктопе | Только touch-события | Добавлены mouse-события (mousedown/mousemove/mouseup) |
| CSS классы диалога не совпадали с JS | Dev добавил JS с одними классами, CSS с другими | Унифицированы, затем диалог полностью убран |
| Расстояние между точками по прямой | `renderWaypointsList` вызывался до построения маршрута, `routeResults` был пуст | Добавлен `renderWaypointsList()` после `drawRouteResults()` |
| Расстояние не обновлялось при смене варианта | `selectRoute`/`selectMiniRoute` не вызывали `renderWaypointsList` | Добавлен `renderWaypointsList()` в обе функции |
| Деплой статики не работал | `deploy_app2.js` копирует только `app.py`; образ перезаписывает статику при рестарте | SFTP → `docker restart``docker cp` (порядок важен!) |
---
@@ -100,6 +111,8 @@
| Десктоп: боковая панель, не ломается layout | ✅ |
| GPX скачивание работает | ✅ |
| Drag-and-drop точек маршрута (touch + mouse) | ✅ |
| Расстояние по маршруту между точками (сумма = route.distance_m) | ✅ |
| Обновление расстояний при смене варианта маршрута | ✅ |
| Деплой + health check OK | ✅ |
---
@@ -109,7 +122,21 @@
- **MapLibre GL JS** 4.7.0 (CDN)
- **SunCalc** 1.9.0 (CDN) — авто-тема по восходу/закату
- Без npm-зависимостей, всё inline CSS/JS
- Деплой: Node.js ssh2 → SFTP → docker cp → docker restart
- Деплой: Node.js ssh2 → SFTP на сервер → `docker restart``docker cp` после рестарта
### ⚠️ Критически важно: порядок деплоя статики
Образ Docker запекает статику при сборке. При `docker restart` образ перезаписывает `/app/static/`. Поэтому:
1. SFTP загрузить файлы на сервер (`/home/slin/enduro-trails/prototype/static/`)
2. `docker restart prototype-enduro-trails-1`
3. Подождать 8 секунд
4. `docker cp /home/slin/.../app.js prototype-enduro-trails-1:/app/static/app.js`
5. `docker cp /home/slin/.../app.css prototype-enduro-trails-1:/app/static/app.css`
6. `docker cp /home/slin/.../index.html prototype-enduro-trails-1:/app/static/index.html`
`deploy_app2.js`**только для `app.py`** (бэкенд).
`deploy_static.js` — SFTP статики, но cp нужно делать вручную после рестарта.
---

View File

@@ -0,0 +1,115 @@
# BRD: Enduro Trails — Поиск точек маршрута
**Версия:** 1.0
**Дата:** 2026-05-05
**Автор:** Стрим 🌊
**Статус:** 🔄 В разработке
---
## 1. Контекст и проблема
Сейчас добавить точку маршрута можно только тапом на карту. Если нужно попасть в конкретное место (деревня, перекрёсток, POI) — нужно сначала найти его на карте, потом тапнуть. Это неудобно, особенно на мобиле.
Верхний search bar решал задачу навигации по карте, но не интегрирован с маршрутом — найденное место просто центрировало карту, не добавляя точку. Плюс bar занимал место и мешал карте.
---
## 2. Цель
Убрать верхний search bar. Добавить поиск прямо в список точек маршрута — inline, для каждой точки отдельно.
---
## 3. Что меняется
### 3.1 Убрать верхний search bar
- Удалить `#search-bar` и `#search-results` из HTML
- Удалить все CSS стили (`#search-bar`, `#search-input`, `#search-results`, `.search-result-item`, `.sri-*`)
- Удалить вызов `initSearch()` из JS
- Кнопку темы (`#btn-theme`) перенести в `#map-controls-r` (рядом с компасом и геолокацией)
**Почему:** bar занимал ~52px сверху, перекрывал карту, дублировал функционал который теперь встроен в waypoints.
### 3.2 Inline поиск в каждой точке маршрута
В каждом `wl-item` добавить кнопку-лупу. Тап → разворачивается inline панель поиска прямо под точкой.
**UX flow:**
1. Пользователь видит список точек маршрута (S → 1 → F)
2. Тапает иконку 🔍 рядом с нужной точкой
3. Под точкой появляется поле ввода с автофокусом
4. Вводит название места → через 400ms debounce → Nominatim поиск
5. Выбирает результат → точка обновляется, карта летит к ней, маршрут перестраивается
6. Панель закрывается
**Детали:**
- Одновременно открыта только одна панель поиска (остальные закрываются)
- Поиск через Nominatim (`countrycodes=ru`, `accept-language=ru`, limit=5)
- Результат: название + подзаголовок (область/район)
- После выбора: `routeWaypoints[idx]` обновляется, `rebuildWaypointMarkers()` + `renderWaypointsList()` + `debounceBuildRoute()`
- `map.flyTo()` к выбранному месту (zoom 13, duration 600ms)
---
## 4. UI компоненты
### Кнопка лупы в wl-item
- Иконка: Lucide `search` SVG (15×15)
- Цвет: `var(--text3)` → hover `var(--accent)`
- Позиция: между `.wl-info` и `.wl-drag-handle`
### Inline панель поиска
- Фон: `var(--surface)`, бордер сверху `var(--border)`
- Input: `var(--surface2)` фон, `var(--border)` бордер, focus → `var(--accent)` бордер
- Результаты: max-height 180px, scroll, hover `var(--surface2)`
- Каждый результат: название (bold) + подзаголовок (мелкий, `var(--text2)`)
### Кнопка темы (перенос)
- Переносится в `#map-controls-r` как `.map-btn`
- `updateThemeButtonIcon()` продолжает работать — находит `#btn-theme` по id
---
## 5. Технические детали
| Аспект | Решение |
|--------|---------|
| Поиск API | Nominatim `/search` (уже используется в проекте) |
| Debounce | 400ms (как в старом search bar) |
| Состояние | `wpSearchTimeout` — глобальная переменная для debounce |
| Закрытие панели | Тап на лупу повторно — закрывает; открытие другой — закрывает текущую |
| После выбора | `routeWaypoints[idx]` = `{lat, lon}`, rebuild + route |
| Кнопка «Добавить точку» | `wl-add` остаётся без изменений |
---
## 6. Definition of Done
| Критерий | Статус |
|----------|--------|
| Верхний search bar отсутствует | ⬜ |
| Кнопка темы работает в map-controls-r | ⬜ |
| Иконка лупы в каждом wl-item | ⬜ |
| Тап на лупу → inline поиск открывается | ⬜ |
| Поиск находит места через Nominatim | ⬜ |
| Выбор результата → waypoint обновляется | ⬜ |
| Маршрут перестраивается после выбора | ⬜ |
| Карта летит к выбранному месту | ⬜ |
| Одновременно открыта только одна панель | ⬜ |
| Деплой + health check OK | ⬜ |
---
## 7. Файлы
| Файл | Изменения |
|------|-----------|
| `prototype/static/index.html` | Удалить `#search-bar`, `#search-results`; перенести `#btn-theme` в `#map-controls-r` |
| `prototype/static/app.css` | Удалить стили search bar; добавить `.wl-search-btn`, `.wl-search-panel`, `.wl-search-input`, `.wl-search-results`, `.wl-search-result-item` |
| `prototype/static/app.js` | Удалить `initSearch()`; добавить `openWaypointSearch()`, `doWaypointSearch()`, `selectWaypointSearchResult()` |
---
*Следующая фаза после реализации: тестирование на мобиле.*

View File

@@ -45,10 +45,22 @@
### Деплой
```bash
# SFTP через ssh2: /tmp/deploy_app.js
# docker cp + restart (файлы запечены в образ)
# ВАЖНО: docker cp нужно делать ПОСЛЕ рестарта контейнера!
# Образ перезаписывает статику при рестарте.
# 1. Загрузить файлы на сервер через SFTP (deploy_static.js)
# 2. docker restart prototype-enduro-trails-1
# 3. Подождать 8 сек
# 4. docker cp /home/slin/enduro-trails/prototype/static/app.js prototype-enduro-trails-1:/app/static/app.js
# 5. docker cp /home/slin/enduro-trails/prototype/static/app.css prototype-enduro-trails-1:/app/static/app.css
# 6. docker cp /home/slin/enduro-trails/prototype/static/index.html prototype-enduro-trails-1:/app/static/index.html
# Для бэкенда (app.py):
docker cp /home/slin/enduro-trails/prototype/app.py prototype-enduro-trails-1:/app/app.py
docker restart prototype-enduro-trails-1
# deploy_app2.js — только для app.py!
# deploy_static.js — SFTP статики, но cp делать вручную после рестарта
```
---
@@ -84,7 +96,7 @@ docker restart prototype-enduro-trails-1
| F-13 | "Связка" | Соединить два трека грунтовками | ⏳ Бэклог | 4 |
| F-14 | "Разведка" | Грунтовки вокруг точки, статистика по типам, POI | ⏳ Бэклог | 4 |
| F-15 | "Народные треки" | OSM Traces, Wikiloc, Komoot, 4x4travel | ⏳ Бэклог | 8 |
| F-16 | Тёмная тема + редизайн | Две темы (авто/светлая/тёмная), SunCalc, мобильный UI, drag-and-drop точек | ✅ Готово | 5 |
| F-16 | Тёмная тема + редизайн | Две темы (авто/светлая/тёмная), SunCalc, мобильный UI, drag-and-drop точек, расстояние по маршруту | ✅ Готово | 5 |
| F-17 | PWA + офлайн | Service Worker, MBTiles, GPS-трекинг | ⏳ Бэклог | 7 |
---
@@ -156,6 +168,23 @@ docker restart prototype-enduro-trails-1
- Кнопка «Скачать GPX» (share dialog убран)
- Фикс: «Добавить точку» из мини-бара
- Десктоп-адаптив (toolbar слева, sheet max-width 400px)
- Расстояние по маршруту между точками (по геометрии OSRM, масштабирование к route.distance_m)
- Обновление расстояний при смене варианта маршрута
**Баги исправлены (05.05):**
- «Добавить точку» не работала — `addWaypointMode()` не устанавливала `routeMode = true`
- Drag-and-drop не работал на десктопе — добавлены mouse-события
- Расстояние в списке точек показывало haversine по прямой вместо маршрута
- При смене варианта маршрута расстояния не обновлялись (`selectRoute`/`selectMiniRoute` не вызывали `renderWaypointsList`)
- `renderWaypointsList` вызывался до построения маршрута → добавлен вызов после `drawRouteResults`
- Деплой статики через `deploy_app2.js` не работал (копировал только `app.py`) → нужен `deploy_static.js` + `docker cp` **после** рестарта контейнера (образ перезаписывает статику при рестарте)
**Технические решения:**
- `getRouteSegmentDistances()` — snap waypoints к геометрии маршрута, суммирование haversine по точкам, масштабирование к `route.distance_m`
- `snapIdx[0] = 0`, `snapIdx[last] = n-1` — принудительный snap первой/последней точки
- Деплой статики: SFTP → сервер → `docker restart``docker cp` (порядок важен!)
- `streaming.mode: "off"` в Telegram канале — убраны дублированные сообщения
- `send_voice.sh` — убрана отправка через `openclaw message send`, только генерация OGG + `MEDIA:` директива
### ⏳ Фаза 6 — SRTM рельеф
- F-12 «Горка» — макс набор высоты, мин дистанция
@@ -185,8 +214,11 @@ docker restart prototype-enduro-trails-1
| Retry TooBig: 5→3→1 | OSRM не даёт 5 альтернатив на длинных маршрутах |
| Retry NoSegment: radiuses=10000 | Точки далеко от дорог — нужен широкий snap |
| Timeout 60s для retry | Длинные маршруты строятся >30с |
| Node.js ssh2 для деплоя | SSH бинарник в контейнере требует glibc 2.38 |
| docker cp вместо compose build | Файлы запечены в образ, cp в running container быстрее |
| `docker cp` после рестарта (не до) | Образ перезаписывает статику при рестарте — cp нужен после того как контейнер поднялся |
| `deploy_app2.js` только для app.py | Скрипт не копирует статику — для фронтенда использовать `deploy_static.js` + ручной cp после рестарта |
| Масштабирование сегментов к `route.distance_m` | OSRM геометрия упрощена — haversine по точкам даёт ~0.2% погрешность; масштабирование даёт точное совпадение |
| `renderWaypointsList()` после `drawRouteResults()` | Список рендерится до построения маршрута — нужен повторный вызов когда `routeResults` заполнен |
| `streaming.mode: "off"` для Telegram | `partial` и `progress` шлют промежуточные сообщения — `off` даёт одно финальное |
| Сэмплирование каждые ~500м для stats | Без этого расчёт >30с на длинных маршрутах |
| Grid cache для calc_route_stats | Убирает повторные SQL запросы для близких точек |

View File

@@ -67,78 +67,60 @@ html, body {
#map { position: fixed; inset: 0; z-index: 0; }
/* ── Search Bar ──────────────────────────────── */
#search-bar {
position: fixed;
top: max(env(safe-area-inset-top, 0px), 12px);
left: 12px; right: 12px;
height: 50px;
/* Push MapLibre nav controls below search bar */
}
/* ── MapLibre nav controls position ──────────── */
.maplibregl-ctrl-top-left {
top: calc(max(env(safe-area-inset-top, 0px), 12px) + 58px) !important;
top: calc(max(env(safe-area-inset-top, 0px), 12px) + 8px) !important;
left: 12px !important;
}
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
display: flex; align-items: center;
padding: 0 6px 0 14px;
gap: 8px; z-index: 300;
box-shadow: var(--shadow);
transition: background 0.3s, border-color 0.3s;
/* ── Waypoint inline search ───────────────────── */
.wl-search-btn {
background: none;
border: none;
color: var(--text3);
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
align-items: center;
flex-shrink: 0;
transition: color 0.15s;
}
#search-bar:focus-within { border-color: var(--accent); }
#search-bar .sb-icon { color: var(--text3); flex-shrink: 0; width: 18px; height: 18px; }
#search-input {
flex: 1; background: none; border: none;
color: var(--text); font-size: 15px; outline: none; min-width: 0;
transition: color 0.3s;
.wl-search-btn:hover, .wl-search-btn:active { color: var(--accent); }
.wl-search-panel {
padding: 6px 8px 4px 8px;
border-top: 1px solid var(--border);
}
#search-input::placeholder { color: var(--text3); }
#btn-theme {
width: auto; min-width: 38px; height: 38px;
border-radius: 10px;
.wl-search-input {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text2);
display: flex; align-items: center; justify-content: center;
gap: 3px; cursor: pointer; flex-shrink: 0;
transition: all 0.15s; padding: 0 8px;
border-radius: 8px;
color: var(--text);
font-size: 13px;
padding: 7px 10px;
outline: none;
box-sizing: border-box;
}
#btn-theme:active { transform: scale(0.94); background: var(--surface3); }
#btn-theme svg { width: 16px; height: 16px; }
#theme-label {
font-size: 9px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text3); line-height: 1;
.wl-search-input:focus { border-color: var(--accent); }
.wl-search-results {
margin-top: 4px;
max-height: 180px;
overflow-y: auto;
}
/* Search results */
#search-results {
position: fixed;
top: calc(max(env(safe-area-inset-top, 0px), 12px) + 58px);
left: 12px; right: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px; z-index: 300;
box-shadow: var(--shadow);
overflow: hidden; display: none;
transition: background 0.3s, border-color 0.3s;
}
.search-result-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
.wl-search-result-item {
padding: 8px 10px;
cursor: pointer;
display: flex; align-items: center; gap: 10px;
transition: background 0.1s;
border-radius: 6px;
font-size: 13px;
color: var(--text);
}
.search-result-item:last-child { border-bottom: none; }
.search-result-item:active { background: var(--surface2); }
.search-result-item:hover { background: var(--surface2); }
.sri-icon { color: var(--text3); flex-shrink: 0; }
.sri-name { font-size: 14px; font-weight: 500; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sri-sub { font-size: 12px; color: var(--text2); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wl-search-result-item:hover, .wl-search-result-item:active {
background: var(--surface2);
}
.wl-search-result-name { font-weight: 500; }
.wl-search-result-sub { font-size: 11px; color: var(--text2); margin-top: 1px; }
/* ── Map Control Buttons ──────────────────────── */
#map-controls-r {
@@ -502,11 +484,9 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
}
.bottom-sheet.open { transform: translateX(0); }
.bottom-sheet.swiping { transition: none; }
#search-bar { left: 84px; right: 12px; max-width: 400px; }
#map-controls-r { right: 12px; bottom: 12px; }
#sheet-backdrop { display: none; }
#ruler-info { left: 84px; }
#search-results { left: 84px; right: 12px; max-width: 400px; }
}
/* ═══════════════════════════════════════════════════
@@ -610,8 +590,7 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
#btn-add-waypoint:hover { border-color: var(--accent); color: var(--accent); }
#btn-build-route { width: 100%; height: 42px; background: var(--accent); color: #fff; border: none; border-radius: 10px; font-size: 14px; font-weight: 700; cursor: pointer; margin-top: 8px; transition: background 0.15s; }
#btn-build-route:active { background: var(--accent-h); }
.search-result-name { font-size: 14px; font-weight: 500; color: var(--text); }
.search-result-detail { font-size: 12px; color: var(--text2); margin-top: 2px; }
/* ── Mini Route Bar ───────────────────────── */
#sheet-route-mini {

View File

@@ -627,10 +627,17 @@ async function renderWaypointsList() {
<span class="wl-label" id="wl-label-${i}">${coordText}</span>
${distStr ? `<span class="wl-dist">${distStr}</span>` : ''}
</div>
<button class="wl-search-btn" onclick="openWaypointSearch(${i})" title="Поиск">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</button>
<div class="wl-drag-handle" data-idx="${i}">${gripSvg}</div>
<button class="wl-remove" onclick="removeWaypoint(${i})" title="Удалить">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="wl-search-panel" id="wl-search-panel-${i}" style="display:none">
<input class="wl-search-input" id="wl-search-input-${i}" type="text" placeholder="Поиск места..." autocomplete="off" autocorrect="off">
<div class="wl-search-results" id="wl-search-results-${i}"></div>
</div>`;
}).join('');
@@ -1175,7 +1182,6 @@ async function initMap() {
checkDataAvailability();
initRouteClicks(map);
initRulerClicks(map);
initSearch();
renderMarkers();
// Apply theme on load
applyTheme();
@@ -1332,6 +1338,73 @@ function initRouteClicks(map) {
// ─── Поиск (Nominatim) ─────────────────────────────────────────────
let searchTimeout = null;
// ─── Waypoint inline search ────────────────────────────────────────
let wpSearchTimeout = null;
function openWaypointSearch(idx) {
// Close all other open panels
document.querySelectorAll('.wl-search-panel').forEach(p => {
if (p.id !== `wl-search-panel-${idx}`) p.style.display = 'none';
});
const panel = document.getElementById(`wl-search-panel-${idx}`);
if (!panel) return;
const isOpen = panel.style.display !== 'none';
panel.style.display = isOpen ? 'none' : 'block';
if (!isOpen) {
const input = document.getElementById(`wl-search-input-${idx}`);
if (input) {
input.value = '';
input.focus();
input.addEventListener('input', () => {
clearTimeout(wpSearchTimeout);
const q = input.value.trim();
if (q.length < 2) {
document.getElementById(`wl-search-results-${idx}`).innerHTML = '';
return;
}
wpSearchTimeout = setTimeout(() => doWaypointSearch(idx, q), 400);
});
}
}
}
async function doWaypointSearch(idx, query) {
const resultsEl = document.getElementById(`wl-search-results-${idx}`);
if (!resultsEl) return;
resultsEl.innerHTML = '<div class="wl-search-result-item"><span class="wl-search-result-name" style="color:var(--text3)">Поиск...</span></div>';
try {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=5&countrycodes=ru&accept-language=ru`;
const resp = await fetch(url);
const data = await resp.json();
if (!data.length) {
resultsEl.innerHTML = '<div class="wl-search-result-item"><span class="wl-search-result-name" style="color:var(--text3)">Ничего не найдено</span></div>';
return;
}
resultsEl.innerHTML = data.map(item => {
const parts = (item.display_name || '').split(', ');
const name = parts[0];
const sub = parts.slice(1, 3).join(', ');
return `<div class="wl-search-result-item" onclick="selectWaypointSearchResult(${idx}, ${item.lat}, ${item.lon}, '${name.replace(/'/g, "\\'")}')">
<div class="wl-search-result-name">${name}</div>
${sub ? `<div class="wl-search-result-sub">${sub}</div>` : ''}
</div>`;
}).join('');
} catch(e) {
resultsEl.innerHTML = '<div class="wl-search-result-item"><span class="wl-search-result-name" style="color:var(--red)">Ошибка поиска</span></div>';
}
}
function selectWaypointSearchResult(idx, lat, lon, name) {
routeWaypoints[idx] = { lat: parseFloat(lat), lon: parseFloat(lon) };
const panel = document.getElementById(`wl-search-panel-${idx}`);
if (panel) panel.style.display = 'none';
rebuildWaypointMarkers();
renderWaypointsList();
window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 13, duration: 600 });
if (routeWaypoints.length >= 2) debounceBuildRoute();
updateMiniRouteCard();
}
function initSearch() {
const input = document.getElementById('search-input');
const results = document.getElementById('search-results');

View File

@@ -18,17 +18,7 @@
<!-- Sheet backdrop -->
<div id="sheet-backdrop" onclick="closeAllSheets()"></div>
<!-- ── Search Bar ─────────────────────────── -->
<div id="search-bar">
<svg class="sb-icon" 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"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="text" id="search-input" placeholder="Поиск места..." autocomplete="off" autocorrect="off">
<button id="btn-theme" onclick="toggleTheme()" title="Переключить тему">
<svg id="theme-icon-sun" 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" style="display:none"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
<svg id="theme-icon-moon" 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="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
<span id="theme-label">Авто</span>
</button>
</div>
<div id="search-results"></div>
<!-- ── Ruler info ─────────────────────────── -->
<div id="ruler-info">
@@ -48,6 +38,10 @@
<button class="map-btn" onclick="locateMe()" title="Моё местоположение">
<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"><polygon points="3 11 22 2 13 21 11 13 3 11"/></svg>
</button>
<button class="map-btn" id="btn-theme" onclick="toggleTheme()" title="Переключить тему">
<svg id="theme-icon-sun" 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" style="display:none"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
<svg id="theme-icon-moon" 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="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>
</button>
</div>
<!-- ════════════════════════════════════════════