# Гео и карты (Фаза 3, волна 2) Канон геокодирования, хранения координат и UX карты. Трекер: [Spec#21](https://git.sabilin.com/EventHub/EventHubSpec/issues/21), эпик [Spec#19](https://git.sabilin.com/EventHub/EventHubSpec/issues/19). Реализация: [Back#77](https://git.sabilin.com/EventHub/EventHubBack/issues/77), [Front#74](https://git.sabilin.com/EventHub/EventHubFront/issues/74), [DevOps#16](https://git.sabilin.com/EventHub/EventHubDevOps/issues/16). Этот документ — контракт; код подтягивается после него. ## Цель Владелец задаёт место события (и org-default календаря) через поиск адреса и пин на карте. Координаты **хранятся у нас**. Участник ищет **ближайшие** commercial-календари и события с опциональным радиусом. Гость видит мини-карту и может открыть ту же точку в привычном приложении карт. Критерий выигрыша: не зависеть от ToS Google/Яндекс на кэш геокода; не блокировать create события, если геокодер лежит. ## Почему не Google / Яндекс как провайдеры Событие — общая сущность: владелец пишет `lat`/`lon`, участники и Discover читают те же числа. - **Google Geocoding:** кэш lat/lng до 30 дней, затем удалить; бессрочно — `place_id`. Исключение «кэш изолирован на одного end-user» **не покрывает** общую точку события. Результат Google нельзя показывать на чужой карте. - **Яндекс (бесплатный API):** данные геокодера нельзя сохранять в свою БД. Коммерческая лицензия — отдельный договор и обязательный логотип / «Открыть в Картах». **Не используем:** Google Maps JavaScript API, Geocoding/Places, Яндекс JS API / Геокодер, ключи карт. **Разрешено:** outbound-ссылки на Google Maps и Яндекс.Карты с **нашими** координатами (Photon или пин пользователя). Это не вызов их API и не хранение их ответа. ## Стек (вариант Ц) | Слой | Выбор | Не берём | |------|--------|----------| | Карта в браузере | [MapLibre GL JS](https://maplibre.org/) | Leaflet + растр `tile.openstreetmap.org` (политика тайлов OSMF) | | Тайлы | [OpenFreeMap](https://openfreemap.org/) публичный инстанс, без ключа | Carto/MapTiler Free (не для коммерции EventHub); свой tile-server | | Геокод | **свой Photon** (Komoot), прокси только через EventHubBack | публичный Nominatim OSMF (1 rps, autocomplete запрещён); `photon.komoot.io` | | Хранение | `address` + `lat`/`lon` в EventHub | `place_id` Google, сырой ответ Яндекса | Данные OSM — [ODbL](https://www.openstreetmap.org/copyright). На карте видимая атрибуция: «© OpenStreetMap contributors» со ссылкой на copyright. Не выгружаем базу OSM оптом через Photon. ``` Front (MapLibre) --tiles--> OpenFreeMap Front --suggest/geocode/reverse--> Back /v1/geo --> Photon (self-host) Back --persist--> event.location / calendar.settings.default_location ``` Front **не** ходит в Photon и Nominatim напрямую. ## Модель данных (уже есть) - Событие: `location` = `{ address, lat?, lon? }`. - Календарь: `settings.default_location` = `{ address, lat?, lon? }` (как сейчас: `address` непустой; `lat`/`lon` оба или ни одного). Волна 2: - **Address-only разрешён.** Create/update события с адресом без координат **не** отбрасывает `location` (сейчас `parse_location` без пары lat/lon даёт `undefined`). - `lat`/`lon` — оба или ни одного; не пара `0, 0` как «пусто» в API (пусто = поля отсутствуют / `null`). - Поиск «рядом»: см. § «Ближайшие календари и события». Сейчас geo режет только события; календари по `default_location` — волна 2 (Back#77). ## API геокодирования (контракт Back#77) `POST` geocode/reverse: user Bearer (пикер). `GET /v1/geo/suggest`: **без токена** (Discover «центр по адресу» для гостя) + rate-limit по IP; с Bearer — тот же лимит на user. Photon с браузера не светим. Кэш одинаковых `q`+`lang` (ETS/Mnesia, TTL дни). Таймаут апстрима ~3 s. ### `GET /v1/geo/suggest?q=&lang=` Typeahead. Клиент debounce (≥300 ms). Пустой / слишком короткий `q` → `400` `{error: "invalid_query"}`. Ответ `200`: ```json { "results": [ { "address": "Москва, Красная площадь", "lat": 55.7539, "lon": 37.6208 } ] } ``` Пустой список — валидный `200`, не ошибка. ### `POST /v1/geo/geocode` Тело: `{ "q": "…", "lang": "ru" }` (`lang` опционален, иначе язык пользователя / `ru`). - `200`: `{ "address": "…", "lat": 55.75, "lon": 37.62, "source": "photon" }` - нет хита: `404` `{error: "not_found"}` - Photon недоступен / таймаут: `503` `{error: "geo_unavailable"}` — **не** 500 ### `POST /v1/geo/reverse` Тело: `{ "lat": 55.75, "lon": 37.62, "lang": "ru" }`. Подпись пина после drag. Тот же shape, что geocode (`source: "photon"`). Нет хита → `200` с `address` из округлённых координат или короткий fallback «lat, lon», без падения пикера. `503` только если Photon недоступен. ### Degrade - Geo-эндпоинты: `503` / пустой suggest, не 500 на весь API. - Create/update события и `default_location`: **не** зависят от Photon. Текстовый адрес сохраняется. - IFT/stage без Photon: сервис живой; e2e пикера пропускают или проверяют degrade (текстовое поле). ## Ops (контракт DevOps#16) Не API-ключи Google/Яндекс. - Compose-сервис **Photon** (JAR + OpenSearch) + volume индекса. Не публиковать Photon в интернет; только сеть compose → EventHubBack. - Env бэкенда: `PHOTON_URL` (например `http://photon:2322`), `PHOTON_TIMEOUT_MS`. Нет map API keys. - Выгрузка OSM: **минимум РФ** (+ СНГ/крупные города, если диск позволяет). Planet — если влезает. Обновление индекса — периодический job, не realtime. - CSP (Traefik / Front): тайлы OpenFreeMap, не Google/Yandex script. Ориентир CSP (уточнить по фактическому style URL): ``` connect-src 'self' https://tiles.openfreemap.org img-src 'self' data: blob: https://tiles.openfreemap.org font-src 'self' data: https://fonts.gstatic.com https://tiles.openfreemap.org worker-src 'self' blob: ``` Geo — same-origin `/v1/geo`. IFT/stage: Photon в compose; без контейнера — degrade, не падение EventHub. **Stage:** свой Photon не запускаем (`PHOTON_URL` пустой). Не используем `photon.komoot.io` как апстрим. Пикер адреса — на IFT при включённом Photon; на stage — текстовый адрес / пин. GPS «рядом» работает без геокодера. ## Front (контракт Front#74) Библиотека: MapLibre GL JS. Стиль OpenFreeMap (например Liberty). Ключей в репозитории Front нет. ### Пикер Форма события и org `default_location` (не сырые lat/lon как основной путь): 1. Поле адреса + suggest (`GET /v1/geo/suggest`). 2. Выбор хита → подставить address/lat/lon. 3. Мини-карта, перетаскиваемый пин → `POST /v1/geo/reverse`. 4. Ручной lat/lon можно спрятать (advanced), не убирать из контракта API. Photon `503`: тост/подсказка, карта и ручной адрес остаются. ### Карточка / просмотр Если есть валидные `lat`/`lon`: - мини-карта MapLibre с пином и атрибуцией OSM; - **рядом** ссылки (`target=_blank`, `rel="noopener noreferrer"`): - Google Maps: `https://www.google.com/maps?q={lat},{lon}` - Яндекс.Карты: `https://yandex.ru/maps/?pt={lon},{lat}&z=16` (порядок **lon,lat**) - OpenStreetMap: `https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=16/{lat}/{lon}` Только адрес без координат: текст адреса, без карты и без deep-link. i18n ru/en. ### Discover — ближайшие Не map-first: тот же список `/search`. Ручной ввод lat/lon **не** основной путь. **Центр поиска** (нужны оба `lat` и `lon`): 1. «Рядом со мной» — `navigator.geolocation` (гость и логин). Отказ/ошибка — без падения; подсказка выбрать адрес. 2. «Искать у адреса» — suggest (`GET /v1/geo/suggest`) → lat/lon центра. Не GPS. **Радиус опционален** (км, UI-пресеты **1 / 5 / 10 / 25 / 50**): | Радиус | Query | Смысл | |--------|--------|--------| | не выбран / «без ограничения» | `lat`,`lon` без `radius` | все точки с координатами, **сортировка по dist** | | выбран пресет | `lat`,`lon`,`radius` | только ≤ N км, сортировка по dist | Дефолт после «рядом со мной»: **10 км** (как сейчас на бэке), пользователь может сменить или снять лимит. `radius` без пары lat/lon игнорируется. Фильтр `type` (`calendar` / `event` / оба) работает как сейчас. `q`/даты/теги сочетаются с geo. В карточке результата — дистанция (`distance_km` с бэка), не сырые координаты. ## Ближайшие календари и события (контракт Back#77) Тот же `GET /v1/search`. Geo-режим: есть `lat` и `lon` → **не** discovery-tops. ### Что сравниваем | Тип | Точка | Нет координат | |-----|--------|----------------| | `event` | `event.location.lat/lon` | вне geo-выборки | | `calendar` | `settings.default_location.lat/lon` | вне geo-выборки (сейчас календари geo **не** фильтруются — это дыра волны 2) | Personal по-прежнему не в search. ACL `can_access` без изменений. Restricted commercial — как в обычном search. Формула: haversine, км (уже есть `logic_search:distance/4`). ### `radius` - Задан: integer **1..100** км; вне диапазона → `400`. Фильтр `distance ≤ radius`. - Не задан: **без отсечения** по dist; только сущности с валидными координатами. - Default бэка `radius=10`, если клиент прислал lat/lon **без** ключа radius — **менять:** отсутствие ключа = без лимита. Front при «рядом» **явно** шлёт `radius=10` (или выбранный пресет). ### Сортировка Geo-режим, клиент не передал `sort` → **`distance` asc**. Явный `sort=title|created_at|start_time` — как сейчас, но только внутри уже отфильтрованных по радиусу. Новое значение: `sort=distance` (км по возрастанию; `order=desc` — дальние первыми, редко нужно). ### Ответ В каждом элементе `events[]` / `calendars[]` при geo-запросе: ```json "distance_km": 1.4 ``` Округление 1 знак. Без geo-параметров поле **нет** (не `null`). Календари в geo-выдаче могут отдавать краткий `default_location` (`address`, `lat`, `lon`) — чтобы Front не гадал. Индекс/PostGIS — **не** волна 2: тот же in-memory scan, что сейчас. ## Non-goals волны 2 - Turn-by-turn, пробки, трекинг курьеров/сотрудников. - Map-first Discover / каталог пинами на всю выдачу. - Свой tile-server / self-host OpenFreeMap. - Публичный Nominatim или `photon.komoot.io` как fallback (чтобы не сесть на чужой fair-use). - Google/Yandex JS SDK, ключи, Places Autocomplete. - Пакетная геокодировка существующих событий без координат (можно later). ## Критерии приёмки канона - [x] Этот документ согласован (Spec#21, вариант Ц). - [ ] DevOps#16: Photon в compose, `PHOTON_URL`, CSP OpenFreeMap; без Google/Yandex keys. - [ ] Back#77: `/v1/geo/*`, кэш, address-only location; search: geo по календарям, optional radius, `sort=distance`, `distance_km`; тесты (mock Photon). - [ ] Front#74: пикер + мини-карта + outbound Google/Яндекс/OSM; Discover ближайшие (GPS/адрес, пресеты радиуса); e2e/mock. - [ ] После тестов: BackSpec/FrontSpec сверены с кодом.