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

This commit is contained in:
Stream
2026-05-06 01:00:01 +03:00
parent f45bd00c73
commit a280fd24f0
4 changed files with 302 additions and 43 deletions

View File

@@ -1,6 +1,6 @@
# BRD: Enduro Trails — Поиск точек маршрута
**Версия:** 1.0
**Версия:** 1.1
**Дата:** 2026-05-05
**Автор:** Стрим 🌊
**Статус:** 🔄 В разработке
@@ -17,7 +17,7 @@
## 2. Цель
Убрать верхний search bar. Добавить поиск прямо в список точек маршрута — inline, для каждой точки отдельно.
Убрать верхний search bar. Добавить поиск прямо в список точек маршрута — inline, для каждой точки отдельно. При первом открытии режима маршрута — онбординг с полями ввода старта и финиша.
---
@@ -30,9 +30,36 @@
- Удалить вызов `initSearch()` из JS
- Кнопку темы (`#btn-theme`) перенести в `#map-controls-r` (рядом с компасом и геолокацией)
**Почему:** bar занимал ~52px сверху, перекрывал карту, дублировал функционал который теперь встроен в waypoints.
### 3.2 Онбординг при активации режима маршрута
### 3.2 Inline поиск в каждой точке маршрута
Когда пользователь тапает «Маршрут» в toolbar и точек ещё нет — показывать вместо пустого списка:
```
┌─────────────────────────────────┐
│ 🔍 Откуда? │ ← поле поиска старта
│ или тапни на карте │ ← подсказка
└─────────────────────────────────┘
```
После выбора старта автоматически появляется:
```
┌─────────────────────────────────┐
│ S Название старта │ ← добавленная точка
├─────────────────────────────────┤
│ 🔍 Куда? │ ← поле поиска финиша
│ или тапни на карте │
└─────────────────────────────────┘
```
После выбора финиша — поля исчезают, маршрут строится, показывается обычный `wl-list`.
**Детали:**
- Поля поиска используют тот же Nominatim что и inline поиск в wl-item
- Подсказка «или тапни на карте» — мелкий текст `var(--text3)` под полем
- Тап на карте тоже работает — добавляет точку и переходит к следующему полю
- Если точки уже есть — онбординг не показывается, сразу wl-list
### 3.3 Inline поиск в каждой точке маршрута
В каждом `wl-item` добавить кнопку-лупу. Тап → разворачивается inline панель поиска прямо под точкой.
@@ -77,7 +104,7 @@
| Аспект | Решение |
|--------|---------|
| Поиск API | Nominatim `/search` (уже используется в проекте) |
| Debounce | 400ms (как в старом search bar) |
| Debounce | 400ms |
| Состояние | `wpSearchTimeout` — глобальная переменная для debounce |
| Закрытие панели | Тап на лупу повторно — закрывает; открытие другой — закрывает текущую |
| После выбора | `routeWaypoints[idx]` = `{lat, lon}`, rebuild + route |
@@ -85,12 +112,29 @@
---
## 6. Definition of Done
## 6. Баги после редизайна (требуют исправления)
| Баг | Описание | Вероятная причина |
|-----|---------|------------------|
| Метки в верхнем левом углу | Попап метки отображается не на позиции маркера, а в углу экрана | CSS `position` маркера сбивает позиционирование MapLibre |
| Метки линейки в верхнем углу | Попап точки линейки отображается не на треке, а в углу | Та же причина — CSS позиционирование маркера |
| Подсказка «Тапни на карте» пропала | `#route-status` не виден когда точек нет | Скрыт или перекрыт после редизайна |
**Вероятная причина багов с маркерами:** в редизайне изменились глобальные CSS стили для `position`, `transform` или `z-index` — это сбивает позиционирование MapLibre маркеров. Маркер MapLibre должен иметь `position: absolute` без переопределения в глобальных стилях.
**Фикс:** проверить CSS для `.maplibregl-marker`, `.marker-flag`, `.ruler-marker` — убрать любой `position: fixed/relative/static` который может переопределять поведение маркера.
---
## 7. Definition of Done
| Критерий | Статус |
|----------|--------|
| Верхний search bar отсутствует | ⬜ |
| Кнопка темы работает в map-controls-r | ⬜ |
| При активации маршрута без точек — поле поиска старта | ⬜ |
| После старта — поле поиска финиша | ⬜ |
| Подсказка «или тапни на карте» видна | ⬜ |
| Иконка лупы в каждом wl-item | ⬜ |
| Тап на лупу → inline поиск открывается | ⬜ |
| Поиск находит места через Nominatim | ⬜ |
@@ -98,37 +142,56 @@
| Маршрут перестраивается после выбора | ⬜ |
| Карта летит к выбранному месту | ⬜ |
| Одновременно открыта только одна панель | ⬜ |
| Метки отображаются на позиции маркера (не в углу) | ⬜ |
| Метки линейки отображаются на треке | ⬜ |
| Деплой + health check OK | ⬜ |
---
## 7. Файлы
## 8. Файлы
| Файл | Изменения |
|------|-----------|
| `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()` |
| `prototype/static/app.css` | Удалить стили search bar; добавить `.wl-search-*`; исправить позиционирование маркеров |
| `prototype/static/app.js` | Удалить `initSearch()`; добавить онбординг, `openWaypointSearch()`, `doWaypointSearch()`, `selectWaypointSearchResult()` |
---
## 8. Дополнительные требования (05.05.2026)
---
### 8.1 Онбординг при активации режима маршрута
## 9. Дополнительные замечания (05.05.2026 — пакет 2)
Когда пользователь тапает кнопку «Маршрут» в toolbar и точек ещё нет — показывать:
| # | Замечание | Детали |
|---|-----------|--------|
| 1 | Иконка колеса | Заменить на нормальное мотокросс колесо |
| 2 | Флажок финиша | Чёрно-белая шахматная раскраска как финишный флаг |
| 3 | Спиннер в основном листе | Вращающееся колесо пока строится маршрут (как в мини-баре) |
| 4 | Тёмная карта | При тёмной теме — тёмный стиль карты, при светлой — светлый |
| 5 | Автозум на маршрут | Плавный `fitBounds` после построения маршрута, маршрут по центру без выхода за границы |
| 6 | Мини-бар перекрывает кнопки справа | Исправить z-index/позиционирование `#sheet-route-mini` |
1. **Поле ввода/поиска для старта** с placeholder «Откуда? Введи название или тапни на карте»
2. После выбора старта — **поле ввода/поиска для финиша** с placeholder «Куда? Введи название или тапни на карте»
3. Подсказка под полем: «или тапни на карте» (мелкий текст, `var(--text3)`)
### Детали по пунктам
**UX flow:**
- Открылся sheet маршрута, точек нет → показать поле старта
- Пользователь вводит → Nominatim поиск → выбирает → точка S добавлена
- Автоматически появляется поле финиша
- Пользователь вводит или тапает на карте → точка F добавлена → маршрут строится
- После добавления обеих точек — поля исчезают, показывается обычный `wl-list`
**П.1 Иконка колеса:**
Заменить текущую SVG на нормальное мотокросс колесо — спицы, кноблинг, центральная втулка. Использовать Lucide `bike` или нарисовать SVG колеса с спицами.
### 8.2 Подсказка «Тапни на карте»
**П.2 Флажок финиша:**
В `waypointPinSvg('F', ...)` для финишной точки использовать шахматный узор (SVG `pattern` чёрные/белые клетки) вместо красного цвета.
`#route-status` с текстом «Тапни точку старта на карте» пропал после редизайна — восстановить видимость. Должен показываться когда точек нет или когда активен режим добавления точки (`addingWaypoint = true`).
**П.3 Спиннер в основном листе:**
В `#route-cards` показывать skeleton/спиннер пока идёт запрос к OSRM. Тот же спиннер что в мини-баре.
**П.4 Тёмная карта:**
`map.setStyle()` при смене темы. Тёмный стиль: `/style-dark.json` (или текущий тёмный), светлый: `/style-light.json` (или текущий светлый). После `setStyle` пересоздать слои маршрутов через `map.on('style.load', onMapStyleLoad)`.
**П.5 Автозум:**
После `drawRouteResults()` вызывать:
```js
const coords = routeResults[0].geometry.coordinates;
const bounds = coords.reduce((b, c) => b.extend(c), new maplibregl.LngLatBounds(coords[0], coords[0]));
map.fitBounds(bounds, { padding: 60, duration: 1000, maxZoom: 14 });
```
**П.6 Мини-бар:**
`#sheet-route-mini` перекрывает `#map-controls-r`. Исправить `right` или `width` мини-бара чтобы не залезал на кнопки справа.

View File

@@ -305,7 +305,7 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
.wl-add .wl-label { color: var(--text3); }
/* ── Route Status ─────────────────────────────── */
#route-status { font-size: 13px; color: var(--text2); padding: 8px 0; display: flex; align-items: center; gap: 6px; }
#route-status { font-size: 13px; color: var(--text2); padding: 8px 0; display: flex; align-items: center; gap: 6px; min-height: 20px; }
/* ── Route Cards ──────────────────────────────── */
#route-cards, #link-cards, #scenic-cards { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
@@ -451,6 +451,11 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
}
#ruler-info.visible { display: flex; align-items: center; gap: 8px; }
/* ── Fix: MapLibre markers must stay absolute ────── */
.maplibregl-marker {
position: absolute !important;
}
/* ── Waypoint Markers ─────────────────────────── */
.route-waypoint-marker { filter: drop-shadow(0 2px 4px rgba(0,0,0,0.4)); width: 28px; height: 36px; cursor: grab; display: block; }
.route-waypoint-marker:active { cursor: grabbing; }
@@ -509,6 +514,35 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
}
.marker-anim { animation: markerPopIn 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28) both; }
/* ── Onboarding (empty waypoints state) ─────────── */
.wl-onboarding {
padding: 4px 0;
}
.wl-onboard-field {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 12px 8px 0;
}
.wl-onboard-input {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 14px;
padding: 8px 12px;
outline: none;
box-sizing: border-box;
}
.wl-onboard-input:focus { border-color: var(--accent); }
.wl-onboard-hint {
text-align: center;
font-size: 12px;
color: var(--text3);
padding: 4px 0 8px;
}
/* ── Misc ────────────────────────────────────── */
.text-accent { color: var(--accent); }
.text-gold { color: var(--gold); }
@@ -595,7 +629,7 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
/* ── Mini Route Bar ───────────────────────── */
#sheet-route-mini {
position: fixed;
bottom: 72px; left: 0; right: 0;
bottom: 72px; left: 0; right: 56px;
height: 64px;
background: var(--surface);
border-top: 1px solid var(--border);
@@ -646,6 +680,23 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
#sheet-route-mini { left: 72px; width: 380px; right: auto; border-radius: 0 14px 0 0; }
}
/* ── Route Loading Spinner ───────────────────── */
.route-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 32px 16px;
}
.route-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* ── Moto Wheel Loading Indicator ────────────── */
.moto-wheel {
width: 32px; height: 32px;

View File

@@ -528,6 +528,27 @@ async function reverseGeocode(lat, lon) {
function waypointPinSvg(label, color) {
const fs = label.length > 1 ? '7' : '9';
// Finish flag — checkered pattern
if (label === 'F') {
const uid = Math.random().toString(36).slice(2);
return `<svg width="20" height="26" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="checker-${uid}" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
<rect width="4" height="4" fill="black"/>
<rect x="4" y="0" width="4" height="4" fill="white"/>
<rect x="0" y="4" width="4" height="4" fill="white"/>
<rect x="4" y="4" width="4" height="4" fill="black"/>
</pattern>
<clipPath id="pin-clip-${uid}">
<path d="M14 1C6.82 1 1 6.82 1 14C1 24 14 35 14 35C14 35 27 24 27 14C27 6.82 21.18 1 14 1Z"/>
</clipPath>
</defs>
<path d="M14 1C6.82 1 1 6.82 1 14C1 24 14 35 14 35C14 35 27 24 27 14C27 6.82 21.18 1 14 1Z" fill="url(#checker-${uid})" stroke="white" stroke-width="1.5"/>
<text x="14" y="19" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="9" font-weight="700" fill="white" stroke="black" stroke-width="0.5">${label}</text>
</svg>`;
}
return `<svg width="20" height="26" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 1C6.82 1 1 6.82 1 14C1 24 14 35 14 35C14 35 27 24 27 14C27 6.82 21.18 1 14 1Z" fill="${color}" stroke="white" stroke-width="1.5"/>
<text x="14" y="19" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="${fs}" font-weight="700" fill="white">${label}</text>
@@ -605,7 +626,29 @@ function getRouteSegmentDistances() {
async function renderWaypointsList() {
const list = document.getElementById('waypoints-list');
if (!routeWaypoints.length) { list.innerHTML = ''; return; }
// ── Onboarding: no waypoints yet ──────────────────────────────
if (!routeWaypoints.length) {
list.innerHTML = `
<div class="wl-onboarding">
<div class="wl-onboard-field" id="wl-onboard-start">
<div class="wl-pin">${waypointPinSvg('S', '#2EA043')}</div>
<div style="flex:1">
<input class="wl-onboard-input" id="wl-onboard-input-start"
type="text" placeholder="Откуда? Введи название..."
autocomplete="off" autocorrect="off">
<div class="wl-search-results" id="wl-onboard-results-start"></div>
</div>
</div>
<div class="wl-onboard-hint">или тапни на карте</div>
</div>`;
_initOnboardSearch('start');
_initWaypointDragHandles(list);
return;
}
// ── Onboarding: only start added, need finish ──────────────────
// (handled below after normal list render)
const gripSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/></svg>`;
@@ -649,8 +692,28 @@ async function renderWaypointsList() {
</div>`;
}
// Onboarding finish field: only start added, no finish yet
if (routeWaypoints.length === 1) {
html += `
<div class="wl-onboard-field" id="wl-onboard-finish">
<div class="wl-pin">${waypointPinSvg('F', '#FF3B1F')}</div>
<div style="flex:1">
<input class="wl-onboard-input" id="wl-onboard-input-finish"
type="text" placeholder="Куда? Введи название..."
autocomplete="off" autocorrect="off">
<div class="wl-search-results" id="wl-onboard-results-finish"></div>
</div>
</div>
<div class="wl-onboard-hint">или тапни на карте</div>`;
}
list.innerHTML = html;
// Init finish onboard search if only 1 waypoint
if (routeWaypoints.length === 1) {
_initOnboardSearch('finish');
}
// Async geocode
routeWaypoints.forEach(async (wp, i) => {
const name = await reverseGeocode(wp.lat, wp.lon);
@@ -662,6 +725,64 @@ async function renderWaypointsList() {
_initWaypointDragHandles(list);
}
// ─── Onboard search helpers ────────────────────────────────────────
function _initOnboardSearch(type) {
const input = document.getElementById(`wl-onboard-input-${type}`);
const resultsEl = document.getElementById(`wl-onboard-results-${type}`);
if (!input) return;
let timeout = null;
input.addEventListener('input', () => {
clearTimeout(timeout);
const q = input.value.trim();
if (q.length < 2) { resultsEl.innerHTML = ''; return; }
timeout = setTimeout(() => _doOnboardSearch(type, q, resultsEl), 400);
});
// Autofocus only for start field (finish field appears inline)
if (type === 'start') {
setTimeout(() => input.focus(), 100);
}
}
async function _doOnboardSearch(type, query, resultsEl) {
resultsEl.innerHTML = '<div class="wl-search-result-item"><span 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 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="_selectOnboardResult('${type}', ${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 style="color:var(--red)">Ошибка</span></div>';
}
}
function _selectOnboardResult(type, lat, lon, name) {
const wp = { lat: parseFloat(lat), lon: parseFloat(lon) };
if (type === 'start') {
routeWaypoints.unshift(wp);
} else {
routeWaypoints.push(wp);
}
rebuildWaypointMarkers();
renderWaypointsList();
window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 12, duration: 600 });
if (routeWaypoints.length >= 2) debounceBuildRoute();
updateMiniRouteCard();
}
function _initWaypointDragHandles(list) {
let dragIdx = -1;
let startY = 0;
@@ -817,6 +938,7 @@ async function buildRoute() {
// Show mini-bar with spinning wheel
showMiniRouteLoading();
showRouteLoading();
// Close main sheet if open
closeSheet('sheet-route');
@@ -905,6 +1027,23 @@ function drawRouteResults(routes, activeIdx) {
renderRouteCards(routes);
// Auto-zoom to active route after drawing
const activeRoute = routes[activeIdx] || routes[0];
if (activeRoute && activeRoute.geometry && activeRoute.geometry.coordinates) {
const coords = activeRoute.geometry.coordinates;
if (coords.length > 1) {
const bounds = coords.reduce(
(b, c) => b.extend(c),
new maplibregl.LngLatBounds(coords[0], coords[0])
);
map.fitBounds(bounds, {
padding: { top: 80, bottom: 160, left: 20, right: 20 },
duration: 1200,
maxZoom: 14
});
}
}
// Update mini sheet if visible
const miniEl = document.getElementById('sheet-route-mini');
if (miniEl && miniEl.classList.contains('visible')) showMiniRouteSheet();
@@ -2022,7 +2161,15 @@ function selectMiniRoute(idx) {
renderWaypointsList();
}
// ─── Mini Route Loading Indicator ─────────────────────────────────
// ─── Route Loading Indicators ────────────────────────────────────
function showRouteLoading() {
const el = document.getElementById('route-cards');
if (el) el.innerHTML = `<div class="route-loading">
<div class="route-spinner"></div>
<span style="color:var(--text3);font-size:13px">Строю маршрут...</span>
</div>`;
}
function showMiniRouteLoading() {
const wheel = document.getElementById('mini-wheel');
const statsEl = document.getElementById('mini-stats');

View File

@@ -228,22 +228,20 @@
<div class="mini-handle" id="mini-route-handle"></div>
<div class="mini-route-info">
<!-- Moto wheel loading indicator -->
<svg id="mini-wheel" class="moto-wheel" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<!-- Rim -->
<circle cx="20" cy="20" r="17" fill="none" stroke="var(--accent)" stroke-width="2.5"/>
<!-- Tyre (outer with knobby tread) -->
<circle cx="20" cy="20" r="19" fill="none" stroke="var(--text2)" stroke-width="1.5" stroke-dasharray="3 2"/>
<!-- Hub -->
<circle cx="20" cy="20" r="3" fill="var(--accent)"/>
<!-- Spokes (8) -->
<line x1="20" y1="3" x2="20" y2="17" stroke="var(--text2)" stroke-width="1.2" stroke-linecap="round"/>
<line x1="20" y1="23" x2="20" y2="37" stroke="var(--text2)" stroke-width="1.2" stroke-linecap="round"/>
<line x1="3" y1="20" x2="17" y2="20" stroke="var(--text2)" stroke-width="1.2" stroke-linecap="round"/>
<line x1="23" y1="20" x2="37" y2="20" stroke="var(--text2)" stroke-width="1.2" stroke-linecap="round"/>
<line x1="7.9" y1="7.9" x2="17.5" y2="17.5" stroke="var(--text2)" stroke-width="1.2" stroke-linecap="round"/>
<line x1="22.5" y1="22.5" x2="32.1" y2="32.1" stroke="var(--text2)" stroke-width="1.2" stroke-linecap="round"/>
<line x1="32.1" y1="7.9" x2="22.5" y2="17.5" stroke="var(--text2)" stroke-width="1.2" stroke-linecap="round"/>
<line x1="17.5" y1="22.5" x2="7.9" y2="32.1" stroke="var(--text2)" stroke-width="1.2" stroke-linecap="round"/>
<svg id="mini-wheel" class="moto-wheel" width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<!-- Обод -->
<circle cx="12" cy="12" r="10"/>
<!-- Втулка -->
<circle cx="12" cy="12" r="2"/>
<!-- Спицы (8 штук) -->
<line x1="12" y1="2" x2="12" y2="10"/>
<line x1="12" y1="14" x2="12" y2="22"/>
<line x1="2" y1="12" x2="10" y2="12"/>
<line x1="14" y1="12" x2="22" y2="12"/>
<line x1="4.93" y1="4.93" x2="10.59" y2="10.59"/>
<line x1="13.41" y1="13.41" x2="19.07" y2="19.07"/>
<line x1="19.07" y1="4.93" x2="13.41" y2="10.59"/>
<line x1="10.59" y1="13.41" x2="4.93" y2="19.07"/>
</svg>
<div class="mini-route-dot" id="mini-dot"></div>
<div class="mini-route-text">