feat(web): POI visibility checkbox in terrain popup (ET-002) #5

Merged
admin merged 11 commits from feature/ET-002-poi-toggle into main 2026-05-21 19:36:21 +03:00
30 changed files with 2501 additions and 191 deletions

View File

@@ -11,3 +11,5 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- Agent system prompts (architect, developer, reviewer, tester, deployer)
- CI pipeline (Gitea Actions)
- Docker configuration
- ET-002: чекбокс «POI» в попапе рельефа — показ/скрытие маркеров POI
с сохранением состояния в localStorage (ключ `poi-visible`)

View File

@@ -48,6 +48,22 @@
6. Коммиты от имени claude-bot (git config user.name/email уже настроен).
7. Не использовать `--no-verify` без явного одобрения Owner.
## Фазы
| # | Название | Описание |
|---|----------|----------|
| PH-1 | MVP | Карта грунтовок + MVT тайлы |
| PH-2 | Routing | OSRM роутинг + базовый UI |
| PH-3 | Smart Route | Альтернативы, статистика, GPX |
| PH-4 | Advanced Routing | Красивый маршрут, связка, разведка |
| PH-5 | Redesign | Тёмная тема, mobile UI, UX |
| PH-6 | Terrain | Hillshade + гипсометрия + TRI |
| PH-7 | Barriers | Шлагбаумы, тротуары, слой препятствий |
| PH-8 | Elevation Profile | Профиль высот, режим «Горка» |
| PH-9 | PWA | Офлайн режим |
Детали каждой фазы: [docs/phases/](docs/phases/)
## Данные
- Terrain tiles: /home/slin/enduro-trails/data/terrain/ (hillshade, TRI, hypso)
- OSM данные: /home/slin/enduro-trails/data/osm/

View File

@@ -68,6 +68,22 @@ migrations/ — миграции БД
.openclaw/ — system prompts агентов
```
## Фазы
| # | Название | Описание |
|---|----------|----------|
| PH-1 | MVP | Карта грунтовок + MVT тайлы |
| PH-2 | Routing | OSRM роутинг + базовый UI |
| PH-3 | Smart Route | Альтернативы, статистика, GPX |
| PH-4 | Advanced Routing | Красивый маршрут, связка, разведка |
| PH-5 | Redesign | Тёмная тема, mobile UI, UX |
| PH-6 | Terrain | Hillshade + гипсометрия + TRI |
| PH-7 | Barriers | Шлагбаумы, тротуары, слой препятствий |
| PH-8 | Elevation Profile | Профиль высот, режим «Горка» |
| PH-9 | PWA | Офлайн режим |
Детали каждой фазы: [docs/phases/](docs/phases/)
## Лицензия
Данные: © OpenStreetMap contributors (ODbL)

View File

@@ -6,3 +6,15 @@
- [design/](./design/) — дизайн-токены, компоненты
- [operations/](./operations/) — runbook, мониторинг
- [api/](./api/) — OpenAPI спецификация
## Фазы
- [PH-1.mvp](./phases/PH-1.mvp/) — MVP: карта грунтовок + MVT тайлы
- [PH-2.routing](./phases/PH-2.routing/) — OSRM роутинг + базовый UI
- [PH-3.smart-route](./phases/PH-3.smart-route/) — Альтернативы, статистика, GPX
- [PH-4.advanced-routing](./phases/PH-4.advanced-routing/) — Красивый маршрут, связка, разведка
- [PH-5.redesign](./phases/PH-5.redesign/) — Тёмная тема, mobile UI, UX
- [PH-6.terrain](./phases/PH-6.terrain/) — Hillshade + гипсометрия + TRI
- [PH-7.barriers](./phases/PH-7.barriers/) — Шлагбаумы, тротуары, слой препятствий
- [PH-8.elevation-profile](./phases/PH-8.elevation-profile/) — Профиль высот, режим «Горка»
- [PH-9.pwa](./phases/PH-9.pwa/) — Офлайн режим

View File

@@ -0,0 +1,46 @@
---
type: phase-brd
phase_id: PH-1.mvp
title: "MVP: карта грунтовок ЦФО + MVT тайлы"
version: 1
status: done
created_at: 2026-05-18
authors:
- "agent:stream"
---
# PH-1 — MVP: карта грунтовок ЦФО
## Цель
Создать веб-карту с фокусом на грунтовые дороги: грунтовки и тропы — главный слой, асфальт — тусклый фон. Данные из OSM, отображение через MapLibre GL JS с кастомным стилем.
## Scope
- Загрузка OSM PBF (Geofabrik, ЦФО + Чувашия)
- Конвертация в SQLite/Spatialite (1.1M треков, 14K POI)
- Self-hosted MVT (vector tiles) через FastAPI
- MapLibre GL JS с кастомным стилем (грунтовки яркие, асфальт тусклый)
- Деплой через Docker Compose на mva154
## Что реализовано
- FastAPI backend с эндпоинтами для MVT тайлов
- SQLite/Spatialite БД с данными ЦФО + Чувашия
- Кастомный стиль MapLibre: цветовая дифференциация по типу покрытия (track grade 1-5)
- Docker Compose конфигурация
- nginx reverse proxy (`/enduro/` → контейнер)
## Ключевые решения
| Решение | Причина |
|---------|---------|
| MapLibre GL JS (не Leaflet) | WebGL, производительность, vector tiles |
| Vanilla JS (не React) | Простота, нет build step, быстрый старт |
| FastAPI (не Django) | Лёгкий, async, минимум зависимостей |
| SQLite/Spatialite (не PostGIS) | Портативность, zero-config, достаточно для 1 региона |
| Self-hosted MVT (не TileServer GL) | Меньше зависимостей, контроль над фильтрацией |
## Дата завершения
02.05.2026

View File

@@ -0,0 +1,43 @@
---
type: phase-brd
phase_id: PH-2.routing
title: "Роутинг: OSRM с кастомным эндуро-профилем"
version: 1
status: done
created_at: 2026-05-18
authors:
- "agent:stream"
---
# PH-2 — Роутинг: «Дикий путь»
## Цель
Добавить построение маршрутов с приоритетом грунтовых дорог. Использовать OSRM с кастомным Lua-профилем, оптимизированным под эндуро.
## Scope
- Кастомный профиль `enduro.lua` для OSRM (приоритет грунтовок, штраф за асфальт)
- Пересборка OSRM графа из OSM PBF (~5.2 GB)
- Базовый UI для построения маршрута (точка А → Б)
- Отображение маршрута на карте
## Что реализовано
- OSRM с профилем enduro.lua (веса: track > path > unclassified > tertiary > secondary)
- API эндпоинт `/api/route` (FastAPI → OSRM)
- UI: клик по карте для установки точек старта/финиша
- Отображение маршрута (GeoJSON LineString на карте)
- Docker-сервис OSRM в compose
## Ключевые решения
| Решение | Причина |
|---------|---------|
| OSRM (не GraphHopper) | Быстрый, проверенный, кастомный lua-профиль |
| Кастомный enduro.lua | Стандартные профили не учитывают грунтовки как приоритет |
| Swap 6 GB | OSRM граф требует ~5.2 GB RAM |
## Дата завершения
03.05.2026

View File

@@ -0,0 +1,43 @@
---
type: phase-brd
phase_id: PH-3.smart-route
title: "Умный маршрут: альтернативы, статистика, GPX"
version: 1
status: done
created_at: 2026-05-18
authors:
- "agent:stream"
---
# PH-3 — Умный маршрут
## Цель
Предоставить пользователю выбор из нескольких вариантов маршрута с разным балансом грунт/асфальт. Показать статистику покрытия и дать возможность экспорта в GPX.
## Scope
- До 5 альтернативных маршрутов с разным балансом грунт/асфальт
- Промежуточные точки (до 8)
- Статистика покрытия (% по типам: track, path, unclassified, tertiary, secondary, primary)
- GPX экспорт (трек + waypoints)
## Что реализовано
- Мульти-запрос к OSRM с варьированием весов профиля
- UI выбора альтернатив (карточки с превью и статистикой)
- Drag-and-drop промежуточных точек на карте
- Панель статистики: дистанция, время, % грунта/асфальта по типам
- GPX экспорт с метаданными (имя маршрута, waypoints, timestamps)
## Ключевые решения
| Решение | Причина |
|---------|---------|
| Варьирование весов (не OSRM alternatives) | OSRM alternatives даёт похожие маршруты; варьирование весов — реально разные |
| До 8 промежуточных точек | Баланс между гибкостью и UX |
| GPX 1.1 формат | Совместимость с большинством навигаторов |
## Дата завершения
04.05.2026

View File

@@ -0,0 +1,42 @@
---
type: phase-brd
phase_id: PH-4.advanced-routing
title: "Продвинутый роутинг: красивый маршрут, связка, разведка"
version: 1
status: done
created_at: 2026-05-18
authors:
- "agent:stream"
---
# PH-4 — Продвинутый роутинг
## Цель
Добавить три специализированных режима маршрутизации: «Красивый маршрут» (замкнутый круг через POI), «Связка» (соединить два трека грунтовками), «Разведка» (статистика грунтовок в радиусе).
## Scope
- **Красивый маршрут** — замкнутый круг заданной длины через живописные POI (озёра, виды, руины, броды)
- **Связка** — соединить два загруженных трека оптимальным грунтовым участком
- **Разведка** — показать статистику грунтовок в радиусе 20/50/100 км от точки
## Что реализовано
- Алгоритм «Красивый маршрут»: выбор POI в радиусе → TSP-оптимизация порядка → OSRM route
- UI загрузки GPX для режима «Связка»
- Алгоритм соединения: найти ближайшие точки двух треков → построить грунтовый мост
- «Разведка»: spatial query по SQLite → агрегация по типам → визуализация на карте (heatmap)
- Переключение режимов в toolbar
## Ключевые решения
| Решение | Причина |
|---------|---------|
| TSP через nearest-neighbor heuristic | Достаточно для 5-10 POI, O(n²) приемлемо |
| Spatialite для spatial queries | Уже есть в стеке, не нужен отдельный сервис |
| Радиусы 20/50/100 км | Покрывают типичные дневные маршруты |
## Дата завершения
04.05.2026

View File

@@ -0,0 +1,50 @@
---
type: phase-brd
phase_id: PH-5.redesign
title: "Редизайн: тёмная тема, mobile UI, UX"
version: 1
status: done
created_at: 2026-05-18
authors:
- "agent:stream"
---
# PH-5 — Редизайн
## Цель
Переработать UI/UX приложения: добавить тёмную/светлую тему с автопереключением, адаптировать под мобильные устройства, улучшить юзабилити (линейка, метки, поиск).
## Scope
- Тёмная/светлая тема (авто по SunCalc — переключение по закату/рассвету)
- Mobile UI: bottom sheets, toolbar, touch-оптимизация
- Линейка (измерение расстояний на карте)
- Метки (сохранение точек интереса, localStorage)
- Поиск (Nominatim geocoding)
- CSS custom properties для theming
- Синхронизация темы карты и UI
## Что реализовано
- Система тем: CSS custom properties, авто (SunCalc), ручное переключение
- Стиль карты MapLibre синхронизирован с UI-темой
- Bottom sheet для мобильных (маршрут, статистика, настройки)
- Toolbar с иконками (режимы, инструменты)
- Touch-события: long press для метки, swipe для bottom sheet
- Линейка: клик-клик для измерения, отображение дистанции
- Метки: создание, редактирование, удаление, persist в localStorage
- Поиск: Nominatim API, debounce, fly-to результату
## Ключевые решения
| Решение | Причина |
|---------|---------|
| SunCalc для авто-темы | Точное время заката для координат пользователя |
| CSS custom properties | Нативный theming без препроцессоров |
| Bottom sheets (не sidebar) | Мобильный паттерн, thumb-friendly |
| localStorage для меток | Простота, нет backend-зависимости |
## Дата завершения
05-06.05.2026

View File

@@ -0,0 +1,48 @@
---
type: phase-brd
phase_id: PH-6.terrain
title: "Рельеф: гипсометрия + hillshade + TRI"
version: 1
status: done
created_at: 2026-05-18
authors:
- "agent:stream"
---
# PH-6 — Рельеф
## Цель
Добавить визуализацию рельефа: гипсометрическая раскраска, hillshade (теневой рельеф), TRI (Terrain Ruggedness Index) для оценки сложности местности.
## Scope
- Загрузка и обработка SRTM 30м (NASA, public domain)
- Генерация raster tiles: гипсометрия, hillshade, TRI
- Раздача через nginx (статические файлы)
- UI: переключение слоёв рельефа, прозрачность
- Легенда для гипсометрии и TRI
## Что реализовано
- Pipeline обработки SRTM: скачивание → merge → reproject → tile generation
- Гипсометрия: цветовая шкала высот (зелёный → коричневый → белый)
- Hillshade: azimuth 315°, altitude 45°, z-factor 1.5
- TRI: классификация (flat, nearly flat, slightly rugged, rugged, very rugged)
- Raster tiles zoom 8-14, формат PNG
- nginx location для раздачи tiles
- UI: layer switcher с opacity slider
- Легенда с цветовой шкалой
## Ключевые решения
| Решение | Причина |
|---------|---------|
| Raster tiles (не Mapbox Terrain RGB) | Простота генерации, nginx отдаёт статику |
| SRTM 30м | Бесплатно, достаточно для ЦФО (равнина) |
| Zoom 8-14 | Баланс между детализацией и объёмом данных |
| TRI как отдельный слой | Помогает оценить сложность без профиля высот |
## Дата завершения
12-14.05.2026

View File

@@ -0,0 +1,51 @@
---
type: phase-brd
phase_id: PH-7.barriers
title: "Барьеры: исключить шлагбаумы и тротуары, слой препятствий"
version: 1
status: active
created_at: 2026-05-18
authors:
- "agent:stream"
---
# PH-7 — Барьеры
## Цель
Сделать роутинг безопасным: маршрут не проходит через физические препятствия (шлагбаумы) и запрещённые для мотоциклов дороги (тротуары, пешеходные зоны). Добавить визуальный слой препятствий на карту.
## Scope
### F-07: Исключить шлагбаумы из OSRM
- Ноды с `barrier=gate|bollard|lift_gate|chain|cycle_barrier|motorcycle_barrier|border_control|block``mode.inaccessible` в OSRM
- `cattle_grid` и `ford` — оставить (проезжие)
### F-08: Исключить тротуары из OSRM
- Ways с `highway=footway|pedestrian|steps|corridor` → исключить из графа (return в process_way)
### F-10: Слой препятствий на карте
- Визуализация шлагбаумов, ворот, блоков на карте
- Иконки по типу барьера
- Popup с информацией (тип, OSM ID)
## Метрики успеха
- Маршрут через точку с шлагбаумом → OSRM обходит или возвращает "не найден"
- Маршрут в городе → не проходит по тротуарам
- Время пересборки графа ≤ 60 мин
- Существующие маршруты без шлагбаумов/тротуаров — не ломаются
## Зависимости
- OSRM граф (пересборка с обновлённым enduro.lua)
- OSM PBF данные (уже есть)
- Work item: [ET-001](../../work-items/ET-001/)
## Риски
| Риск | Митигация |
|------|-----------|
| Пересборка графа ~40 мин (сервис недоступен) | Пересобирать ночью или в low-traffic |
| Слишком много заблокированных нод → маршруты не строятся | cattle_grid и ford оставлены; тестировать на реальных маршрутах |
| OSRM RAM при пересборке | Swap 6 GB уже настроен |

View File

@@ -0,0 +1,35 @@
---
type: phase-brd
phase_id: PH-8.elevation-profile
title: "Профиль высот на маршруте, режим «Горка»"
version: 1
status: planned
created_at: 2026-05-18
authors:
- "agent:stream"
---
# PH-8 — Профиль высот
## Цель
Показать профиль высот построенного маршрута. Добавить режим «Горка» — поиск участков с максимальным перепадом высот (подъёмы/спуски).
## Scope
- Профиль высот: график elevation по дистанции для построенного маршрута
- Данные высот из SRTM 30м (уже есть в PH-6)
- Интерактивный график: hover → маркер на карте
- Статистика: набор высоты, сброс высоты, макс/мин высота
- Режим «Горка»: поиск участков с перепадом > N метров в радиусе
## Зависимости
- PH-6 (SRTM данные, terrain pipeline)
- PH-2/PH-3 (роутинг, маршруты)
## Открытые вопросы
- Формат графика: Canvas или SVG?
- Сэмплирование точек: каждые N метров или адаптивное?
- «Горка»: порог перепада настраиваемый или фиксированный?

View File

@@ -0,0 +1,37 @@
---
type: phase-brd
phase_id: PH-9.pwa
title: "PWA: офлайн режим, кэширование тайлов"
version: 1
status: planned
created_at: 2026-05-18
authors:
- "agent:stream"
---
# PH-9 — PWA офлайн
## Цель
Сделать приложение доступным офлайн: Service Worker для кэширования, предзагрузка тайлов для выбранного региона, сохранение маршрутов локально.
## Scope
- Service Worker: кэширование статики (JS, CSS, шрифты, иконки)
- Предзагрузка тайлов: выбрать область на карте → скачать тайлы для офлайн
- Кэширование vector tiles (MVT) и raster tiles (terrain)
- Офлайн-роутинг: сохранение построенных маршрутов для просмотра без сети
- Install prompt (Add to Home Screen)
- Manifest.json
## Зависимости
- PH-1 (MVT тайлы)
- PH-6 (raster terrain tiles)
- Все предыдущие фазы (UI, роутинг)
## Открытые вопросы
- Лимит кэша (сколько тайлов можно скачать?)
- Стратегия инвалидации кэша
- Офлайн-роутинг: только просмотр сохранённых или локальный OSRM?

View File

@@ -1,122 +0,0 @@
---
type: phase-brd
phase_id: pilot
title: "Enduro Trails — пилотный проект мультиагентной разработки"
version: 1
status: approved
created_at: 2026-05-15
authors:
- "agent:stream"
- "human:slava"
---
# BRD — Enduro Trails (пилотный проект)
## 1. Цель и метрика успеха
**Цель:** Создать веб-приложение для планирования эндуро-маршрутов с визуализацией грунтовых дорог, рельефа и навигацией — как пилотный проект мультиагентной системы разработки.
**Метрики успеха:**
- Рабочее приложение доступно по URL (https://openclaw.mva154.duckdns.org/enduro/)
- Покрытие региона: ЦФО + Чувашия (1.1M треков, 14K POI)
- Построение маршрута < 5 секунд
- Мобильный UI (PWA-ready)
- Агентный конвейер: фича от постановки до деплоя ≤ 4 часа
## 2. Стейкхолдеры
| Роль | Кто | Интерес |
|------|-----|---------|
| Заказчик / Owner | Слава | Использует для планирования поездок |
| Analyst | Стрим (OpenClaw) | BRD, ТЗ, координация |
| Разработка | Claude Code CLI агенты | Architect, Developer, Reviewer, Tester, Deployer |
## 3. Scope
### В скоупе
- Карта грунтовых дорог (MapLibre GL JS, кастомный стиль)
- Роутинг «Дикий путь» (OSRM, кастомный профиль enduro.lua)
- Альтернативные маршруты (до 5 вариантов)
- Промежуточные точки (до 8)
- Статистика покрытия (% грунт/асфальт по типам)
- «Красивый маршрут» (замкнутый круг через POI)
- «Связка» (соединить два трека)
- «Разведка» (грунтовки в радиусе)
- Рельеф (гипсометрия + hillshade, SRTM 30м)
- TRI (Terrain Ruggedness Index)
- Линейка, метки, GPX экспорт
- Тёмная/светлая тема (авто по SunCalc)
- Мобильный UI (bottom sheets, toolbar, touch)
- Поиск (Nominatim geocoding)
### Вне скоупа (v1)
- PWA офлайн режим
- GPS-трекинг в реальном времени
- Народные треки (Wikiloc, Komoot)
- Профиль высот на маршруте
- Мультирегион (вся Россия)
- Нативное мобильное приложение
## 4. Архитектура
### Компоненты
- **Frontend** — MapLibre GL JS, vanilla JS (ES modules), CSS custom properties
- **Backend API** — FastAPI (Python 3.12), uvicorn (4 workers)
- **Tile Server** — статические raster tiles через nginx
- **Vector Tiles** — MVT из SQLite (self-hosted, FastAPI)
- **Routing Engine** — OSRM с кастомным профилем `enduro.lua`
- **Database** — SQLite + Spatialite (431 MB)
- **Reverse Proxy** — nginx (`/enduro/` → контейнер)
### Инфраструктура
- Один сервер: mva154 (82.22.50.71)
- Docker Compose
- Gitea (git + CI)
- Plane (управление задачами)
### Данные
- OSM PBF (Geofabrik, ЦФО + Чувашия)
- SRTM 30м (NASA, public domain)
- OSRM граф (~5.2 GB)
## 5. Реализованные фазы
| Фаза | Описание | Статус | Дата |
|------|----------|--------|------|
| 1 | MVP: карта + MVT тайлы | ✅ | 02.05.2026 |
| 2 | Роутинг + базовый UI | ✅ | 03.05.2026 |
| 3 | Умный маршрут (альтернативы, статистика, GPX) | ✅ | 04.05.2026 |
| 4 | Продвинутый роутинг (красивый, связка, разведка) | ✅ | 04.05.2026 |
| 5 | Редизайн (тёмная тема, mobile UI, UX) | ✅ | 05-06.05.2026 |
| 5.4 | Рельеф (hillshade + гипсометрия + TRI) | ✅ | 12-14.05.2026 |
## 6. Бэклог
| Фаза | Описание | Приоритет |
|------|----------|-----------|
| 3.1 | Улучшение роутинга (шлагбаумы, тротуары, слой препятствий) | Высокий |
| 6 | SRTM продвинутый (профиль высот, «Горка») | Средний |
| 7 | PWA + офлайн | Средний |
| 8 | Народные треки | Низкий |
## 7. Ключевые решения
| Решение | Причина |
|---------|---------|
| MapLibre GL JS (не Leaflet) | WebGL, производительность, vector tiles |
| Vanilla JS (не React) | Простота, нет build step, быстрый старт |
| FastAPI (не Django) | Лёгкий, async, минимум зависимостей |
| SQLite/Spatialite (не PostGIS) | Портативность, zero-config, достаточно для 1 региона |
| OSRM (не GraphHopper) | Быстрый, проверенный, кастомный lua-профиль |
| Self-hosted MVT (не TileServer GL) | Меньше зависимостей, контроль над фильтрацией |
| Raster tiles для terrain (не Mapbox Terrain RGB) | Простота генерации, nginx отдаёт статику |
| Docker Compose | Один файл — весь стек |
## 8. Риски
| Риск | Митигация |
|------|-----------|
| OSRM граф большой (5.2 GB RAM) | Swap 6 GB настроен |
| SQLite не масштабируется | Миграция на PostGIS при необходимости |
| Один сервер — single point of failure | Бэкапы, Docker restart policy |
| SRTM 30м недостаточно для крутых склонов | Достаточно для ЦФО (равнина) |

View File

@@ -1,40 +0,0 @@
---
type: phase-plan
phase_id: pilot
title: "План пилотной фазы"
version: 1
status: active
---
# План пилотной фазы — Enduro Trails
## Текущий статус
Проект в стадии перехода от прототипа к управляемой мультиагентной разработке.
### Выполнено (прототип, 02-14.05.2026)
- Фазы 1-5.4 реализованы вручную (Стрим + Dev-агент)
- Все основные фичи работают
- Приложение доступно по URL
### В процессе (мультиагентная инфраструктура, 15.05.2026)
- ✅ Репо в Gitea с канонической структурой
- ✅ Claude Code CLI авторизован
- ✅ Service account claude-bot
- ✅ Branch protection
- ✅ CI pipeline
- ✅ System prompts агентов
- 🔄 Миграция прототипа в репо
### Следующие шаги
1. Merge миграции в main
2. Первая задача через полный агентный конвейер (Фаза 1 мультиагентного BRD)
3. Orchestrator MVP (Фаза 2 мультиагентного BRD)
## Приоритет фич (бэклог)
1. **F-07 + F-08** — Исключить шлагбаумы и тротуары из OSRM (пересборка графа)
2. **F-10** — Слой препятствий на карте
3. **F-09** — Больше альтернатив (penalized re-query)
4. Профиль высот на маршруте
5. PWA офлайн

View File

@@ -0,0 +1,20 @@
---
type: business-request
work_item_id: ET-002
title: "Чекбокс показа/скрытия POI на карте"
created_at: 2026-05-20
source: telegram
requester: Слава
---
# Исходный запрос — ET-002
## Формулировка заказчика
> На карте сейчас всегда отражаются маркеры POI. Нужен в кнопке рельефа добавить чекбокс показывать/не показывать POI.
## Контекст
- Канал: Telegram
- Дата: 2026-05-19
- Приоритет: не указан (обычный)

View File

@@ -0,0 +1,61 @@
---
type: brd
work_item_id: ET-002
title: "BRD: Чекбокс показа/скрытия POI в попапе рельефа"
version: 1
status: approved
created_at: 2026-05-20
authors:
- "agent:analyst"
---
# BRD — ET-002: Чекбокс показа/скрытия POI в попапе рельефа
## 1. Цель
Дать пользователю возможность скрывать маркеры POI на карте, чтобы они не загромождали обзор при планировании маршрута. Управление — через чекбокс в существующем попапе кнопки «Рельеф».
## 2. Контекст
- Сейчас POI (слои `poi-circles`, `poi-labels`) отображаются всегда при загрузке карты.
- Попап кнопки «Рельеф» (`terrain-popup`) уже содержит чекбоксы: Тени рельефа, Перепады, Грунтовки, Тропы.
- Механизм `toggleLayer('poi')` уже реализован в коде, но не привязан к UI в попапе.
## 3. Scope
### In scope
- Добавить чекбокс «POI» в попап кнопки «Рельеф» (после секции «Тропы»)
- POI включены по умолчанию (checked)
- Состояние чекбокса сохраняется в localStorage (ключ `poi-visible`)
- При загрузке страницы — восстанавливать состояние из localStorage
- Скрытие/показ слоёв `poi-circles` и `poi-labels` через MapLibre `setLayoutProperty`
### Out of scope
- Фильтрация POI по типу (кафе, заправки, и т.д.)
- Отдельная кнопка для POI вне попапа рельефа
- Изменение иконок или стилей POI
- Backend-изменения
## 4. Метрики успеха
| Метрика | Критерий |
|---------|----------|
| Чекбокс отображается в попапе | Визуально присутствует после «Тропы» |
| Снятие чекбокса скрывает POI | Слои `poi-circles` и `poi-labels` получают visibility: none |
| Установка чекбокса показывает POI | Слои получают visibility: visible |
| Состояние сохраняется | После перезагрузки страницы чекбокс и видимость POI соответствуют последнему выбору |
| Не ломает существующий функционал | Грунтовки, тропы, рельеф, роутинг работают как прежде |
## 5. Риски
| Риск | Вероятность | Митигация |
|------|-------------|-----------|
| Конфликт с существующим toggleLayer('poi') | Низкая | Использовать тот же механизм layerState, не дублировать логику |
| Попап становится слишком длинным на мобильных | Низкая | Один чекбокс — минимальное увеличение высоты |
## 6. Зависимости
- Нет внешних зависимостей
- Только фронтенд (vanilla JS + HTML)

View File

@@ -0,0 +1,98 @@
---
type: trz
work_item_id: ET-002
title: "ТЗ: Чекбокс показа/скрытия POI в попапе рельефа"
version: 1
status: approved
created_at: 2026-05-20
authors:
- "agent:analyst"
---
# ТЗ — ET-002: Чекбокс показа/скрытия POI в попапе рельефа
## 1. Функциональные требования
### REQ-F-01: Чекбокс POI в попапе рельефа
Система должна отображать чекбокс «POI» в попапе кнопки «Рельеф» (`terrain-popup`), расположенный после чекбокса «Тропы» (`trails-path-cb`), отделённый горизонтальной линией (`<hr>`).
### REQ-F-02: Состояние по умолчанию
При первом посещении (отсутствие ключа в localStorage) чекбокс POI должен быть включён (checked), маркеры POI видимы.
### REQ-F-03: Скрытие POI
При снятии чекбокса система должна установить `visibility: 'none'` для слоёв `poi-circles` и `poi-labels` через `map.setLayoutProperty()`.
### REQ-F-04: Показ POI
При установке чекбокса система должна установить `visibility: 'visible'` для слоёв `poi-circles` и `poi-labels`.
### REQ-F-05: Сохранение состояния
Система должна сохранять состояние чекбокса в `localStorage` под ключом `poi-visible` (значения: `'1'` — показывать, `'0'` — скрывать).
### REQ-F-06: Восстановление состояния при загрузке
При загрузке страницы система должна:
1. Прочитать значение `localStorage.getItem('poi-visible')`
2. Если значение `'0'` — снять чекбокс и скрыть слои POI
3. Если значение `'1'` или отсутствует (null) — установить чекбокс и показать слои POI
### REQ-F-07: Синхронизация с layerState
Изменение чекбокса должно обновлять `layerState.poi` (true/false), чтобы состояние было консистентно с внутренней моделью приложения.
## 2. Нефункциональные требования
### REQ-NF-01: Производительность
Переключение видимости POI должно происходить мгновенно (< 50 мс), без перезагрузки тайлов.
### REQ-NF-02: Совместимость
Решение должно работать во всех браузерах, поддерживающих MapLibre GL JS (Chrome 80+, Firefox 78+, Safari 14+, Edge 80+).
### REQ-NF-03: Мобильная адаптация
Чекбокс должен быть доступен и нажимаем на мобильных устройствах (touch target ≥ 44px).
### REQ-NF-04: Отсутствие регрессий
Существующие чекбоксы (Тени рельефа, Перепады, Грунтовки, Тропы) должны продолжать работать без изменений.
## 3. UI-спецификация
### Расположение в HTML
```html
<!-- Внутри #terrain-popup, после trails-path-cb -->
<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>
</label>
```
### Стилизация
Использовать существующий класс `terrain-checkbox` — единообразие с остальными чекбоксами в попапе.
## 4. Данные
- localStorage ключ: `poi-visible`
- Значения: `'1'` (показывать) | `'0'` (скрывать)
- MapLibre слои: `poi-circles`, `poi-labels`
- Состояние в JS: `layerState.poi`
## 5. API
Нет изменений в backend API. Вся логика — клиентская.
## 6. Затрагиваемые файлы
| Файл | Изменение |
|------|-----------|
| `src/web/index.html` | Добавить чекбокс в `terrain-popup` |
| `src/web/app.js` | Добавить функцию `onPoiCheckbox()`, логику восстановления состояния при загрузке |

View File

@@ -0,0 +1,85 @@
---
type: acceptance-criteria
work_item_id: ET-002
title: "Acceptance Criteria: Чекбокс POI"
version: 1
status: approved
created_at: 2026-05-20
authors:
- "agent:analyst"
---
# Acceptance Criteria — ET-002
## AC-01: Чекбокс отображается в попапе
```gherkin
Given пользователь открыл карту
When пользователь нажимает кнопку «Рельеф»
Then в попапе отображается чекбокс «POI» после секции «Тропы»
And чекбокс отделён горизонтальной линией от секции выше
```
## AC-02: POI включены по умолчанию
```gherkin
Given пользователь открыл карту впервые (нет ключа poi-visible в localStorage)
When попап рельефа открыт
Then чекбокс «POI» установлен (checked)
And маркеры POI видны на карте
```
## AC-03: Скрытие POI
```gherkin
Given чекбокс «POI» установлен
And маркеры POI видны на карте
When пользователь снимает чекбокс «POI»
Then маркеры POI исчезают с карты
And слои poi-circles и poi-labels имеют visibility: none
```
## AC-04: Показ POI
```gherkin
Given чекбокс «POI» снят
And маркеры POI скрыты
When пользователь устанавливает чекбокс «POI»
Then маркеры POI появляются на карте
And слои poi-circles и poi-labels имеют visibility: visible
```
## AC-05: Состояние сохраняется после перезагрузки
```gherkin
Given пользователь снял чекбокс «POI»
When пользователь перезагружает страницу
Then чекбокс «POI» снят
And маркеры POI скрыты
```
## AC-06: Восстановление включённого состояния
```gherkin
Given пользователь установил чекбокс «POI»
When пользователь перезагружает страницу
Then чекбокс «POI» установлен
And маркеры POI видны
```
## AC-07: Не ломает существующие чекбоксы
```gherkin
Given попап рельефа открыт
When пользователь переключает чекбоксы «Тени рельефа», «Перепады», «Грунтовки», «Тропы»
Then каждый чекбокс работает как прежде
And чекбокс «POI» не влияет на их поведение
```
## AC-08: Синхронизация с layerState
```gherkin
Given чекбокс «POI» снят
When внешний код читает layerState.poi
Then значение равно false
```

View File

@@ -0,0 +1,116 @@
---
type: test-plan
work_item_id: ET-002
title: "Test Plan: Чекбокс POI"
version: 1
status: approved
created_at: 2026-05-20
authors:
- "agent:analyst"
---
tests:
- id: TP-01
type: unit
description: "onPoiCheckbox() устанавливает visibility слоёв"
steps:
- "Mock map.getLayer() → true"
- "Mock map.setLayoutProperty()"
- "Установить checkbox.checked = false"
- "Вызвать onPoiCheckbox()"
expected:
- "setLayoutProperty('poi-circles', 'visibility', 'none') вызван"
- "setLayoutProperty('poi-labels', 'visibility', 'none') вызван"
- "localStorage.setItem('poi-visible', '0') вызван"
- "layerState.poi === false"
- id: TP-02
type: unit
description: "onPoiCheckbox() показывает слои при checked=true"
steps:
- "Mock map.getLayer() → true"
- "Установить checkbox.checked = true"
- "Вызвать onPoiCheckbox()"
expected:
- "setLayoutProperty('poi-circles', 'visibility', 'visible') вызван"
- "setLayoutProperty('poi-labels', 'visibility', 'visible') вызван"
- "localStorage.setItem('poi-visible', '1') вызван"
- "layerState.poi === true"
- id: TP-03
type: unit
description: "Восстановление состояния при загрузке — POI скрыты"
steps:
- "localStorage.setItem('poi-visible', '0')"
- "Вызвать функцию инициализации POI"
expected:
- "checkbox.checked === false"
- "layerState.poi === false"
- "Слои скрыты"
- id: TP-04
type: unit
description: "Восстановление состояния при загрузке — POI видны (default)"
steps:
- "localStorage не содержит ключ poi-visible"
- "Вызвать функцию инициализации POI"
expected:
- "checkbox.checked === true"
- "layerState.poi === true"
- id: TP-05
type: e2e
description: "Чекбокс POI виден в попапе рельефа"
steps:
- "Открыть карту в браузере"
- "Нажать кнопку «Рельеф»"
expected:
- "В попапе виден чекбокс с текстом «POI»"
- "Чекбокс расположен после «Тропы», отделён линией"
- id: TP-06
type: e2e
description: "Переключение POI скрывает/показывает маркеры"
steps:
- "Открыть карту, дождаться загрузки POI"
- "Открыть попап рельефа"
- "Снять чекбокс POI"
- "Убедиться что маркеры исчезли"
- "Установить чекбокс POI"
- "Убедиться что маркеры появились"
expected:
- "Маркеры POI скрываются и появляются в соответствии с чекбоксом"
- id: TP-07
type: e2e
description: "Состояние POI сохраняется после перезагрузки"
steps:
- "Открыть карту"
- "Снять чекбокс POI"
- "Перезагрузить страницу"
- "Открыть попап рельефа"
expected:
- "Чекбокс POI снят"
- "Маркеры POI не отображаются"
- id: TP-08
type: integration
description: "Чекбокс POI не влияет на другие слои"
steps:
- "Включить Тени рельефа и Грунтовки"
- "Снять чекбокс POI"
expected:
- "Тени рельефа остаются видимыми"
- "Грунтовки остаются видимыми"
- "Только POI скрыты"
- id: TP-09
type: e2e
description: "Мобильная доступность чекбокса"
steps:
- "Открыть карту на мобильном viewport (375px)"
- "Открыть попап рельефа"
- "Тапнуть чекбокс POI"
expected:
- "Чекбокс нажимается без проблем"
- "Touch target достаточный (≥ 44px)"

View File

@@ -0,0 +1,139 @@
---
type: adr
work_item_id: ET-002
adr_id: adr-0001
title: "ADR-0001: Управление видимостью POI — клиентское решение на localStorage"
status: accepted
created_at: 2026-05-21
authors:
- "agent:architect"
supersedes: []
superseded_by: []
labels: []
---
# ADR-0001 — Управление видимостью POI: клиентское решение на localStorage
## Статус
Accepted
## Контекст
ET-002 добавляет в попап кнопки «Рельеф» (`terrain-popup`) чекбокс «POI»,
позволяющий пользователю скрывать маркеры POI (слои `poi-circles`,
`poi-labels`), чтобы не загромождать карту при планировании маршрута.
Существующее состояние кодовой базы (`src/web/app.js`):
- объект `layerState = { tracks, paths, poi, basemap }` — внутренняя модель
видимости слоёв (строка 377), `poi: true` по умолчанию;
- карта групп слоёв `poi: ['poi-circles', 'poi-labels']` (строка 381);
- функция `toggleLayer(group)` уже переключает видимость через
`map.setLayoutProperty(..., 'visibility', ...)` (строки 385389), но
завязана на кнопку-тулбар (`btn.classList.toggle('active', ...)`), а не на
чекбокс попапа;
- сложился устойчивый паттерн персистентности в `localStorage` со
значениями `'1'`/`'0'`: ключи `terrain-hillshade`, `terrain-tri`,
`trails-track`, `trails-path`, `enduro-theme-mode`, `MARKERS_KEY`.
Backend (FastAPI) и БД (SQLite/Spatialite) к видимости POI отношения не
имеют — POI отдаются как часть тайлов/источника, видимость регулируется
исключительно на уровне рендера MapLibre.
## Рассматриваемые варианты
### Вариант A — Клиентское решение: `setLayoutProperty` + `localStorage` (выбран)
Видимость переключается через `map.setLayoutProperty()` для слоёв
`poi-circles` и `poi-labels`; состояние хранится в `localStorage` под
ключом `poi-visible`; `layerState.poi` остаётся единственным источником
истины в рантайме.
- **Плюсы:** нулевые изменения backend/БД/инфраструктуры; переключение
мгновенное (тайлы не перезагружаются — слой остаётся в источнике,
меняется только `layout.visibility`); полностью консистентно с уже
существующими чекбоксами попапа и принципом «минимум зависимостей».
- **Минусы:** настройка не синхронизируется между устройствами/браузерами
(для данной фичи это приемлемо и зафиксировано как Out of scope в BRD).
### Вариант B — Хранение настройки на backend (профиль пользователя)
Видимость POI сохраняется через новый API-эндпоинт в SQLite.
- **Плюсы:** синхронизация между устройствами.
- **Минусы:** требует модели пользователя/сессий (в проекте отсутствует),
новый эндпоинт, миграцию БД, сетевой round-trip — несопоставимо
избыточно для одного UI-флага. Противоречит принципам BRD (минимум
зависимостей, нет необходимости в server-state).
### Вариант C — Удаление/повторное добавление слоёв POI
Скрытие через `map.removeLayer()`, показ через `map.addLayer()`.
- **Плюсы:** нет «невидимых» слоёв в стиле.
- **Минусы:** дороже по производительности, риск рассинхрона порядка
слоёв и обработчиков событий (`map.on('click', 'poi-circles', ...)`,
строка 1515), усложняет код. `setLayoutProperty` — штатный механизм
MapLibre именно для этого сценария.
## Решение
Принимается **Вариант A**.
1. **Видимость** слоёв `poi-circles` и `poi-labels` переключается через
`map.setLayoutProperty(layerId, 'visibility', 'visible' | 'none')`.
2. **Персистентность**`localStorage`, ключ `poi-visible`, значения
`'1'` / `'0'`, в соответствии с уже сложившейся конвенцией проекта.
3. **Источник истины в рантайме**`layerState.poi`. Обработчик
`onPoiCheckbox()` обязан синхронно обновлять `layerState.poi`,
`localStorage` и `layout.visibility` обоих слоёв.
4. **Без дублирования логики.** Логика установки видимости группы POI
должна переиспользовать существующую карту групп слоёв
(`poi: ['poi-circles','poi-labels']`). Допускается выделение общего
приватного хелпера (например `applyPoiVisibility(visible)`),
вызываемого как из `onPoiCheckbox()`, так и из восстановления
состояния при загрузке. `toggleLayer('poi')` (тулбар-кнопка) и
`onPoiCheckbox()` (чекбокс попапа) должны оставаться консистентны
через общий `layerState.poi` — недопустимо, чтобы кнопка и чекбокс
показывали разное состояние.
5. **Backend, БД, API, инфраструктура — без изменений.** Состав
компонентов C4 не меняется, обновление C4-диаграмм не требуется.
## Последствия
### Положительные
- Изменение полностью клиентское: deploy без миграций и без изменения
состава контейнеров.
- Переключение < 50 мс (REQ-NF-01) — тайлы не перезагружаются.
- Конвенция `localStorage` остаётся единообразной — ниже когнитивная
нагрузка при поддержке.
### Отрицательные / ограничения
- Настройка локальна для браузера: при очистке `localStorage` или смене
устройства сбрасывается на дефолт (POI включены). Принято осознанно.
- В стиле карты остаются слои с `visibility: none` — это штатно для
MapLibre и не влияет на производительность.
### Технический долг
- `toggleLayer()` и `onPoiCheckbox()` частично дублируют намерение
«переключить группу слоёв». Если в будущих фазах появятся ещё
popup-чекбоксы для слоёв, стоит унифицировать тулбар-кнопки и
popup-чекбоксы в единый контроллер слоёв. На ET-002 — вне scope.
## Классификация изменения
Minor change. Новые сервисы/БД/эндпоинты не вводятся, состав компонентов
не меняется. Лейбл `arch:major-change` **не требуется**. Обязательного
архитектурного approve не требуется.
## Связанные документы
- `docs/work-items/ET-002/01-brd.md`
- `docs/work-items/ET-002/02-trz.md`
- `docs/work-items/ET-002/03-acceptance-criteria.md`
- `docs/work-items/ET-002/07-infra-requirements.md`
- `docs/architecture/README.md`

View File

@@ -0,0 +1,94 @@
---
type: infra-requirements
work_item_id: ET-002
title: "Инфраструктурные требования — ET-002: Чекбокс показа/скрытия POI"
version: 1
status: approved
created_at: 2026-05-21
authors:
- "agent:architect"
---
# Инфраструктурные требования — ET-002
## 1. Резюме
ET-002 — изменение **исключительно фронтенда** (`src/web/index.html`,
`src/web/app.js`). Новой инфраструктуры не требуется. Документ
зафиксирован для полноты work-item и явно подтверждает отсутствие
инфра-воздействия (см. ADR-0001).
## 2. Контейнеры и сервисы
| Аспект | Требование |
|--------|------------|
| Новые контейнеры | Нет |
| Изменения существующих сервисов (api, osrm, nginx) | Нет |
| Изменения `docker-compose.yml` | Нет |
| Изменения `Dockerfile` | Нет |
## 3. Сеть
| Аспект | Требование |
|--------|------------|
| Новые порты | Нет |
| Изменения reverse proxy (nginx, `/enduro/`) | Нет |
| Новые внешние домены / DNS | Нет |
| Исходящие сетевые вызовы из фронтенда | Нет (вся логика локальная) |
## 4. Хранилища данных
| Аспект | Требование |
|--------|------------|
| Изменения схемы SQLite/Spatialite | Нет |
| Миграции БД (`migrations/`) | Нет |
| Серверное хранилище состояния | Нет |
| Клиентское хранилище | `localStorage`, ключ `poi-visible` (`'1'`/`'0'`), ≈ 1 байт полезной нагрузки на браузер |
## 5. Конфигурация и секреты
| Аспект | Требование |
|--------|------------|
| Новые переменные окружения | Нет |
| Новые секреты | Нет |
| Изменения конфигурации FastAPI / uvicorn | Нет |
## 6. Зависимости
| Аспект | Требование |
|--------|------------|
| Новые npm/Python пакеты | Нет |
| Новые внешние сервисы | Нет |
| Версия MapLibre GL JS | Без изменений (`setLayoutProperty` — штатный API) |
## 7. Сборка и деплой
- **Pipeline:** существующий Gitea Actions без изменений (`lint`, `test`,
`build`).
- **Артефакт:** статические ассеты фронтенда (`src/web/`). Деплой —
штатная пересборка/перевыкладка и `docker compose up -d` на mva154.
- **Простой (downtime):** нет — изменение только в статике фронтенда.
- **План отката:** обратный коммит (revert) и повторный деплой;
миграций/состояния, требующих отдельного отката, нет.
## 8. Ресурсы (CPU / RAM / диск)
Воздействие отсутствует. Переключение `layout.visibility` слоёв
выполняется в браузере клиента; тайлы не перезапрашиваются (REQ-NF-01).
## 9. Наблюдаемость
Новые метрики, логи и алерты не требуются. Поведение проверяется
e2e-тестами фронтенда согласно `04-test-plan.yaml`.
## 10. Влияние на C4
Состав компонентов системы не меняется. Обновление
`docs/architecture/c4-*.mmd` не требуется (диаграммы C4 в репозитории
на данный момент отсутствуют — только `docs/architecture/README.md`).
## 11. Вывод
Инфраструктурных, сетевых, конфигурационных изменений и изменений БД
**нет**. ET-002 безопасен для деплоя в рамках обычного релизного цикла
фронтенда. Эскалация `arch:major-change` не требуется.

View File

@@ -0,0 +1,144 @@
---
type: review
work_item_id: ET-002
title: "Code Review: Чекбокс показа/скрытия POI в попапе рельефа"
version: 2
status: approved
verdict: APPROVED
created_at: 2026-05-21
authors:
- "agent:reviewer"
---
# Code Review — ET-002
## Вердикт
**APPROVED** — найдены только замечания уровня P3 (nice-to-have).
Блокеров (P0) и must-fix (P1) нет.
## Объём ревью
Проверены изменения в `src/web/app.js` и `src/web/index.html` против:
- `docs/work-items/ET-002/02-trz.md` (ТЗ, v1, approved)
- `docs/work-items/ET-002/03-acceptance-criteria.md` (AC, v1, approved)
- `docs/work-items/ET-002/06-adr/adr-0001-poi-visibility-client-side.md`
- `docs/work-items/ET-002/04-test-plan.yaml`
- `CLAUDE.md`
Дополнительно учтён юнит-тест `tests/unit/poi_toggle.test.js`.
---
## 1. Соответствие ТЗ
| Требование | Статус | Где реализовано |
|------------|--------|-----------------|
| REQ-F-01 — чекбокс «POI» в `terrain-popup` после `trails-path-cb`, отделён `<hr>` | ✅ | `index.html:56-60` |
| REQ-F-02 — состояние по умолчанию: checked, POI видимы | ✅ | `index.html:58` (атрибут `checked`); `app.js:2848-2852` (`stored === null → poiOn = true`) |
| REQ-F-03 — снятие чекбокса → `visibility: 'none'` для `poi-circles`, `poi-labels` | ✅ | `app.js:2833-2837``applyPoiVisibility(false)``app.js:2815-2825` |
| REQ-F-04 — установка чекбокса → `visibility: 'visible'` | ✅ | те же функции, ветка `visible` |
| REQ-F-05 — сохранение в `localStorage['poi-visible']` (`'1'`/`'0'`) | ✅ | `app.js:2835` |
| REQ-F-06 — восстановление при загрузке (`'0'` скрыть / `'1'`\|null показать) | ✅ | `app.js:2847-2853` (`restorePoiState`) |
| REQ-F-07 — синхронизация `layerState.poi` | ✅ | `app.js:2816` (`layerState.poi = visible` в общем хелпере) |
| REQ-NF-01 — переключение < 50 мс, без перезагрузки тайлов | ✅ | используется `setLayoutProperty`, источник слоёв не трогается |
| REQ-NF-02 — совместимость с MapLibre GL JS | ✅ | штатные API MapLibre, без экзотики |
| REQ-NF-03 — мобильный touch target ≥ 44px | ⚠️ см. P3-2 | переиспользован класс `terrain-checkbox` (как предписано ТЗ §3) |
| REQ-NF-04 — отсутствие регрессий | ✅ | существующие чекбоксы и их обработчики не затронуты |
UI-спецификация ТЗ §3 (разметка, `id="poi-visible-cb"`, `onchange="onPoiCheckbox()"`,
класс `terrain-checkbox`) воспроизведена в `index.html` дословно.
**Вывод:** все функциональные требования выполнены.
## 2. Соответствие ADR (adr-0001)
| Пункт решения | Статус | Комментарий |
|---------------|--------|-------------|
| 1. Видимость через `map.setLayoutProperty(... 'visibility' ...)` | ✅ | `app.js:2822` |
| 2. Персистентность — `localStorage['poi-visible']`, `'1'`/`'0'` | ✅ | соответствует конвенции проекта (`trails-track`, `terrain-*`) |
| 3. Источник истины — `layerState.poi`; `onPoiCheckbox()` синхронно обновляет `layerState`, `localStorage`, `layout.visibility` | ✅ | `onPoiCheckbox``setItem` + `applyPoiVisibility` (правит `layerState` и оба слоя) |
| 4. Без дублирования: общий приватный хелпер, переиспользование `layerGroups.poi` | ✅ | выделен `applyPoiVisibility(visible)`, итерирует `layerGroups.poi`; используется и `onPoiCheckbox`, и `restorePoiState` — ровно как предлагает ADR |
| 5. Backend/БД/API/инфраструктура без изменений | ✅ | изменения чисто клиентские |
Выбран и реализован Вариант A. Решение полностью соответствует ADR.
Замечание по консистентности с `toggleLayer('poi')` — см. P3-1.
## 3. Качество кода
Сильные стороны:
- Изменения локализованы в явно размеченном блоке
`>>> ET-002 POI visibility block ... <<<` (`app.js:2800-2854`) — удобно
для ревью и для тест-харнеса.
- JSDoc на всех трёх функциях, есть ссылка на ADR в шапке блока.
- `applyPoiVisibility` имеет guard `if (!map) return` и проверку
`map.getLayer(id)` перед `setLayoutProperty` — устойчиво к раннему вызову
и к смене стиля.
- `restorePoiState` корректно интегрирован в существующую цепочку
восстановления: `style.load → onMapStyleLoad → rebuildMapOverlays`
(`app.js:131`) — POI восстанавливается и при первой загрузке, и при
переключении темы. Паттерн идентичен `restoreTrailsState`.
- `restorePoiState` не пишет в `localStorage` (восстановление не должно
иметь побочных эффектов) — поведение задокументировано в JSDoc и
покрыто тестом TP-03.
- Разделение ответственности: персистентность — только в `onPoiCheckbox`,
применение видимости — в общем хелпере.
Замечаний P0/P1/P2 нет.
## 4. Качество тестов
`tests/unit/poi_toggle.test.js` исполняет **реальный** код из `app.js`
(блок извлекается по маркерам ET-002 и оборачивается через `new Function`
с мок-зависимостями) — это покрывает риск рассинхрона тестов и продакшн-кода.
- TP-01 — снятие чекбокса: скрытие слоёв + `setItem('poi-visible','0')` + `layerState.poi=false`
- TP-02 — установка чекбокса: показ слоёв + `setItem('1')` + `layerState.poi=true`
- TP-03 — `restorePoiState()` при `'0'`: скрытие + чекбокс снят + без записи в `localStorage`
- TP-04 — `restorePoiState()` без ключа: дефолт «видимы» ✅
- Доп.: значение `'1'`, изоляция чужих слоёв (дух TP-08), синхронизация
`layerState` без слоёв на карте.
TP-05..TP-07, TP-09 (e2e) и TP-08 (integration) по своей природе не
покрываются юнит-тестом — это ожидаемо и не является замечанием к данному PR.
## Findings
### P3-1 (nice-to-have) — рассинхрон `toggleLayer('poi')` с чекбоксом
`toggleLayer(group)` (`app.js:386-396`) меняет `layerState.poi`, но не
обновляет ни чекбокс `poi-visible-cb`, ни `localStorage`. ADR-0001 п.4
указывает, что состояние кнопки и чекбокса не должно расходиться.
Смягчающие факты: `toggleLayer` не имеет ни одного вызова в кодовой базе,
элемента `btn-poi` в `index.html` нет (вызов `toggleLayer('poi')` упал бы
на `btn.classList`). Фактически это недостижимый код, расхождение
пользователю не наблюдаемо. ADR сам относит унификацию тулбар-кнопок и
popup-чекбоксов к будущему техдолгу (раздел «Технический долг»).
Рекомендация (вне scope ET-002): удалить мёртвый `toggleLayer` либо
оформить unified-контроллер слоёв отдельной задачей.
### P3-2 (nice-to-have) — REQ-NF-03 не проверяется по диффу
Touch target ≥ 44px зависит от CSS-класса `terrain-checkbox` в `app.css`,
который в рамках ET-002 не менялся (ТЗ §3 предписывает переиспользовать
существующий класс). Замечания к коду нет — отметка для приёмки:
подтвердить REQ-NF-03 прогоном e2e-теста TP-09.
### P3-3 (nice-to-have) — неровный отступ в IIFE `initTerrain`
В `app.js:2949-2950` и `2962-2963` вызовы `restoreTrailsState()` /
`restorePoiState()` имеют отступ, не совпадающий с окружающим блоком.
Это предсуществующая стилевая мелочь, которую ET-002 лишь продолжил.
Косметика; рекомендуется выровнять при ближайшем касании файла.
## Итог
Реализация полностью соответствует ТЗ и ADR-0001, код аккуратен, юнит-тесты
исполняют реальный код и покрывают TP-01..TP-04. Найдены только три
замечания уровня P3, ни одно из которых не блокирует мерж.
**Вердикт: APPROVED.**

View File

@@ -0,0 +1,168 @@
---
type: review
work_item_id: ET-002
title: "Code Review: Чекбокс показа/скрытия POI в попапе рельефа"
version: 1
status: approved
verdict: APPROVED
created_at: 2026-05-21
authors:
- "agent:reviewer"
---
# Code Review — ET-002
## Вердикт
**APPROVED** (с комментариями).
P0/P1-findings нет. Зафиксировано 1×P2 и 3×P3 — все некритичные, не
блокируют merge. P2 относится к рассогласованию апстрим-артефактов
(test-plan vs infra-requirements), а не к дефекту реализации.
## Объект ревью
- Ветка: `feature/ET-002-poi-toggle`
- Базовая точка: `main` (832099c3) — ветка на 4 коммита впереди
- Код-коммит: `8c17a4f` `feat(web): add POI visibility checkbox to terrain popup`
- Изменённые файлы кода:
- `src/web/index.html` (+5 строк)
- `src/web/app.js` (+58 строк)
- `tests/unit/poi_toggle.test.js` (новый, 167 строк)
- `tests/unit/test_poi_toggle.py` (новый, 162 строки)
- Прочитано: `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`,
`06-adr/adr-0001-poi-visibility-client-side.md`, `07-infra-requirements.md`,
`CLAUDE.md`.
## 1. Соответствие ТЗ
| Требование | Статус | Комментарий |
|------------|--------|-------------|
| REQ-F-01 Чекбокс в попапе после «Тропы», отделён `<hr>` | ✅ | `index.html:56-60``<hr>` + `label.terrain-checkbox`, идёт сразу после `trails-path-cb` |
| REQ-F-02 Состояние по умолчанию — checked | ✅ | Атрибут `checked` в HTML; `restorePoiState()` при отсутствии ключа даёт `poiOn = true` |
| REQ-F-03 Скрытие → `visibility: 'none'` обоих слоёв | ✅ | `applyPoiVisibility(false)``setLayoutProperty` для `layerGroups.poi` |
| REQ-F-04 Показ → `visibility: 'visible'` | ✅ | `applyPoiVisibility(true)` |
| REQ-F-05 Сохранение в `localStorage` `poi-visible` `'1'`/`'0'` | ✅ | `onPoiCheckbox()``localStorage.setItem('poi-visible', …)` |
| REQ-F-06 Восстановление при загрузке | ⚠️ | Реализовано; нештатные значения ключа трактуются как «скрыть» — см. R-02 (P3) |
| REQ-F-07 Синхронизация `layerState.poi` | ✅ | `applyPoiVisibility()` пишет `layerState.poi` первой строкой |
| REQ-NF-01 Производительность < 50 мс | ✅ | Только `setLayoutProperty`, тайлы не перезапрашиваются |
| REQ-NF-02 Совместимость браузеров | ✅ | Стандартные API (`localStorage`, `setLayoutProperty`) |
| REQ-NF-03 Мобильная доступность ≥ 44px | ✅ | Переиспользован общий класс `terrain-checkbox` (как у соседних чекбоксов) |
| REQ-NF-04 Отсутствие регрессий | ✅ | Изменения аддитивные; точки восстановления зеркалят `restoreTrailsState()` |
Все функциональные и нефункциональные требования ТЗ выполнены.
## 2. Соответствие ADR-0001
| Пункт решения ADR | Статус | Комментарий |
|-------------------|--------|-------------|
| п.1 Видимость через `setLayoutProperty` | ✅ | Подтверждено; `removeLayer`/`addLayer` не используются (Вариант C отвергнут корректно) |
| п.2 Персистентность `localStorage` `poi-visible` `'1'`/`'0'` | ✅ | Соответствует конвенции проекта |
| п.3 Источник истины — `layerState.poi`; `onPoiCheckbox()` синхронно обновляет state + storage + visibility | ✅ | Выполнено |
| п.4 Без дублирования; переиспользование `layerGroups.poi`; общий хелпер | ✅ | Создан `applyPoiVisibility(visible)` — буквально имя, предложенное в ADR; используется и `onPoiCheckbox()`, и `restorePoiState()` |
| п.4 Консистентность `toggleLayer('poi')``onPoiCheckbox()` через `layerState.poi` | ✅ | Обе функции читают/пишут `layerState.poi`. POI-кнопки в тулбаре нет (`btn-poi` отсутствует, `toggleLayer` нигде не вызывается) — визуальный рассинхрон невозможен |
| п.5 Backend/БД/API/инфраструктура без изменений | ✅ | Затронут только `src/web/` |
Реализация полностью соответствует принятому ADR. Отдельно отмечается
точное следование п.4 — выделен ровно тот приватный хелпер, который ADR
описывал как рекомендуемый.
## 3. Acceptance Criteria
| AC | Покрытие |
|----|----------|
| AC-01 Чекбокс в попапе после «Тропы», отделён линией | ✅ `test_poi_checkbox_placed_after_trails_separated_by_hr` |
| AC-02 POI включены по умолчанию | ✅ `test_poi_checkbox_checked_by_default`, JS `TP-04` |
| AC-03 Скрытие POI | ✅ JS `TP-01` |
| AC-04 Показ POI | ✅ JS `TP-02` |
| AC-05 Состояние сохраняется после перезагрузки | ✅ JS `TP-03` (restore при `poi-visible=0`) |
| AC-06 Восстановление включённого состояния | ✅ JS `restorePoiState() при poi-visible=1` |
| AC-07 Не ломает существующие чекбоксы | ✅ JS `onPoiCheckbox() меняет только poi-circles/poi-labels`; изменения HTML аддитивны |
| AC-08 Синхронизация с `layerState` | ✅ JS `TP-01`/`TP-02` (`layerState.poi`), `applyPoiVisibility()` без слоёв |
Все 8 критериев имеют поведенческое покрытие.
## 4. Качество кода
Сильные стороны:
- **Нет дублирования.** Логика видимости группы вынесена в единый
`applyPoiVisibility()`; карта слоёв `layerGroups.poi` не продублирована.
- **Консистентность с кодовой базой.** `restorePoiState()` подключён в
тех же трёх точках, что и `restoreTrailsState()` (`rebuildMapOverlays`
+ обе ветки `initTerrain`), используется `window._map`, паттерн
`localStorage` `'1'`/`'0'` — всё единообразно с существующим кодом.
- **Защитное программирование.** Проверки `if (!map)`, `if (map.getLayer(id))`,
`if (cb)`; `layerState.poi` обновляется даже когда карта/слои ещё не
готовы (порядок инициализации безопасен).
- **Документированность.** JSDoc на всех трёх функциях, блок-маркеры с
ссылкой на ADR, запись в `CHANGELOG.md`.
- **Коммиты.** Conventional Commits соблюдён (`feat(web):`, `docs(ET-002):`).
Замечания — см. findings R-02, R-03 (P3).
## 5. Качество тестов
- **Unit (`poi_toggle.test.js`)** — высокое качество: тесты исполняют
**реальный** код из `app.js` (блок извлекается по маркерам и
оборачивается через `new Function` с инъекцией моков), а не его копию.
Покрыты TP-01..TP-04 + 3 дополнительных кейса (значение `'1'`,
изоляция чужих слоёв, синхронизация state без слоёв на карте).
- **Python (`test_poi_toggle.py`)** — статические проверки структуры
HTML/JS (REQ-F-01, REQ-F-02, ADR-0001) + запуск JS-раннера через
`node --test`, со `skip` при отсутствии `node` (по аналогии с
существующим `test_lua_syntax`/`luac`).
- Браузерные e2e TP-05..TP-09 не реализованы — см. R-01 (P2).
## Findings
### R-01 — Отклонение от test-plan: e2e TP-05..TP-09 не реализованы (P2)
`04-test-plan.yaml` определяет TP-05..TP-09 как `type: e2e`, однако
`07-infra-requirements.md` §6 явно запрещает новые npm-пакеты, а
Playwright-инфраструктуры в репозитории нет. Это конфликт между двумя
**approved**-артефактами, а не дефект разработчика: реализовать e2e «как
написано» означало бы нарушить инфра-требования. Поведение всех
сценариев покрыто статическими + unit-тестами, отклонение подробно
задокументировано в шапке `test_poi_toggle.py`.
**Рекомендация:** Analyst — согласовать `04-test-plan.yaml` с
`07-infra-requirements.md` (пометить TP-05..09 как покрытые альтернативно
либо завести отдельную инфра-задачу на Playwright). На merge ET-002 не
влияет.
### R-02 — `restorePoiState()`: нештатное значение ключа скрывает POI (P3)
`const poiOn = stored === null || stored === '1';` — любое значение,
отличное от `'1'`/`null` (например мусор в `localStorage`), трактуется
как «скрыть». REQ-F-06 описывает только `'0'` и `'1'`/`null`, а дефолт
фичи (REQ-F-02) — POI включены. Надёжнее: `const poiOn = stored !== '0';`
— тогда повреждённое значение деградирует к дефолту «показать».
Крайний кейс (ключ пишет только это приложение), отсюда P3.
### R-03 — Непоследовательные отступы в `initTerrain` (P3)
`app.js:2948-2950` и `2961-2963`: `restoreTrailsState()`/`restorePoiState()`
смещены относительно `restoreTerrainState()`. Дефект унаследован из
существующего кода — новые строки повторяют локальный (сломанный) отступ.
Косметика; при желании выровнять блок целиком.
### R-04 — Хрупкая связанность тестов с исходником (P3)
Тесты зависят от комментариев-маркеров `>>> ET-002 POI visibility block`
в `app.js` и от точного совпадения пробелов в
`test_poi_logic_reuses_layer_state_and_groups`
(`"poi: ['poi-circles', 'poi-labels']"`). Для монолитного non-module
`app.js` это прагматичное и задокументированное решение, но хрупкое при
рефакторинге/прогоне линтера. Технический долг — учесть при будущей
модуляризации фронтенда.
## Заключение
Реализация ET-002 корректна, полна и точно следует ТЗ и ADR-0001.
Дублирования нет, регрессионные риски закрыты повторением проверенного
паттерна `restoreTrailsState()`. Тесты исполняют реальный код и
покрывают все acceptance-критерии. Блокирующих замечаний нет.
**Вердикт: APPROVED.** R-02/R-03/R-04 — на усмотрение разработчика
(можно поправить в этом же PR или вынести в техдолг). R-01 — действие на
стороне Analyst, merge не блокирует.

View File

@@ -0,0 +1,227 @@
---
type: test-report
work_item_id: ET-002
version: 1
status: pass
tester: "agent:tester"
date: 2026-05-21
commit_tested: 8c17a4f
verdict: PASS
---
# Test Report — ET-002
## Verdict: **PASS** → `stage:ready-to-deploy`
Полный регресс зелёный: **14 passed, 4 skipped, 0 failed**, lint чистый,
JS-юнит-тесты POI 7/7 pass, тест-окружение отвечает 200. Блокирующих
багов (P0/P1) не найдено.
Тест-кейсы TP-01..TP-04 и TP-08 из `04-test-plan.yaml` исполнены и
прошли. e2e-кейсы TP-05..TP-07, TP-09 **не исполнялись** — в репозитории
нет Playwright-инфраструктуры, а `07-infra-requirements.md §6` запрещает
новые npm-пакеты (конфликт двух approved-артефактов, зафиксирован
reviewer'ом как R-01/P2). Поведенческая суть этих сценариев покрыта
unit- и статическими тестами. На merge/деплой ET-002 не влияет.
## Окружение
- **Дата прогона:** 2026-05-21
- **Ветка:** `feature/ET-002-poi-toggle`
- **Коммит:** `8c17a4f` (`feat(web): add POI visibility checkbox to terrain popup`;
взят из `12-review.md``git` в окружении тестера недоступен)
- **Python:** 3.12.13
- **pytest:** 8.3.3 (plugins: asyncio-1.3.0, anyio-4.13.0)
- **Node:** v22.22.2 (`node --test`)
- **ruff:** 0.15.14
- **test-env:** https://openclaw.mva154.duckdns.org/enduro/ → HTTP 200
## Healthcheck
| Среда | URL | Код |
|---|---|---|
| local dev | http://localhost:5556/health | connection refused (dev не поднят — ОК, прогон оффлайн) |
| test | https://openclaw.mva154.duckdns.org/enduro/ | 200 |
| test API | https://openclaw.mva154.duckdns.org/enduro/api/health | 200 `{"status":"ok","db_exists":true}` |
ET-002 — фронтенд-изменение; в test задеплоен `main`, поэтому healthcheck
подтверждает только живость окружения, а не наличие фичи (фича попадёт
в test штатной перевыкладкой после merge).
## Команды запуска
```bash
# Unit + integration (эквивалент make test)
python -m pytest tests/ -v
# JS behavioral unit-тесты POI (TP-01..TP-04)
node --test tests/unit/poi_toggle.test.js
# Lint
ruff check src/
ruff check tests/
```
## Результаты pytest
`python -m pytest tests/ -v`**14 passed, 4 skipped, 1 warning in 0.51s**
| # | Тест | Тип | Результат |
|---|---|---|---|
| 1 | `test_routing_barriers.py::test_lua_syntax` | unit (структура lua) | **PASS** |
| 2 | `test_routing_barriers.py::test_blocked_barriers_match_trz` | static AC | **PASS** |
| 3 | `test_routing_barriers.py::test_excluded_highways_match_trz` | static AC | **PASS** |
| 4 | `test_routing_barriers.py::test_route_avoids_barrier` | integration | **SKIP** (OSRM недоступен) |
| 5 | `test_routing_barriers.py::test_route_no_footway` | integration | **SKIP** (OSRM недоступен) |
| 6 | `test_routing_barriers.py::test_route_allows_cattle_grid` | integration | **SKIP** (OSRM недоступен) |
| 7 | `test_routing_barriers.py::test_existing_route_works` | regression | **SKIP** (OSRM недоступен) |
| 8 | `test_health.py::test_health_endpoint` | unit (async) | **PASS** |
| 9 | `test_poi_toggle.py::test_poi_checkbox_present_in_html` | static (REQ-F-01) | **PASS** |
| 10 | `test_poi_toggle.py::test_poi_checkbox_checked_by_default` | static (REQ-F-02) | **PASS** |
| 11 | `test_poi_toggle.py::test_poi_checkbox_placed_after_trails_separated_by_hr` | static (REQ-F-01) | **PASS** |
| 12 | `test_poi_toggle.py::test_poi_checkbox_uses_shared_style_class` | static (UI-спец) | **PASS** |
| 13 | `test_poi_toggle.py::test_poi_functions_defined` | static (ADR-0001) | **PASS** |
| 14 | `test_poi_toggle.py::test_poi_logic_uses_localstorage_key` | static (REQ-F-05) | **PASS** |
| 15 | `test_poi_toggle.py::test_poi_logic_reuses_layer_state_and_groups` | static (ADR п.3-4) | **PASS** |
| 16 | `test_poi_toggle.py::test_restore_poi_state_wired_into_init` | static (REQ-F-06) | **PASS** |
| 17 | `test_poi_toggle.py::test_poi_visibility_toggled_via_set_layout_property` | static (ADR п.1) | **PASS** |
| 18 | `test_poi_toggle.py::test_js_unit_tests_pass` | behavioral (Node-раннер) | **PASS** |
**4 SKIP** — интеграционные тесты роутинга ET-001; требуют поднятого OSRM
(`OSRM_URL`, по умолчанию `http://172.22.0.1:5559`), который в окружении
тестера недоступен. Это штатное поведение (`test_routing_barriers.py`
помечает их `skip`, чтобы CI без инфраструктуры не падал). ET-002 —
фронтенд-изменение, на роутинг влиять не может; к регрессу не относится.
Предупреждение `PendingDeprecationWarning` из `starlette/formparsers.py`
внешняя зависимость, к ET-002 отношения не имеет, не блокирует.
## Результаты JS unit-тестов (TP-01..TP-04)
`node --test tests/unit/poi_toggle.test.js`**# pass 7, # fail 0**
Тесты исполняют **реальный** код POI-блока из `src/web/app.js` (блок
извлекается по маркерам `ET-002 POI visibility block` и оборачивается
через `new Function` с инъекцией моков).
| Тест | TC | Результат |
|---|---|---|
| снятый чекбокс скрывает слои POI и сохраняет `0` | TP-01 | **PASS** |
| установленный чекбокс показывает слои POI и сохраняет `1` | TP-02 | **PASS** |
| `restorePoiState()` при `poi-visible=0` скрывает POI | TP-03 | **PASS** |
| `restorePoiState()` без ключа включает POI по умолчанию | TP-04 | **PASS** |
| `restorePoiState()` при `poi-visible=1` показывает POI | доп. | **PASS** |
| `onPoiCheckbox()` меняет только `poi-circles`/`poi-labels` | доп. (дух TP-08) | **PASS** |
| `applyPoiVisibility()` синхронизирует `layerState` без слоёв | доп. | **PASS** |
## Результаты lint
| Команда | Результат |
|---|---|
| `ruff check src/` | **All checks passed!** |
| `ruff check tests/` | **All checks passed!** |
## Покрытие тест-плана (04-test-plan.yaml)
| TC | Тип | Исполнение | Статус |
|---|---|---|---|
| **TP-01** | unit | JS-тест «TP-01» (через `test_js_unit_tests_pass`) | **PASS** |
| **TP-02** | unit | JS-тест «TP-02» | **PASS** |
| **TP-03** | unit | JS-тест «TP-03» | **PASS** |
| **TP-04** | unit | JS-тест «TP-04» | **PASS** |
| **TP-05** | e2e | **BLOCKED** (нет Playwright). Покрыто статически: `test_poi_checkbox_present_in_html`, `test_poi_checkbox_placed_after_trails_separated_by_hr` | COVERED (alt) |
| **TP-06** | e2e | **BLOCKED** (нет Playwright). Покрыто поведенчески: JS «TP-01»/«TP-02», «меняет только poi-circles/poi-labels» | COVERED (alt) |
| **TP-07** | e2e | **BLOCKED** (нет Playwright). Покрыто поведенчески: JS «TP-03» (`restorePoiState` при `poi-visible=0`) | COVERED (alt) |
| **TP-08** | integration | JS «onPoiCheckbox() меняет только слои poi-circles и poi-labels» — изоляция чужих слоёв подтверждена | **PASS** (alt) |
| **TP-09** | e2e | **BLOCKED** (нет Playwright). Класс `terrain-checkbox` подтверждён статически; по touch-target см. P3-2 | PARTIAL |
**Исполнено и пройдено: TP-01..TP-04, TP-08 (5/9).**
**e2e TP-05..TP-07, TP-09 (4/9) — заблокированы инфраструктурой**, их
поведение покрыто unit/статикой. Прямой инструментальной проверки в
реальном браузере не было.
## Соответствие Acceptance Criteria
| AC | Описание | Источник проверки | Статус |
|---|---|---|---|
| AC-01 | Чекбокс в попапе после «Тропы», отделён `<hr>` | `test_poi_checkbox_placed_after_trails_separated_by_hr` | **PASS** |
| AC-02 | POI включены по умолчанию | `test_poi_checkbox_checked_by_default` + JS «TP-04» | **PASS** |
| AC-03 | Скрытие POI → `visibility: none` | JS «TP-01» | **PASS** |
| AC-04 | Показ POI → `visibility: visible` | JS «TP-02» | **PASS** |
| AC-05 | Состояние сохраняется после перезагрузки | JS «TP-03» (`restorePoiState` при `poi-visible=0`) | **PASS** |
| AC-06 | Восстановление включённого состояния | JS «restorePoiState при poi-visible=1» | **PASS** |
| AC-07 | Не ломает существующие чекбоксы | JS «меняет только poi-circles/poi-labels»; HTML-изменения аддитивны | **PASS** |
| AC-08 | Синхронизация с `layerState.poi` | JS «TP-01»/«TP-02» + «applyPoiVisibility без слоёв» | **PASS** |
Все 8 критериев имеют поведенческое покрытие; ни один не нарушен.
Браузерная (e2e) проверка AC-01/03/04/05 ограничена отсутствием
Playwright — поведение подтверждено на уровне реального кода POI-блока.
## Найденные баги
### P0 (блокирующие)
Нет.
### P1 (критические)
Нет.
### P2 (важные)
**T-01 (= R-01 из `12-review.md`) — e2e TP-05..TP-09 не исполнимы.**
`04-test-plan.yaml` определяет TP-05..TP-09 как `type: e2e`, но
`07-infra-requirements.md §6` запрещает новые npm-пакеты, а
Playwright-инфраструктуры в репозитории нет (проверено: нет
`package.json`, `node_modules`, бинаря `playwright`, `npx`). Это конфликт
двух **approved**-артефактов, а не дефект разработки. Поведение покрыто
unit/статикой.
**Действие:** Analyst — согласовать `04-test-plan.yaml` с
`07-infra-requirements.md` (пометить TP-05..09 как покрытые альтернативно
либо завести инфра-задачу на Playwright). **Merge/деплой ET-002 не
блокирует.**
### P3 (косметика / наблюдения)
1. **(= R-02 из `12-review.md`)** `app.js:2849`
`const poiOn = stored === null || stored === '1';`: нештатное значение
ключа `poi-visible` трактуется как «скрыть». Надёжнее
`stored !== '0'` (деградация к дефолту «показать», REQ-F-02).
Крайний кейс — ключ пишет только это приложение. Не блокирует.
2. **REQ-NF-03 (touch target ≥ 44px)**`.terrain-checkbox`
(`app.css:806`) при `padding:8px` и `font-size:15px` даёт высоту
строки ≈ 35px, что ниже ориентира 44px. Однако чекбокс POI
использует **тот же** класс, что и все соседние чекбоксы попапа
(Тени/Перепады/Грунтовки/Тропы) — **регрессии нет**, кликабельна вся
строка-`<label>`. Точная проверка на реальном устройстве (TP-09)
невозможна без e2e. Если 44px критичны — это пред-существующий
UX-вопрос ко всему попапу, отдельной задачей. Не блокирует ET-002.
3. **Окружение тестера.** Пакеты `shapely`, `mapbox-vector-tile`,
`httpx` (`requirements.txt`) и `pytest-asyncio`, `ruff`
(`pyproject.toml [dev]`) не были предустановлены в песочнице — без
`shapely` падал сбор `test_health.py` (импорт `src.api.main`).
Тестер доустановил их по манифестам проекта, после чего регресс
прошёл полностью. Это дефект провижининга окружения, **не дефект
ET-002**. CI обязан выполнять `pip install -r requirements.txt` и
`.[dev]` перед `make test`.
## Замечания тестера
- Прогон выполнен против локального репозитория без поднятого dev-сервера
(`make test` оффлайн). Backend не затронут (ET-002 — только `src/web/`).
- Ручная сверка реализации: `index.html:56-60``<hr>` + чекбокс
`poi-visible-cb` (`checked`, класс `terrain-checkbox`) сразу после
`trails-path-cb`; `app.js:2800-2854` — POI-блок с `applyPoiVisibility`,
`onPoiCheckbox`, `restorePoiState`; `restorePoiState()` подключён в 3
точках восстановления (`app.js:131, 2950, 2963`). Соответствует ТЗ,
ADR-0001 и выводам `12-review.md`.
- Тесты не подгонялись под код; на prom ничего не запускалось.
## Итог
**Verdict: PASS**`stage:ready-to-deploy`.
Все исполнимые тест-кейсы (TP-01..04, TP-08), все 8 acceptance-критериев
и lint — зелёные; блокирующих (P0/P1) багов нет. Незакрытые e2e-кейсы
(TP-05..07, TP-09) заблокированы инфраструктурным конфликтом
approved-артефактов (T-01/P2) — действие на стороне Analyst, на ET-002
не влияет. Готово к штатной перевыкладке фронтенда в test/prom согласно
`07-infra-requirements.md`.

View File

@@ -247,7 +247,8 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
#waypoints-list { display: flex; flex-direction: column; margin-bottom: 10px; }
.wl-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 0;
padding: 8px 4px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
border-bottom: 1px solid var(--border);
position: relative;
}
@@ -769,3 +770,262 @@ body.has-map-mode #sheet-backdrop.visible { pointer-events: none; }
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ═══════════════════════════════════════════
Terrain Layer (Phase 5.4)
═══════════════════════════════════════════ */
/* Terrain toggle button active state */
#terrain-toggle.active {
color: var(--accent, #4CAF50);
background: rgba(76, 175, 80, 0.15);
}
/* Terrain popup */
.terrain-popup {
position: fixed;
z-index: 500;
background: var(--surface, #1e1e1e);
border: 1px solid var(--border, rgba(255,255,255,0.12));
border-radius: 12px;
padding: 12px 14px;
min-width: 160px;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
user-select: none;
}
.terrain-popup-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text2, rgba(255,255,255,0.5));
margin-bottom: 10px;
}
.terrain-checkbox {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
cursor: pointer;
font-size: 15px;
color: var(--text, #fff);
border-radius: 6px;
}
.terrain-checkbox span {
font-size: 15px;
line-height: 1.3;
}
.terrain-checkbox:hover {
color: var(--accent, #4CAF50);
}
.terrain-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent, #4CAF50);
cursor: pointer;
flex-shrink: 0;
}
/* Light theme overrides */
.theme-light .terrain-popup {
background: var(--surface, #fff);
border-color: var(--border, rgba(0,0,0,0.12));
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
.theme-light .terrain-popup-title {
color: var(--text2, rgba(0,0,0,0.5));
}
.theme-light .terrain-checkbox {
color: var(--text, #111);
}
/* Terrain hillshade hint & disabled state */
.terrain-hint {
display: block;
font-size: 11px;
color: var(--accent, #4CAF50);
font-style: italic;
padding: 4px 0 2px 28px;
line-height: 1.2;
}
.terrain-checkbox.disabled {
opacity: 0.45;
pointer-events: none;
cursor: not-allowed;
}
.terrain-checkbox.disabled input[type="checkbox"] {
cursor: not-allowed;
}
/* ── Scale + Zoom bar (one line, top-right) ───────── */
#scale-zoom-bar {
position: absolute;
top: calc(max(env(safe-area-inset-top, 0px), 8px) + 4px);
right: 12px;
display: flex;
align-items: center;
gap: 6px;
z-index: 10;
pointer-events: none;
}
.szb-scale {
height: 16px;
border: 1.5px solid rgba(255,255,255,0.8);
border-top: none;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
}
.szb-label {
font-size: 10px;
font-weight: 500;
color: rgba(255,255,255,0.9);
text-shadow: 0 0 3px rgba(0,0,0,0.8), 0 1px 2px rgba(0,0,0,0.6);
white-space: nowrap;
padding: 0 4px;
}
.szb-zoom {
font-size: 11px;
font-weight: 600;
color: rgba(255,255,255,0.85);
text-shadow: 0 0 3px rgba(0,0,0,0.8), 0 1px 2px rgba(0,0,0,0.6);
white-space: nowrap;
}
/* ── Search panel ───────────────────────────── */
#search-panel {
position: fixed;
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
left: 0; right: 0;
background: var(--surface);
border-top: 1px solid var(--border);
z-index: 350;
padding: 12px 16px;
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
}
.search-panel-inner {
display: flex;
gap: 8px;
align-items: center;
}
#standalone-search-input {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
font-size: 15px;
color: var(--text1);
outline: none;
}
#standalone-search-input:focus {
border-color: var(--accent);
}
#search-close-btn {
background: none;
border: none;
color: var(--text3);
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
}
#standalone-search-results {
max-height: 240px;
overflow-y: auto;
margin-top: 8px;
}
#standalone-search-results .search-result-item {
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
}
#standalone-search-results .search-result-item:hover,
#standalone-search-results .search-result-item:active {
background: var(--surface2);
}
#standalone-search-results .search-result-name {
font-size: 14px;
font-weight: 500;
color: var(--text1);
}
#standalone-search-results .search-result-sub {
font-size: 12px;
color: var(--text3);
margin-top: 2px;
}
/* ─── Zoom controls ──────────────────────────────────────────────────────── */
#zoom-controls {
position: fixed;
left: 12px;
top: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
z-index: 400;
}
#zoom-controls .map-btn {
width: 40px;
height: 40px;
font-size: 20px;
font-weight: 700;
line-height: 1;
}
#zoom-level {
background: var(--surface, #1e1e1e);
color: var(--text, #fff);
border-radius: 6px;
padding: 4px 8px;
font-size: 13px;
font-weight: 600;
min-width: 32px;
text-align: center;
border: 1px solid rgba(255,255,255,0.1);
}
/* ─── Scale bar ──────────────────────────────────────────────────────────── */
#scale-bar {
position: fixed;
bottom: calc(80px + env(safe-area-inset-bottom, 0px) + 16px);
left: 12px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
z-index: 400;
pointer-events: none;
}
#scale-line {
height: 4px;
width: 100px;
background: #fff;
border: 1px solid rgba(0,0,0,0.6);
border-top: none;
border-left: 2px solid #fff;
border-right: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.5);
}
#scale-label {
font-size: 11px;
color: #fff;
text-shadow: 0 1px 3px rgba(0,0,0,0.9), 0 0 4px rgba(0,0,0,0.7);
font-weight: 700;
letter-spacing: 0.3px;
}

View File

@@ -88,6 +88,7 @@ function switchMapStyle() {
if (!map) return;
const dark = isDarkTheme();
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
const tileBase = window.location.origin + basePath;
const styleUrl = dark ? basePath + '/style-dark.json' : basePath + '/style.json';
// Save current position before style change
@@ -96,18 +97,22 @@ function switchMapStyle() {
const bearing = map.getBearing();
const pitch = map.getPitch();
fetch(styleUrl, { method: 'HEAD' }).then(r => {
if (r.ok) {
map.setStyle(styleUrl);
// Restore position after style loads
map.once('style.load', () => {
map.jumpTo({ center, zoom, bearing, pitch });
});
} else {
console.log('Map style not available:', styleUrl);
fetch(styleUrl).then(r => {
if (r.ok) return r.json();
throw new Error('Style not available');
}).then(style => {
// Fix tile URLs to absolute (same as initMap)
if (style.sources && style.sources['trails-tiles'] && style.sources['trails-tiles'].tiles) {
style.sources['trails-tiles'].tiles = [`${tileBase}/api/tiles/{z}/{x}/{y}.mvt`];
}
map.setStyle(style);
// Restore position and overlays after style loads
map.once('idle', () => {
map.jumpTo({ center, zoom, bearing, pitch });
rebuildMapOverlays();
});
}).catch(() => {
// Network error, don't switch
// Network error or style not available, don't switch
});
}
@@ -120,6 +125,11 @@ function onMapStyleLoad() {
}
function rebuildMapOverlays() {
// Re-apply terrain and trails after style change
restoreTerrainState();
restoreTrailsState();
restorePoiState();
// Re-apply recon circle if active
if (reconMode && reconCenter) {
doRecon(reconCenter[0], reconCenter[1]);
@@ -1283,9 +1293,7 @@ function selectMarkerType(idx, lat, lng) {
closeMarkerDialog();
const markers = loadMarkers();
const icon = MARKER_ICONS[idx] || MARKER_ICONS[0];
const name = prompt('Название метки (Enter = автоимя):');
if (name === null) return;
const autoName = name.trim() || `Метка ${markers.length + 1}`;
const autoName = `Метка ${markers.length + 1}`;
const marker = { id: Date.now(), name: autoName, icon, lat, lon: lng };
markers.push(marker);
saveMarkers(markers);
@@ -1320,6 +1328,17 @@ function drawNamedMarker(markerData) {
}
function renderMarkers() {
// Clear existing marker objects to prevent duplicates
Object.keys(namedMarkerObjects).forEach(id => {
const obj = namedMarkerObjects[id];
if (obj) {
const popup = obj.getPopup();
if (popup) popup.remove();
obj.remove();
}
});
namedMarkerObjects = {};
// Re-draw from localStorage
const markers = loadMarkers();
markers.forEach(m => drawNamedMarker(m));
}
@@ -1379,8 +1398,52 @@ async function initMap() {
window._map = map;
map.addControl(new maplibregl.NavigationControl(), 'top-left');
map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-right');
map.addControl(new maplibregl.FullscreenControl(), 'top-left');
// Custom scale + zoom indicator (one line, top-right)
const scaleZoomBar = document.createElement('div');
scaleZoomBar.id = 'scale-zoom-bar';
scaleZoomBar.innerHTML = '<div class="szb-scale"><span class="szb-label">30 km</span></div><div class="szb-zoom">z7</div>';
document.getElementById('map').appendChild(scaleZoomBar);
function updateScaleZoom() {
const zoom = Math.round(map.getZoom());
const lat = map.getCenter().lat;
const metersPerPixel = 156543.03392 * Math.cos(lat * Math.PI / 180) / Math.pow(2, map.getZoom());
const targetPx = 80;
const rawMeters = metersPerPixel * targetPx;
let distance, unit, niceMeters;
if (rawMeters >= 1000) {
const km = rawMeters / 1000;
distance = km >= 100 ? Math.round(km / 50) * 50 :
km >= 10 ? Math.round(km / 5) * 5 :
km >= 1 ? Math.round(km) : Math.round(km * 10) / 10;
unit = 'km';
niceMeters = distance * 1000;
} else {
distance = rawMeters >= 100 ? Math.round(rawMeters / 50) * 50 :
rawMeters >= 10 ? Math.round(rawMeters / 5) * 5 :
Math.round(rawMeters);
unit = 'm';
niceMeters = distance;
}
const actualPx = Math.round(niceMeters / metersPerPixel);
const clampedPx = Math.max(40, Math.min(150, actualPx));
const scaleEl = scaleZoomBar.querySelector('.szb-scale');
const labelEl = scaleZoomBar.querySelector('.szb-label');
const zoomEl = scaleZoomBar.querySelector('.szb-zoom');
scaleEl.style.width = clampedPx + 'px';
labelEl.textContent = distance + ' ' + unit;
zoomEl.textContent = 'z' + zoom;
}
updateScaleZoom();
map.on('zoom', updateScaleZoom);
map.on('move', updateScaleZoom);
map.on('load', () => {
checkDataAvailability();
@@ -2637,16 +2700,25 @@ function toggleTerrainPopup() {
const isVisible = popup.style.display !== 'none';
popup.style.display = isVisible ? 'none' : 'block';
btn.classList.toggle('active', !isVisible);
// Close on outside click
// Position popup to the left of the button
if (!isVisible) {
const rect = btn.getBoundingClientRect();
popup.style.right = (window.innerWidth - rect.left + 8) + 'px';
// Position: align bottom of popup with bottom of button, ensure fits in viewport
const popupHeight = popup.offsetHeight;
const desiredTop = rect.bottom - popupHeight;
const minTop = 8;
popup.style.top = Math.max(minTop, desiredTop) + 'px';
updateHillshadeAvailability();
setTimeout(() => {
document.addEventListener('click', closeTerrainOnOutside);
}, 10);
} else {
document.removeEventListener('click', closeTerrainOnOutside);
}
btn.classList.toggle('active', !isVisible);
}
function closeTerrainOnOutside(e) {
@@ -2663,22 +2735,124 @@ function onTerrainCheckbox() {
const map = window._map;
if (!map) return;
const hypsoChecked = document.getElementById('terrain-hypso-cb').checked;
const hillshadeChecked = document.getElementById('terrain-hillshade-cb').checked;
const triChecked = document.getElementById('terrain-tri-cb').checked;
// Save state
localStorage.setItem('terrain-hypso', hypsoChecked ? '1' : '0');
localStorage.setItem('terrain-hillshade', hillshadeChecked ? '1' : '0');
localStorage.setItem('terrain-tri', triChecked ? '1' : '0');
// Update button active state
const btn = document.getElementById('terrain-toggle');
btn.classList.toggle('active', hypsoChecked || hillshadeChecked);
btn.classList.toggle('active', hillshadeChecked || triChecked);
// Apply layers
applyTerrainLayer('terrain-hypso', TERRAIN_BASE_URL + '/hypso/{z}/{x}/{y}.png', hypsoChecked, 0.55, 5, 15);
applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', hillshadeChecked, 0.40, 10, 15);
applyTerrainLayer('terrain-tri', TERRAIN_BASE_URL + '/tri/{z}/{x}/{y}.png', triChecked, 0.70, 5, 15);
}
function onTrailsCheckbox() {
const map = window._map;
if (!map) return;
const trackChecked = document.getElementById('trails-track-cb').checked;
const pathChecked = document.getElementById('trails-path-cb').checked;
// Save state
localStorage.setItem('trails-track', trackChecked ? '1' : '0');
localStorage.setItem('trails-path', pathChecked ? '1' : '0');
// Toggle layer visibility
if (map.getLayer('trails-track')) {
map.setLayoutProperty('trails-track', 'visibility', trackChecked ? 'visible' : 'none');
}
if (map.getLayer('trails-path-bridleway')) {
map.setLayoutProperty('trails-path-bridleway', 'visibility', pathChecked ? 'visible' : 'none');
}
}
function restoreTrailsState() {
const trackState = localStorage.getItem('trails-track');
const pathState = localStorage.getItem('trails-path');
// Default: both checked (visible)
const trackOn = trackState === null || trackState === '1';
const pathOn = pathState === null || pathState === '1';
const trackCb = document.getElementById('trails-track-cb');
const pathCb = document.getElementById('trails-path-cb');
if (trackCb) trackCb.checked = trackOn;
if (pathCb) pathCb.checked = pathOn;
const map = window._map;
if (map) {
if (map.getLayer('trails-track')) {
map.setLayoutProperty('trails-track', 'visibility', trackOn ? 'visible' : 'none');
}
if (map.getLayer('trails-path-bridleway')) {
map.setLayoutProperty('trails-path-bridleway', 'visibility', pathOn ? 'visible' : 'none');
}
}
}
// >>> ET-002 POI visibility block (do not remove markers — used by unit tests) >>>
// Видимость POI (слои poi-circles, poi-labels) управляется чекбоксом
// «POI» в попапе рельефа. Состояние хранится в localStorage под ключом
// 'poi-visible' ('1'/'0'). Источник истины в рантайме — layerState.poi.
// См. docs/work-items/ET-002/06-adr/adr-0001-poi-visibility-client-side.md
/**
* Применяет видимость группы слоёв POI и синхронизирует layerState.poi.
*
* Единый приватный хелпер: переиспользуется чекбоксом попапа
* (onPoiCheckbox) и восстановлением состояния при загрузке/смене стиля
* (restorePoiState). Не пишет в localStorage — персистентность остаётся
* ответственностью обработчика чекбокса.
*
* @param {boolean} visible - true — показать POI, false — скрыть.
*/
function applyPoiVisibility(visible) {
layerState.poi = visible;
const map = window._map;
if (!map) return;
const visibility = visible ? 'visible' : 'none';
layerGroups.poi.forEach(id => {
if (map.getLayer(id)) {
map.setLayoutProperty(id, 'visibility', visibility);
}
});
}
/**
* Обработчик чекбокса «POI» в попапе рельефа (атрибут onchange).
*
* Сохраняет выбор в localStorage ('poi-visible': '1' видимы | '0' скрыты)
* и применяет видимость слоёв POI через applyPoiVisibility().
*/
function onPoiCheckbox() {
const checked = document.getElementById('poi-visible-cb').checked;
localStorage.setItem('poi-visible', checked ? '1' : '0');
applyPoiVisibility(checked);
}
/**
* Восстанавливает видимость POI при загрузке страницы и после смены
* стиля карты (переключение темы).
*
* По умолчанию (ключ 'poi-visible' отсутствует или равен '1') POI
* видимы; '0' — скрыты. Синхронизирует чекбокс, layerState.poi и
* фактическую видимость слоёв.
*/
function restorePoiState() {
const stored = localStorage.getItem('poi-visible');
const poiOn = stored === null || stored === '1';
const cb = document.getElementById('poi-visible-cb');
if (cb) cb.checked = poiOn;
applyPoiVisibility(poiOn);
}
// <<< ET-002 POI visibility block <<<
function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
const map = window._map;
if (!map) return;
@@ -2693,7 +2867,6 @@ function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
tiles: [tileUrl],
tileSize: 256,
scheme: 'tms',
bounds: [35, 45, 55, 62],
minzoom: minzoom,
maxzoom: maxzoom
});
@@ -2709,7 +2882,8 @@ function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) {
type: 'raster',
source: sourceId,
paint: {
'raster-opacity': opacity
'raster-opacity': opacity,
'raster-resampling': 'linear'
},
minzoom: minzoom,
maxzoom: maxzoom
@@ -2743,22 +2917,22 @@ function updateHillshadeAvailability() {
}
function restoreTerrainState() {
const hypso = localStorage.getItem('terrain-hypso') === '1';
const hillshade = localStorage.getItem('terrain-hillshade') === '1';
const tri = localStorage.getItem('terrain-tri') === '1';
const hypsoCb = document.getElementById('terrain-hypso-cb');
const hillshadeCb = document.getElementById('terrain-hillshade-cb');
const triCb = document.getElementById('terrain-tri-cb');
if (hypsoCb) hypsoCb.checked = hypso;
if (hillshadeCb) hillshadeCb.checked = hillshade;
if (triCb) triCb.checked = tri;
if (hypso || hillshade) {
if (hillshade || tri) {
onTerrainCheckbox();
}
// Update button active state
const btn = document.getElementById('terrain-toggle');
if (btn) btn.classList.toggle('active', hypso || hillshade);
if (btn) btn.classList.toggle('active', hillshade || tri);
}
// Hook into map load and zoom changes
@@ -2771,8 +2945,9 @@ function restoreTerrainState() {
setTimeout(restoreTerrainState, 100);
});
// Initial state
updateHillshadeAvailability();
restoreTerrainState();
restoreTrailsState();
restorePoiState();
} else {
// Map not ready yet, wait
const interval = setInterval(() => {
@@ -2784,7 +2959,78 @@ function restoreTerrainState() {
});
updateHillshadeAvailability();
restoreTerrainState();
restoreTrailsState();
restorePoiState();
}
}, 500);
}
})();
// ─── Standalone Search Mode ──────────────────────────────────────
let searchModeActive = false;
let standaloneSearchTimeout = null;
function toggleSearchMode() {
searchModeActive = !searchModeActive;
const panel = document.getElementById('search-panel');
const btn = document.getElementById('tb-search');
if (searchModeActive) {
panel.style.display = 'block';
btn.classList.add('active');
const input = document.getElementById('standalone-search-input');
input.value = '';
document.getElementById('standalone-search-results').innerHTML = '';
setTimeout(() => input.focus(), 100);
input.oninput = () => {
clearTimeout(standaloneSearchTimeout);
const q = input.value.trim();
if (q.length < 2) {
document.getElementById('standalone-search-results').innerHTML = '';
return;
}
standaloneSearchTimeout = setTimeout(() => doStandaloneSearch(q), 400);
};
input.onkeydown = (e) => {
if (e.key === 'Escape') toggleSearchMode();
};
} else {
panel.style.display = 'none';
btn.classList.remove('active');
}
}
async function doStandaloneSearch(query) {
const resultsEl = document.getElementById('standalone-search-results');
resultsEl.innerHTML = '<div class="search-result-item"><span style="color:var(--text3)">Поиск...</span></div>';
try {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=6&countrycodes=ru&accept-language=ru`;
const resp = await fetch(url);
const data = await resp.json();
if (!data.length) {
resultsEl.innerHTML = '<div class="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].trim();
const sub = parts.slice(1, 3).join(',').trim();
return `<div class="search-result-item" onclick="standaloneSelectResult(${item.lat}, ${item.lon}, '${name.replace(/'/g, "\\\'")}')">
<div class="search-result-name">${name}</div>
${sub ? `<div class="search-result-sub">${sub}</div>` : ''}
</div>`;
}).join('');
} catch(e) {
resultsEl.innerHTML = '<div class="search-result-item"><span style="color:var(--red)">Ошибка поиска</span></div>';
}
}
function standaloneSelectResult(lat, lon, name) {
toggleSearchMode();
window._map.flyTo({ center: [parseFloat(lon), parseFloat(lat)], zoom: 13, duration: 800 });
}

View File

@@ -32,6 +32,34 @@
<!-- ── No data warning ───────────────────── -->
<div id="no-data-warning">⚠️ База данных недоступна</div>
<!-- ── Terrain popup ────────────────────── -->
<div id="terrain-popup" class="terrain-popup" style="display:none">
<div class="terrain-popup-title">Эндуро</div>
<label class="terrain-checkbox">
<input type="checkbox" id="terrain-hillshade-cb" onchange="onTerrainCheckbox()">
<span>Тени рельефа</span>
</label>
<span class="terrain-hint" id="terrain-hillshade-hint" style="display:none">Зум 10+</span>
<label class="terrain-checkbox">
<input type="checkbox" id="terrain-tri-cb" onchange="onTerrainCheckbox()">
<span>Перепады</span>
</label>
<hr style="margin:6px 0;border-color:rgba(128,128,128,0.3)">
<label class="terrain-checkbox">
<input type="checkbox" id="trails-track-cb" onchange="onTrailsCheckbox()" checked>
<span>Грунтовки</span>
</label>
<label class="terrain-checkbox">
<input type="checkbox" id="trails-path-cb" onchange="onTrailsCheckbox()" checked>
<span>Тропы</span>
</label>
<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>
</label>
</div>
<!-- ── Map Buttons (right) ───────────────── -->
<div id="map-controls-r">
<button class="map-btn" id="btn-compass" onclick="toggleCompass()" title="Компас">
@@ -40,6 +68,9 @@
<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="terrain-toggle" onclick="toggleTerrainPopup()" 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"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></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>
@@ -197,6 +228,15 @@
</div>
</div>
<!-- Search panel -->
<div id="search-panel" style="display:none">
<div class="search-panel-inner">
<input id="standalone-search-input" type="text" placeholder="Поиск места..." autocomplete="off" autocorrect="off">
<button id="search-close-btn" onclick="toggleSearchMode()"></button>
</div>
<div id="standalone-search-results"></div>
</div>
<!-- ════════════════════════════════════════════
BOTTOM TOOLBAR
════════════════════════════════════════════ -->
@@ -221,6 +261,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/><path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/><path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/></svg>
<span>Линейка</span>
</button>
<button class="tb-btn" id="tb-search" onclick="toggleSearchMode()">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" 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.3-4.3"/></svg>
<span>Поиск</span>
</button>
<button class="tb-btn" id="tb-marker" onclick="toggleMarkerMode()">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>
<span>Метка</span>

View File

@@ -0,0 +1,167 @@
'use strict';
/**
* ET-002 — поведенческие unit-тесты чекбокса видимости POI.
*
* Покрывают TP-01..TP-04 из docs/work-items/ET-002/04-test-plan.yaml.
*
* Тесты исполняют РЕАЛЬНЫЙ код из src/web/app.js: блок POI извлекается
* по маркерам `>>> ET-002 POI visibility block` и оборачивается в
* фабрику через `new Function`, которой передаются мок-зависимости
* (window, document, localStorage, layerState, layerGroups). Так
* монолитный browser-скрипт проверяется без полной загрузки в Node.
*
* Запуск: `node --test tests/unit/poi_toggle.test.js`
* (в CI оборачивается pytest-тестом tests/unit/test_poi_toggle.py).
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const APP_JS = path.join(__dirname, '..', '..', 'src', 'web', 'app.js');
/**
* Извлекает блок POI-логики из app.js и собирает из него модуль,
* подставляя переданные зависимости.
*/
function loadPoiModule(deps) {
const src = fs.readFileSync(APP_JS, 'utf8');
const m = src.match(
/\/\/ >>> ET-002 POI visibility block[^\n]*\n([\s\S]*?)\/\/ <<< ET-002 POI visibility block/
);
assert.ok(m, 'POI-блок не найден в app.js (маркеры ET-002 отсутствуют)');
const factory = new Function(
'layerState', 'layerGroups', 'window', 'document', 'localStorage',
m[1] + '\nreturn { applyPoiVisibility, onPoiCheckbox, restorePoiState };'
);
return factory(
deps.layerState, deps.layerGroups, deps.window, deps.document, deps.localStorage
);
}
/**
* Готовит изолированное мок-окружение для одного теста.
* @param {object} [opts]
* @param {string} [opts.stored] - значение ключа poi-visible в localStorage
* (если не указан — ключ отсутствует).
* @param {boolean} [opts.layerExists] - что возвращает map.getLayer().
*/
function makeEnv({ stored, layerExists = true } = {}) {
const calls = { setLayoutProperty: [], setItem: [] };
const store = {};
if (stored !== undefined) store['poi-visible'] = stored;
const localStorage = {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); calls.setItem.push([k, String(v)]); },
};
const map = {
getLayer: () => layerExists,
setLayoutProperty: (id, prop, val) => calls.setLayoutProperty.push([id, prop, val]),
};
const checkbox = { checked: true };
const document = {
getElementById: (id) => (id === 'poi-visible-cb' ? checkbox : null),
};
const layerState = { tracks: true, paths: true, poi: true, basemap: true };
const layerGroups = { poi: ['poi-circles', 'poi-labels'] };
const win = { _map: map };
const mod = loadPoiModule({
layerState, layerGroups, window: win, document, localStorage,
});
return { mod, calls, checkbox, layerState, store };
}
// ── TP-01: onPoiCheckbox() скрывает слои при checked=false ───────────────
test('TP-01: снятый чекбокс скрывает слои POI и сохраняет 0', () => {
const env = makeEnv();
env.checkbox.checked = false;
env.mod.onPoiCheckbox();
assert.deepEqual(env.calls.setLayoutProperty, [
['poi-circles', 'visibility', 'none'],
['poi-labels', 'visibility', 'none'],
]);
assert.deepEqual(env.calls.setItem, [['poi-visible', '0']]);
assert.equal(env.layerState.poi, false);
});
// ── TP-02: onPoiCheckbox() показывает слои при checked=true ──────────────
test('TP-02: установленный чекбокс показывает слои POI и сохраняет 1', () => {
const env = makeEnv();
env.checkbox.checked = true;
env.mod.onPoiCheckbox();
assert.deepEqual(env.calls.setLayoutProperty, [
['poi-circles', 'visibility', 'visible'],
['poi-labels', 'visibility', 'visible'],
]);
assert.deepEqual(env.calls.setItem, [['poi-visible', '1']]);
assert.equal(env.layerState.poi, true);
});
// ── TP-03: восстановление состояния — POI скрыты ─────────────────────────
test('TP-03: restorePoiState() при poi-visible=0 скрывает POI', () => {
const env = makeEnv({ stored: '0' });
env.mod.restorePoiState();
assert.equal(env.checkbox.checked, false);
assert.equal(env.layerState.poi, false);
assert.deepEqual(env.calls.setLayoutProperty, [
['poi-circles', 'visibility', 'none'],
['poi-labels', 'visibility', 'none'],
]);
// restore не должен переписывать localStorage
assert.deepEqual(env.calls.setItem, []);
});
// ── TP-04: восстановление состояния — POI видны (default) ────────────────
test('TP-04: restorePoiState() без ключа включает POI по умолчанию', () => {
const env = makeEnv();
env.mod.restorePoiState();
assert.equal(env.checkbox.checked, true);
assert.equal(env.layerState.poi, true);
assert.deepEqual(env.calls.setLayoutProperty, [
['poi-circles', 'visibility', 'visible'],
['poi-labels', 'visibility', 'visible'],
]);
});
// ── Доп.: значение '1' восстанавливает видимость явно ────────────────────
test('restorePoiState() при poi-visible=1 показывает POI', () => {
const env = makeEnv({ stored: '1' });
env.mod.restorePoiState();
assert.equal(env.checkbox.checked, true);
assert.equal(env.layerState.poi, true);
});
// ── Доп.: POI-логика не трогает чужие слои (дух TP-08) ───────────────────
test('onPoiCheckbox() меняет только слои poi-circles и poi-labels', () => {
const env = makeEnv();
env.checkbox.checked = false;
env.mod.onPoiCheckbox();
const touched = env.calls.setLayoutProperty.map((c) => c[0]);
assert.deepEqual([...touched].sort(), ['poi-circles', 'poi-labels']);
});
// ── Доп.: layerState синхронизируется даже без слоёв на карте ────────────
test('applyPoiVisibility() обновляет layerState даже если слой ещё не добавлен', () => {
const env = makeEnv({ layerExists: false });
env.mod.applyPoiVisibility(false);
assert.equal(env.layerState.poi, false);
assert.deepEqual(env.calls.setLayoutProperty, []);
});

View File

@@ -0,0 +1,162 @@
"""ET-002 — тесты чекбокса показа/скрытия POI в попапе рельефа.
Изменение ET-002 — исключительно фронтендовое (`src/web/index.html`,
`src/web/app.js`). В CI исполняется только `pytest tests/`, поэтому файл
покрывает фичу двумя способами:
1. Статические проверки структуры `index.html` и `app.js` — выполняются
всегда, без внешних зависимостей.
2. Поведенческие JS unit-тесты (TP-01..TP-04 из `04-test-plan.yaml`) —
запускаются через встроенный тест-раннер Node (`node --test`). Если
`node` в системе отсутствует — эта часть помечается `skip` (по аналогии
с `tests/integration/test_routing_barriers.py::test_lua_syntax` и
`luac`).
Браузерные e2e-сценарии (TP-05..TP-09) требуют Playwright-инфраструктуры,
которой в репозитории нет; добавление новых npm-пакетов запрещено
`07-infra-requirements.md`. Их поведенческая суть покрыта JS unit-тестами
и статическими проверками ниже.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from shutil import which
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
INDEX_HTML = REPO_ROOT / "src" / "web" / "index.html"
APP_JS = REPO_ROOT / "src" / "web" / "app.js"
JS_TEST = REPO_ROOT / "tests" / "unit" / "poi_toggle.test.js"
def _index_html() -> str:
assert INDEX_HTML.is_file(), f"не найден {INDEX_HTML}"
return INDEX_HTML.read_text(encoding="utf-8")
def _app_js() -> str:
assert APP_JS.is_file(), f"не найден {APP_JS}"
return APP_JS.read_text(encoding="utf-8")
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки index.html (REQ-F-01, UI-спецификация)
# ──────────────────────────────────────────────────────────────────────────────
def test_poi_checkbox_present_in_html():
"""REQ-F-01: чекбокс POI присутствует в попапе с корректными атрибутами."""
html = _index_html()
assert 'id="poi-visible-cb"' in html, "нет чекбокса poi-visible-cb"
assert 'onchange="onPoiCheckbox()"' in html, "чекбокс не привязан к onPoiCheckbox()"
assert "<span>POI</span>" in html, "нет подписи «POI»"
def test_poi_checkbox_checked_by_default():
"""REQ-F-02: чекбокс POI отрисован как checked (POI видны по умолчанию)."""
html = _index_html()
# Атрибут checked должен стоять именно на инпуте poi-visible-cb.
start = html.index('id="poi-visible-cb"')
tag_end = html.index(">", start)
assert "checked" in html[start:tag_end], "у чекбокса POI нет атрибута checked"
def test_poi_checkbox_placed_after_trails_separated_by_hr():
"""REQ-F-01: чекбокс POI стоит после «Тропы» и отделён <hr>."""
html = _index_html()
trails_pos = html.index('id="trails-path-cb"')
poi_pos = html.index('id="poi-visible-cb"')
assert poi_pos > trails_pos, "POI должен идти после чекбокса «Тропы»"
between = html[trails_pos:poi_pos]
assert "<hr" in between, "POI не отделён горизонтальной линией <hr>"
def test_poi_checkbox_uses_shared_style_class():
"""UI-спецификация: чекбокс использует общий класс terrain-checkbox."""
html = _index_html()
start = html.index('id="poi-visible-cb"')
label_start = html.rfind("<label", 0, start)
label_open_end = html.index(">", label_start)
assert 'class="terrain-checkbox"' in html[label_start:label_open_end], (
"чекбокс POI должен быть в label с классом terrain-checkbox"
)
# ──────────────────────────────────────────────────────────────────────────────
# Статические проверки app.js (ADR-0001)
# ──────────────────────────────────────────────────────────────────────────────
def test_poi_functions_defined():
"""ADR-0001: определены хелпер и обработчики POI."""
js = _app_js()
for fn in ("applyPoiVisibility", "onPoiCheckbox", "restorePoiState"):
assert f"function {fn}(" in js, f"не определена функция {fn}()"
def test_poi_logic_uses_localstorage_key():
"""REQ-F-05: персистентность через localStorage ключ poi-visible."""
js = _app_js()
assert "localStorage.setItem('poi-visible'" in js, "состояние POI не сохраняется"
assert "localStorage.getItem('poi-visible')" in js, "состояние POI не читается"
def test_poi_logic_reuses_layer_state_and_groups():
"""ADR-0001 п.3-4: источник истины — layerState.poi, группа слоёв не дублируется."""
js = _app_js()
assert "layerState.poi" in js, "POI-логика не синхронизирует layerState.poi"
assert "layerGroups.poi" in js, "POI-логика должна переиспользовать layerGroups.poi"
# poi-группа объявлена ровно один раз — в общей карте layerGroups.
assert js.count("poi: ['poi-circles', 'poi-labels']") == 1, (
"карта групп слоёв POI задублирована"
)
def test_restore_poi_state_wired_into_init():
"""REQ-F-06: restorePoiState() вызывается при инициализации/смене стиля."""
js = _app_js()
# Один def + минимум 3 вызова (rebuildMapOverlays + 2 ветки initTerrain).
assert js.count("restorePoiState()") >= 4, (
"restorePoiState() не подключён ко всем точкам восстановления"
)
def test_poi_visibility_toggled_via_set_layout_property():
"""ADR-0001 п.1: видимость переключается через setLayoutProperty."""
js = _app_js()
marker = "// >>> ET-002 POI visibility block"
end = "// <<< ET-002 POI visibility block"
assert marker in js and end in js, "маркеры POI-блока отсутствуют"
block = js[js.index(marker):js.index(end)]
assert "setLayoutProperty" in block, "POI-блок не использует setLayoutProperty"
assert "removeLayer" not in block, (
"POI-блок не должен удалять слои (ADR-0001 отвергает вариант C)"
)
# ──────────────────────────────────────────────────────────────────────────────
# Поведенческие JS unit-тесты через Node (TP-01..TP-04)
# ──────────────────────────────────────────────────────────────────────────────
node_required = pytest.mark.skipif(
which("node") is None,
reason="node не установлен — поведенческие JS unit-тесты пропущены",
)
@node_required
def test_js_unit_tests_pass():
"""TP-01..TP-04: запускает behavioral JS-тесты POI через `node --test`."""
assert JS_TEST.is_file(), f"не найден JS-тест {JS_TEST}"
node = which("node")
result = subprocess.run(
[node, "--test", str(JS_TEST)],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
)
assert result.returncode == 0, (
f"JS unit-тесты POI упали (код {result.returncode}):\n"
f"{result.stdout}\n{result.stderr}"
)