14 KiB
Гео и карты (Фаза 3, волна 2)
Канон геокодирования, хранения координат и UX карты. Трекер: Spec#21, эпик Spec#19.
Реализация: Back#77, Front#74, DevOps#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 | Leaflet + растр tile.openstreetmap.org (политика тайлов OSMF) |
| Тайлы | OpenFreeMap публичный инстанс, без ключа | 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. На карте видимая атрибуция: «© 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:
{
"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 https://picsum.photos https://*.picsum.photos
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 как основной путь):
- Поле адреса + suggest (
GET /v1/geo/suggest). - Выбор хита → подставить address/lat/lon.
- Мини-карта, перетаскиваемый пин →
POST /v1/geo/reverse. - Ручной 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}
- Google Maps:
Только адрес без координат: текст адреса, без карты и без deep-link.
i18n ru/en.
Discover — ближайшие
Не map-first: тот же список /search. Ручной ввод lat/lon не основной путь.
Центр поиска (нужны оба lat и lon):
- «Рядом со мной» —
navigator.geolocation(гость и логин). Отказ/ошибка — без падения; подсказка выбрать адрес. - «Искать у адреса» — 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-запросе:
"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).
Критерии приёмки канона
- Этот документ согласован (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 сверены с кодом.