Наслаждался я как-то сортировкой и тэггированием своей обширной коллекции фоточек с помощью своей самописной утилиты, о которой уже писал здесь. …И тут где-то между мной и креслом опять зачесался «я ж у мамы программист».
И подумалось мне…
Есть множество специальных программ, работающих с метаданными — всевозможные (не будем показывать пальцем) медиаплееры с нескучными обоями, сортировкой и фильтрами по чему и как угодно (особенно как угодно), созданием и редактированием плейлистов, подгружаемыми обложками, текстами и барышнями. Есть, с позволения сказать, просмотрщики изображений с теми же барышнями, коллекциями оных с описаниями, редактированием, тэггированием, гистограммами, спектрограммами и телепрограммами.
И один лишь рынок особой категории геоданных, именующих себя детьми лейтенанта Шмидта, находится в хаотическом состоянии. Анархия раздирала корпорации вроде Гугла… Ладно, я увлёкся. Но в самом деле.
Да, есть 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)