Наслаждался я как-то сортировкой и тэггированием своей обширной коллекции фоточек с помощью своей самописной утилиты, о которой уже писал здесь. …И тут где-то между мной и креслом опять зачесался «я ж у мамы программист».
И подумалось мне…
Есть множество специальных программ, работающих с метаданными — всевозможные (не будем показывать пальцем) медиаплееры с нескучными обоями, сортировкой и фильтрами по чему и как угодно (особенно как угодно), созданием и редактированием плейлистов, подгружаемыми обложками, текстами и барышнями. Есть, с позволения сказать, просмотрщики изображений с теми же барышнями, коллекциями оных с описаниями, редактированием, тэггированием, гистограммами, спектрограммами и телепрограммами.
И один лишь рынок особой категории геоданных, именующих себя детьми лейтенанта Шмидта, находится в хаотическом состоянии. Анархия раздирала корпорации вроде Гугла… Ладно, я увлёкся. Но в самом деле.
Да, есть Google My Maps. Можно тыкать точки, рисовать линии, группировать по слоям. Но модель данных там крайне простая: фактически плоский список слоёв, никаких деревьев, примитивная одометрия. А вот чтобы «Папочка → подпапочка → маршрут → точки маршрута»…
Можно куда-нибудь в OSMAnd / Maps.me. Мощные как навигационные приложения, но данные в основном живут в GPX или похожих форматах. Это удобно для треков, но плохо подходит для сложных структур: коллекций, связей между объектами, альбомов фотографий и прочих радостей жизни.
Многие пытаются делать это в Notion. Но карта там — это просто виджет, а не часть модели данных. Нет прям��й связи: я передвинул картинку в альбоме — у меня обновился статус у родительской сущности с метаданными, точки на карте и т.д.
А хотелось странного: единой модели, где карта — не просто картинка, а полноценное представление данных. И шоб всё красиво, моментально-реактивно и наглядно. В какой-то момент стало ясно, что проблема не в интерфейсах существующих инструментов. Проблема в модели данных, на которой они построены.
Я почесал репу…
…и попробовал сформулировать, каким должен быть инструмент для работы с личной географией. Не навигация. Не карта ради карты. А органайзер пространственных данных.
В основе лежит разделение сущностей. Я их выделил четыре: Точка, Место, Маршрут и Папка.
Точка
Это чистая география. Широта, долгота, высота и ID. Больше ничего. Она не «принадлежит» месту или маршруту и вообще ничего о них не знает. Это намеренно сделанная атомарная сущность: чистая геометрия без контекста. Сама по себе. Каждое Место связано с одной Точкой. Маршруты состоят из последовательности Точек. Просто через ID Точек, которые, эти ID, Места и Маршруты хранят у себя.
Место
Это уже мета. Место не хранит географических координат. Это название, описание, фотоальбом, ссылки, положение и порядок в дереве, плюс всё, что потребуется впердь. В общем, смысл, метаописание именно места в жизни. У которого, естественно, есть и своя география — привязка к одной конкретной Точке в виде строки с её ID (вообще, айдишники есть у всех сущностей, в виде UUIDv4 — строкой на фронте и в бинарном виде binary(16) в базе, чтобы не раздувать индексы).
Пример: «Беседка, в которой мы впервые поцеловались с Машей». Можно сохранить это место (уже с Точкой на карте), добавить описание, шпионские фотографии, а затем включить его в маршрут вечерней ностальгической прогулки в пятницу.
Маршрут
Это упорядоченная последовательность Точек. Но тоже с разной метаинформацией — теми же описаниями и пр. Из особенностей: одна и та же Точка может входить в Маршрут несколько раз; Маршрут, как и Место, не копирует координаты, а хранит ссылки на существующие Точки. Это позволяет строить как линейные маршруты, так и повторяющиеся сценарии движения.
Пример: та же «Вечерняя прогулка в пятницу вечером». Начинаю из дома (Точка, к которой, например, также привязано Место «Мой дом» с фотографиями кошек и ободранных обоев), захожу в магазин купить воды (Точка), захожу в библиотеку (Точка), шатаюсь по парку (Точка, Точка, Маша, Точка…), на обратном пути опять захожу в ту же библиотеку (та же самая Точка библиотеки, просто ссылка на неё) и возвращаюсь домой (опять та же самая первая Точка, к которой до кучи привязано Место «Мой дом»). Ну, вот так провёл вечер пятницы.
Папка
Сущность, чтобы всё это безобразие организовать иерархически. С её помощью строится древовидная модель с сохранением порядка элементов. У Папки есть, разумеется, поле «id», поле «parent» c ID родительской папки, числовое дробное «srt» для определения порядка с папками-соседями. Есть несколько разных деревьев, состоящих из Папок — в частности, есть дерево для Мест и есть дерево для Маршрутов. Каждая папка может содержать: несколько Мест или Маршрутов (в зависимости от дерева, куда входит папка; это определяется полем «context» Папки); таких же вложенных папок (через то самое поле «parent»). Таким образом, эти деревья строятся автоматом, реактивно, на основе плоского списка объектов Папок на основе их полей «id», «parent», «context» и «srt». У папок, конечно, тоже есть доп.инфа: название, описание и ещё всякое. Ну, а сами Места и Маршруты покладаются в соответствующие Папки благодаря своим собственным полям «folderid» — с айдишниками Папок.
Особенно мне понравилась идея с полной атомарностью безмозглых Точек. На них, наивных, все, кому ни попадя, могут ссылаться, а сами они просто есть. Таким образом, одна Точка может одновременно быть у Места (да и не у одного; мало ли кто ещё в этой беседке мог целоваться; надеюсь, не с Машей), у нескольких Маршрутов и у каждого, возможно, не по одному разу. Может, конечно, и сама по себе болтаться, но смысла в этом ноль.
…Ну и понеслась
Прежде чем погружаться в дебри, предлагаю сразу заглянуть в суть тем, кому важнее код, чем буквы:
Код и доки — GitHub. Там у нас исходники, описание, скрины и даже мануал на русском и английском (ну да, я расстарался на двуязычие).
Пощупать в бою — рабочий сервис, собираемый в rolling. Для «просто потыкать» можно зайти под тестовым аккаунтом — логин/пароль:
test/test.
И да. К чему я вообще всё это пишу. Цель у поста простая, алтруистично-корыстная.
Во-первых, поделиться с миром чем-то, надеюсь, хорошим, открытым и свободным (миру мир, каждому по сус^Wпострбностям и всё такое).
Во-вторых — привлечь уважаемых хабровчан к возможному участию в интересном на мой взгляд проекте: критике, тестированию, идеям и развитию.
Ну, а если кому-то захочется поддержать разработку финансово — буду только рад. Мечты ведь иногда сбываются.
А теперь — под капот.

Зуд между мной и креслом давал о себе знать. Стек я выбрал довольно приземлённый:
MariaDB / MySQL
PHP на бэкенде
Vue 3 (Composition API, TypeScript, SCSS) + Pinia + Axios на фронте
Ничего революционного. С одной стороны — либерально и предсказуемо, с другой — в целом гибко, расширяемо и достаточно, с третьей — стильно, модно, молодёжно. Ну фронтендер я. Веб-разработчик несчастный… Не пинайте убогого.
Тем более, я сразу решил, что проект будет не только открытым, но и свободным, так что, как в старом меме, «…программируйте дома хоть на хаскеле, просто не надо всем пропагандировать…» ну и дальше по тексту; никто ведь не мешает никому всё это переписать на хаскеле, верно? :o)
В общем, завёл у себя на любимом Artix-е (холивары) всё это дело, архитектуру нарисовал на листочке и наваял структуру базы. Получилось на данный момент так (схема базы, упрощённо; основные сущности — points, places, routes и folders, остальное — инфраструктура):

Чтобы было проще понять модель, я нарисовал три упрощённые схемы.
Главная схема модели данных

На схеме видно ключевую идею модели:
Point — атомарная геометрия
Place — метаданные точки
Route — упорядоченный список точек
Folder — иерархия для организации объектов
Визуальная схема концепции

Схема «как это работает в жизни»

Ключевая идея здесь — полная атомарность сущности Point.
Точка ничего не знает о местах, маршрутах или папках.
Все остальные объекты лишь ссылаются на неё.
Как всё это синхронизируется
Когда модель данных более-менее устаканилась, возник следующий вопрос: как синхронизировать изменения между клиентом и сервером.
Можно было пойти традиционным путём:
POST /placePATCH /place/:idDELETE /place/:idPOST /routePATCH /route/:id
…и так далее.
Но довольно быстро стало понятно, что в интерфейсе такого приложения пользователь редко делает одну операцию. Обычно это целая серия изменений:
передвинул точку,
поправил описание места,
добавил пару точек в маршрут,
переместил папку,
удалил что-нибудь лишнее (или, тем более, не лишнее).
Отправлять на сервер десяток отдельных запросов ради одного логического действия показалось странным. Поэтому я пошёл немного другим путём.
Dirty tracking
Все сущности в клиентском состоянии (Pinia-стор) имеют три служебных флага:
added
updated
deleted
Они отмечают, что произошло с объектом за текущую сессию редактирования.
Например:
{ "id": "e7c6c8c4-4e3b-4d2f-8b61-8c9eaa2c1d51", "title": "Беседка", "pointid": "a2c17b8f...", "added": false, "updated": true, "deleted": false }
Таким образом клиент всегда знает, какие объекты реально добавились/изменились/удалились.
Перед отправкой данные фильтруются примерно так: if ((i.added || i.updated || i.deleted) && !(i.added && i.deleted))
берём только изменённые объекты
исключаем те, которые были созданы и удалены в одной сессии
Пакетная синхронизация
Когда пользователь кликает по кнопочке «Сохранить», клиент отправляет один запрос, содержащий пакет изменений. Примерно такой:
{ "userid": "...", "sessionid": "...", "data": { "points": [...], "places": [...], "routes": [...], "folders": [...] } }
Сервер проходит по массивам и выполняет соответствующие операции:
deleted→ DELETEadded→ INSERTupdated→ UPDATE
Причём, в таком порядке, с elseif-ами: если удаляется — уже не важно, менялось или нет; если добавляется — бессмысленно затем апдейтить и т.д. Фактически это мини-транзакция на уровне приложения.
Почему так оказалось удобнее
У такого подхода оказалось несколько приятных свойств:
Минимум сетевых запросов
Все изменения отправляются одним батчем. Это особенно приятно при активном редактировании маршрутов и папок.
Интерфейс остаётся мгновенно реактивным
Пользователь работает с локальной моделью данных, а не ждёт ответа сервера после каждой операции.
Простая логика на сервере
Серверу не нужно поддерживать десятки эндпоинтов. Он просто обрабатывает набор изменений.
Легко расширять модель
Если завтра появится новая сущность, достаточно будет
добавить массив в пакет,
добавить обработчик на сервере.
Протокол синхронизации не меняется.
…И, наконец, пользователю не нужно будет прищуриваться, приподнимая одну бровь (попробуйте, кстати) — «что это он там делает, сохраняет/удаляет, пока я ничего не вижу?…» Всё действия в базе происходят только по нажатию на кнопочку «Сохранить». А ежели пользователь возжелал выйти из системы, не сохранившись… ну, вы знаете: всплывашка на всю Ивановскую «У вас есть несохранённые… Желаете?… А может, всё-таки…»
Кстати, при любом значимом изменении состояния на клиенте, стор синхронизируется со своей копией в localStorage, так что можно и просто окошко/вкладку закрывать-открывать. Хотя, кому это в голову-то придёт… такой шедевр… :o)
Карты
Ну, тут велосипедить было бы, во-первых, странно, а во-вторых, мягко говоря, геморройно. Поэтому карты динамически подключаются в соответствующих компонентах Vue и взаимодействуют со стором по своим API. Переключаться между картами можно по select-у в подвале основного окна сервиса.
Я пока использую две: OpenStreetMap и Яндекс.Карты.
В принципе, думаю добавить ещё варианты со временем.
С картами, конечно, тоже пришлось повозиться… Вернее, с некоторыми особенностями этих их API… Мда. Но оно того стоило.
А где же REST?
Формально его здесь почти нет. И это осознанное решение.
Для интерфейсов, где пользователь работает с целым графом взаимосвязанных объектов, модель «изменения → пакет → синхронизация» оказалась гораздо проще, чем классический CRUD-зоопарк.
Реактивность и Undo / Redo
Бонусом к батчевой синхронизации стало то, что механизм undo/redo — не безжалостное и долгое пинание базы, а просто перемещение по истории состояний модели (snapshot-ов).
В сторе Pinia хранится стек изменений, и отмена операции — это просто откат к предыдущему снимку, а возврат — переход к следующему. Если мы находимся где-то посерёдке и что-то меняем руками, с текущего индекса всё в стеке дальше заменяется новым снимком. В общем, стандартная модель.
Такой подход хорошо сочетается с пакетной синхронизацией. Пока пользователь редактирует данные,
изменения живут локально,
можно свободно отменять действия,
сервер видит только финальный результат, отправленный ему по кнопке «Сохранить».
Так как все сущности хранятся в одном графе объектов, интерфейс автоматически реагирует на изменения:
переместил папку → дерево перестроилось,
изменил точку → обновилась карта,
изменился маршрут → пересчиталась длина,
кликнул по «назад» или «вернуть» → всё перерисовалось,
и т.д. Фактически карта, дерево и редактор — это просто разные представления одной и той же модели данных.
И это, пожалуй, самая приятная часть всей системы.
Интеграция и оффлайн
Чтобы не запирать данные внутри системы, я сразу реализовал полноценные импорт и экспорт в JSON и GPX. Это позволяет не терять связь с внешним миром и другими навигационными приложениями. Да и просто кинуть другу пачку интересных мест/маршрутов. Или от него получить. Пачку.
А благодаря тому, что модель данных живёт в браузере, «Места» работают и как PWA. Можно установить их как приложение, просто спокойно уйти в оффлайн, и продолжить работать с картой, а при восстановлении связи — синхронизировать накопленные изменения.
Вот. Надеюсь, вам всё это понравится и пригодится. Так что добро пожаловать, юзайте, форкайте, ругайте и хвалите:
А я открыт к предложениям, коллаборациям, отзывам, баг-репортам, pull request’ам и донатам, само собой :o)
