Как я пытался починить поиск по картам для водителей. Часть 2

    Первое, что хочется сказать — это было сложно. Гораздо сложнее, чем я думал. Я имел до этого весьма жесткий опыт выведения продуктов в релиз на работе, однако никогда не дотаскивал до продакшена персональные проекты. Они у меня все заканчивались на прототипах разной степени отвратительности, но этот вроде бы выжил. В данный момент он запущен для 80+ стран (вся Европа, Азия и Северная Америка), на обеих мобильных платформах, и в конце статьи будут ссылки на скачивание — поэтому всех заинтересовавшихся приглашаю попробовать, поломать и поругать.

    Вот вкратце мысль, с которой все началось: На мой взгляд поиск по существующим мобильным картам сделан для пешеходов и не работает для водителей совсем. Нужно останавливаться, копаться в заваленных лишней информацией и рекламой картах, тыкать на маленькие иконки. Это неудобно, никак не выручит тебя в неизвестном месте, в конце концов просто опасно. Необходимо интуитивное и чистое решение, которые не отвлекает и не заставляет тормозить.

    В первой части я описал свой путь от этой нехитрой мысли до рабочего решения, а далее расскажу, как протащил это решение до релиза.

    Чтобы сэкономить ваше время, начну с краткого пересказа предыдущей части: там я пишу, что вместо поиска решил использовать сканирование в движении, а интерфейс приложения — максимально упростить. Вместо нелепой для водителя строки ввода добавил несколько больших кнопок для вещей, которые могут пригодиться в дороге: АЗС, зарядка, банкомат, парковка, аптека. Вместо карты сделал список, а при выборе результата открывается навигация через Apple/Google Maps. Для приложения решил использовать Flutter (заодно познакомился, что это за зверь), данные взял из OpenStreetMap. Закончил свой рассказ на том, что был готов более-менее вменяемый прототип.

    Тогда все это заняло где-то 4-5 месяцев, потом начались перемены в жизни и проект ушел на второй план — да и я начал от него уставать. Еще через месяц смахнул пыль, освежил в голове написанием статейки на хабре и решил: давай будем заканчивать. Любой человек, знающий разницу между прототипом и продуктом, на этом месте грустно улыбнется.

    То, что было дальше, заняло еще месяца четыре. Я завел себе таск-трекер, в общих чертах сформировал список проблем, насобирал девайсов для тестирования. По вечерам и на выходных я садился за работу и двигал проект вперед. В какие моменты казалось, что список только растет, а я тону в бесконечных нюансах и доделках. Брал себя в руки, что-то выкидывал, где-то наоборот загонялся до перфекционизма. Далее я постараюсь рассказать о самых интересных моментах разработки такого, казалось бы, простого проекта.

    Технологии


    Общая архитектура


    Еще где-то в середине работы начало появляться ощущение, что архитектура расползается и выходит из-под контроля. Завелось слишком много компонентов и связей между ними, нащупывались несколько узких мест. С счастью, проект небольшой, и я вовремя это почувствовал, поэтому навести порядок не составило особого труда. На уровне отдельных компонентов это свелось к рефакторингу и выкидываю лишних библиотек, на глобальном уровне я разнес функционал по 3-м небольшим серверам, которые завел в DigitalOcean.

    1. АПИ-сервер (Python) — основной сервер-прокладка, к нему мы и обращаемся. Там не очень много логики, в основном формирование результатов для выдачи. Самый экономичный по ресурсам.
    2. Эластик-сервер (Java) — на нем крутятся Elasticsearch и Photon (опен-сорс геокодер). Они используют один и тот же индекс, в который импортирована вся планета из OpenStreetMap. Функции сервера: поиск мест по полигону и геокодер. По своей природе эластик очень быстрый и легкий, поэтому сервер тоже не очень жирный.
    3. Гео-сервер (Node) — самый тяжелый из всех. На основе Open Source Routing Machine я написал небольшое апи, и в его задачи входят все географические вычисления: прокладка маршрутов, расчет изохрон, генерация тайлов. Каждая отдельная операция не то, чтобы очень ресурсная, однако для любого поиска их нужны десятки, и это становится узким местом. В данный момент на этом сервере 16 гб оперативки, и в целом все работает за доли секунды — кроме генерации тайлов. Когда их много в очереди, ждать картинок с картами можно и несколько секунд. К счастью, на клиенте они появляются асинхронно, и это не сильно портит общей картины (надеюсь).

    Кроме того, я решил скармливать для геовычислений экстракты из OpenStreetMap отдельно по странам. Работает это так: делаем первый запрос с координатами на наш геокодер, тот определяет страну, и тогда мы берем подгруженные файлы только этой страны для нужных нам манипуляций. Это необходимо, потому что даже мой довольно мощный сервер не в состоянии конвертировать экстракт размером больше двух гигабайт — процесс быстро отжирает всю память и захлебывается. К счастью, почти все страны вписываются в этот лимит, кроме США: этого монстра пришлось разбить на штаты. Наконец, для поддержания полутора сотен экстрактов я решил написать пучок скриптов, которые проверяют их на здоровье, чинят и обновляют.



    В общем, все это звучит сложнее, чем работает на практике. В принципе, получившимся решением я доволен — оно имеет неплохой задел для масштабирования по количеству поддерживаемых территорий и уровню нагрузки.

    Динамическая изохрона


    Долгое время одной из ключевых для меня проблем была неоднородная плотность результатов. Причина вполне понятна — это неоднородная плотность самой дорожной сетки и застройки на ней. В городе среднего размера в радиусе 5 минут езды может быть 2-3 банкомата, а теперь переместимся в центр мегаполиса — и для тех же 5 минут может быть и 20, и 30 результатов. Наконец прыгаем в сельскую местность и наблюдаем почти гарантированный 0 результатов, пока мы не приблизимся к городу и радиус поиска что-то захватит.

    Эта проблема дает нелинейную и непредсказуемую нагрузку на сервер, а главное — довольно паршивый опыт для юзера. Добавление в опции фильтра (5 минут, 10 минут, 30 минут) в общем-то ничего не решает. В деревне по-прежнему даже 30-минутный радиус может не вернуть ничего, а в мегаполисе и 5 минут завалят тебя результатами. Плюс мы добавили лишний функционал, в кнопки которого водитель должен на ходу попадать. В общем, ерунда, нужно принципиально другое решение.

    Когда решение нашлось, оно оказалось очень простым. Вместо того, чтобы идти от обратного и предлагать пользователю выбрать радиус поиска, мы можем сделать этот радиус автоматическим. Логика на самом деле элементарная:

    1. Ставишь лимиты результатов — например не меньше 1 и не больше 20 — и стартуешь с 10 минут
    2. Делаешь поиск по местам. Пока что для нас не нужно прокладывать маршруты к ним, поэтому обходимся чисто расчетом изохроны и фильтром по полигону в эластике — обе операции очень дешевые
    3. Если количество результатов вылезает в какую-то сторону из лимитов (в нашем случае 0 или 20+), делим или умножаем время на 2 и опять делаем поиск. Если входит в лимит, то уже тогда строим маршруты, сортируем по времени пути и т.д.

    На деле это немного сложнее и есть пара нюансов, например сверхплотная городская сетка, когда мы уже сократили время до минимального, а результатов все еще слишком много. Тут уже необходимо сортировать, а значит и прокладывать маршруты, что дороговато. Впрочем, это крайние случаи и они не сильно бросаются в глаза.

    В реальности человек вряд ли будет скроллить список ниже 5-6 позиций, поэтому в 95% сценариев динамическая изохрона решила проблему. Мы убрали узкое место — непредсказуемое количество результатов — и сделали нагрузку на географический сервер для любого запроса почти плоской. Проверить это очень легко:

    Старый способ: берем 10-минутный радиус и 30 результатов
    Итог: 1 запрос на изохрону + 30 запросов на маршруты = 31

    Новый способ: проверяем, 30 результатов это много, делим радиус пополам, теперь получаем 10 результатов
    Итог: 2 запроса на изохрону + 10 запросов на маршруты = 12




    Новая логика карт


    В прошлой части я описывал механизм генерации карточек с проложенными маршрутами. Это тогда оказалось довольно сложным и дорогим в плане вычислений занятием, однако они настолько мне понравились, что я решил их оставить. При этом я понимал, что в своем текущем виде они несли мало практической пользы — по ним было непонятно, в какую сторону ты едешь, и все они были повернуты севером наверх. Надо было дорабатывать.

    Первое, что я решил сделать, это в реальном времени разворачивать карты по компасу. Во флаттере это описывалось микроскопической логикой и работало очень быстро, однако при наличии 10+ результатов, которые постоянно крутятся, начинала просаживаться производительность. Кроме того это абсолютно тошнотворно смотрелось: по сути вертелись статичные картинки, и это больше сбивало с толку во время езды, чем как-то помогало.

    Следущей идеей было обозначать на картах направление движения стрелкой. Это было очень просто — у меня уже был вычислен вектор, и надо было всего лишь сгенерировать геометрическую фигуру стрелки. При этом в статичном положении карты продолжали показывать позицию водителя круглым маркером. Был один нюанс — надо было нормализовать размеры маркеров и стрелок для разных уровней зума. Это вроде бы несложная задача, но на ней я застрял надолго. Дело было в следующем: все символы на карте я генерировал в метрах, а за основу брал долю высоты всей карты в метрах. Оказалось, что в ходе создания карт — определения квадратных bounding box, склеивания и обрезания тайлов по ним и т.д — накапливались погрешности, и эти небольшие погрешности в итоге приводили к визуально очень разным размерам маркеров. Особенно адская была ситуация с картами маленького масштаба. Не буду вдаваться в подробности решения, однако из-за этих погрешностей логику генерации карточек пришлось перекроить кардинально. Очень сильно в этом помог turf — прекрасный набор инструментов для манипуляции с геоданными.

    Со стрелкой карты были уже полезнее, однако все-таки чего-то не хватало. После живого тестирования стало понятно — все карточки повернуты севером наверх. В статике это не бросалось в глаза, но моментально становилось очевидным, когда ты садишься за руль. Водитель подсознательно ожидает, что стрелка будет всегда направлена вверх при движении. Обнаружив это, я снова сел на работу. Это опять была одна из тех задач, которые кажутся очень простыми, но ты проведешь за ней пару дней. Казалось бы — вычисляй азимут, да и поворачивай финальный GeoJSON перед растеризацией. Но был опять один нюанс — этот финальный GeoJSON был сгенерирован по прямому bounding box, и, будучи повернутым и обрезанным по нему же, обнаруживает пустые места.



    На схеме выше я примерно привел свое решение. По итогу оно получилось не очень дорогим в плане ресурсов и покрывающим 99% сценариев (думаю, что где-то возле полюсов полезут баги). В целом, сервер геовычислений по-прежнему остается самым ресурсоемким участком проекта, однако теперь его карточки маршрутов кроме эстетики еще и весьма практичны. Я пробовал даже доехать до места исключительно по этим карточкам, не включая навигацию. И даже доехал.



    Качество данных


    Все свои данные я брал разными способами из OpenStreetMap. Как известно, сей ресурс является на 100% некоммерческим и поддерживается коллективным разумом. Это и плюс (он бесплатный и с понятной структурой), это же и минус — данные весьма неоднородные.

    На высоком уровне это означает неравномерное покрытие земного шара в целом: в странах и городах с большим количеством энтузиастов описана каждая лужайка и тропинка, а в других местах есть почти пустые зоны с базовыми схематичными объектами. Обновляются данные с точно такой же неравномерностью. Тестируя свое приложение, я пару раз натыкался на новые заправки, кафе, а иногда и целые дороги, которые не успели еще нанести на карты. Жаловаться на это глупо: тот же гугл тратит астрономические бюджеты и содержит целый штат автомобилей, отвечающих за релевантность его данных. Так что здесь лучшее, что мы можем сделать, это синхронизироваться с экстрактами OpenStreetMap почаще. Ну и пожелать удачи их сообществу.

    А вот на более низком уровне из-за хаотичного редактирования карт возникает целый ряд других проблем, которые вполне решаются. В основном это касается мусорных данных и дубликатов. Многообразие этого бардака поражает: одно и то же место может быть описано 3 раза по-разному, заведения не имеют названий, типы и теги проставлены неправильно и так далее. У всего этого нет какого-то единого решения, скорее необходим комплекс мер по систематизации контента. Например, у меня есть следующие условия:

    Есть несколько синонимов и вариантов одного и того же тега -> описываем словари алиасов (например parking, parking_space, parking_entrance и тд).

    Есть несколько мест с одним и тем же типом и одинаковыми координатами:

    • если у всех нет названия -> названием становится тип места
    • название есть только у одного -> берем его
    • название есть у всех и они разные -> берем хронологически последнее название

    Есть несколько мест с одним и тем же типом и почти одинаковыми координатами:

    • если у всех нет названия -> скорее всего дубликаты, не будем усложнять. Слить в одну точку с усредненными координатами, у которой именем становится тип места. Человек приедет и разберется
    • название есть только у одного -> то же самое, только теперь у нас уже есть имя
    • название есть у всех и они разные -> а вот это уже кластер

    Кластер в нашем случае — карточка, в шапке которой описаны несколько мест. Чаще всего это скопления магазинов или стоящие рядом заправочные станции. Или например один банкомат находится внутри здания банка, а другой снаружи. Никакой сложности для нас они не представляют: вычисляем усредненные координаты и прокладываем маршрут до них. В интерфейсе чисто и просто это показываем:



    Интерфейс и дизайн


    Так у меня сложилось, что до начала разработки я уже обычно представляю себе финальную картину. При этом не люблю рисовать схемы и концепты, предпочитая формировать дизайн параллельно с функциями (если это свой проект, конечно). Такой итеративный подход с одной стороны очень крутой, потому что позволяет переключаться между визуалом и кодом, с другой стороны — иногда приходится переделывать все слишком часто. То же самое вышло и тут: простейший вроде бы интерфейс я перелопачивал раз сто. Начиная от мелочей типа иконок и отступов, заканчивая композицией карточек, менюшками и т.д. Все описывать не буду, пройдусь по-быстрому через ключевые вопросы. Кому не интересен дизайн, смело пропускайте.

    Цветовая палитра


    Я долго не мог понять, что делать с палитрой. Мне очень хотелось обозначить категории мест разными цветами, за исключением зеленого — его решил приберечь как акцент. Выбрал легко различаемые и сочные цвета, вроде все хорошо. Спустя какое-то время обнаружил, что синий для АЗС перекликается с синим, которым на карте обозначается положение водителя. Ничего с этим так не сделал, оставил как есть — но внутренний перфекционист недоволен.



    “По пути” и “Вы рядом”


    После того, как появилась логика, определяющая направление движение водителя, стало возможным разделить маршруты на “по пути” и остальные. Как я уже говорил, это определяется первым сегментом проложенного к месту маршрута: совпадает ли он с последнем сегментом маршрута водителя. Если да, то мы уже едем к этому месту. Дальше возник вопрос, как это показать в интерфейсе. Кроме изменений в карте, которые я описал выше, пришла идея плашки “По пути” (или “En route” по-английски — вроде у них это значит то же самое). Эту же плашку я переиспользую для другого сценария: когда расстояние до найденного места меньше 25 метров. Тогда не имеет смысла прокладывать маршрут, я прячу карту и пишу, что вы уже находитесь близко (“Вы рядом” / “Look around”).



    Общая карта


    В самом начале разработки для дебага я использовал статическую карту от гугла, чтобы увидеть изохрону и результаты. Потом долго с ней носился, не зная, куда прилепить: вроде и карта — штука интересная, но вроде и место занимать не должна. К тому же мучительно не хотелось даже в такой мелочи зависеть от гугла. Так что в итоге карту я тогда убрал, но спустя какое-то время начал генерировать карточки маршрутов и понял, что технологически “дорос” и до большой карты своими силами. Оказалось, что сделать это было уже не так сложно, хотя до сих пор общая карта остается самым ресурсоемким куском всего проекта. А чтобы она не занимала место в интерфейсе, я вынес карту на отдельную страничку (так и дергать ее будут реже).



    Локализация


    Для нормального выхода в продакшн необходима локализация. Это всегда с одной стороны очень прямая и простая работа, с другой — когда начинаешь ею заниматься, отовсюду вылезают толпы тараканов. В моем случае основной контент из OSM уже шел локализованным, так что оставались только типы мест и элементы интерфейса. За исключением нескольких затыков (долго не мог сформулировать плашку “По пути”) все было легко. Стоит заметить, что названия мест могут занимать и 2, и 3 строчки, а в экраны небольшой ширины могут и не влезать — поэтому здесь помог виджет auto_size_text, рекомендую в разумных пределах.



    А вот с технической стороны оказалось не так гладко. На сегодняшней день практически единственное решение для локализации под флаттер — библиотечка Intl_translation, и она… странная. Понятно, что им приходится сидеть на двух стульях и генерировать совершенно разные форматы строчек под андроид и айфон. Однако этот подход с вынесением переводов в отдельный класс, потом прогон скриптов из консоли (!) для создания каких-то промежуточных файлов, потом возня с ними… Это все абсолютно неочевидно для новичка, а главное тяжело для сопровождения, поскольку каждая правка сопровождается ручными танцами с бубном.

    Впрочем, есть вероятность, что это я не разобрался до конца — а может какие-то среды разработки уже делают это все автоматически. В любом случае мой опыт был довольно напряжным, и я надеюсь, что механизмы локализации для флаттера переработают.

    Релиз


    Про сам релиз, собственно, рассказать интересного нечего. Были смутные подозрения, что яблочный магазин откажется аппрувить флаттер-приложение, но они не оправдались — все прошло хорошо. Я перерисовал иконку и набросал интро страничку, которая встречает юзера в первый раз. Пришлось немного повозиться с рисованием картинок, но вроде получилось ничего. Никаких подводных камней с пермишенами и их локализацией тоже не возникло.

    В последний момент сборка релизного билда под андроид сломалась из-за перехода с support на androidx: какие-то библиотеки, которые я использовал, не сразу его поддержали. В итоге я пару дней просто ждал, но надо отдать должное авторам этих библиотек — они починили их очень быстро. И тем не менее этот инцидент лишний раз убедил меня: никакой крупный коммерческий проект я пока на флаттер не потащу. Не смотря на то, как он мне понравился, вся эта история еще очень и очень сырая.

    Ну и, как обещал, ссылки на скачивание:





    Планы


    И пару слов напоследок про планы. Если кому-то кроме меня пригодится эта штука и будут скачивания — у меня есть много идей по дальнейшему развитию. Вот примерный перечень того, что хотелось бы включить в релиз №2:

    • больше данных по местам (например, цены на бензин или типы зарядок для электрокаров)
    • темная тема, для ночных водителей
    • компактный режим, чтобы видеть больше результатов без прокрутки (например, выключать по желанию мини-карты)
    • ускорение картографии (реально нужно обдумать это узкое место — либо оптимизировать как-то хитро, либо перевести на контейнерную виртуализацию)

    Еще дальше очень хотелось бы поддерживать Android Auto и Apple CarPlay. Я никогда не делал для них приложений, поэтому самому любопытно попробовать.

    Все, всем спасибо за внимание.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 20

      0
      Поставил. Прикольная штука, но по мск маршрутики в плашках не отрисовывает, может хабраэффект?
        +1
        Наверное, стоит на старте приложения повесить предупреждение, что время указано самое оптимистичное (указать более или менее достоверное время пути по данным OSM мне кажется малореально).
        Так, например, прямо сейчас ваше приложение показывает 3 минуты до ближайшей заправки, при том, что по кратчайшему маршруту 3 светофора и пара крайне нагруженных участков дороги. При клике по карточке, открывшееся окно карт гугла уже показывает маршруты по 7-10 минут, что уже вполне реально.
          0
          Все правильно, я считаю без трафика — голое время езды. Иногда оно совпадает идеально, а в час-пик часто существенно отличается. Я думал-думал, откуда мне взять трафик, и пока единственный вариант — в определенное время дня ограничить максимальную скорость передвижения. Но это конечно костыль…
          0
          Весьма круто, понравился ваш дизайн, подход. Сейчас делаю что-то похожее на ваш проект, для карт и роутинга использую api mapbox-gl, для роутинга они вроде как внутри используют эту же routing machine. Почему flask? Маршруты, я так понял, вы вне храните? На чём основана работа постоянного обновления данных, маршрутов? Ну т.е. обновление по событию или постоянно дергается сервер?
            0
            Спасибо) фласк просто так — это апи на питоне за три секунды. Маршруты не храню, считаю каждый раз. Данные время от времени синхронизирую с экстрактами OSM (пару скриптов написал)
            0
            Для персонального проекта все хорошо, без особых наворотов. Но я бы сказал, что подходит для малого количества задач. Мне для постоянного использования не подойдет.
            Позвольте спросить, чем не устроил, к примеру, Навител, или 2ГИС?
              0
              Все равно спасибо за отзыв) ну, для меня 2ГИС бесполезен, потому что живу не в СНГ, хотя он хорошо сделан
              Мне приходится часто ездить и бывать в незнакомых городах, где на ходу надо искать заправку или банкомат — вот этот кейс и пытался решить
                0
                Если бы вы жили в СНГ, боюсь что вам OSM бы не сильно помог. Скажем, я точно знаю, что у Сбербанка скажем порядка 100 тыс устройств самообслуживания (включая банкоматы). Но при этом OSM мне показывает, что банкоматов всего скажем 12 тыс на все банки. Вот такое вот покрытие по некоторым параметрам.
                  0
                  Понимаю. Ну, я надеюсь постепенно находить другие источники локальных данных (часы работы, цены на бензин или статус зарядок для электрокаров). А данные OSM использовать в основном для дорог
              0
              Приложение интересное и возможно полезное, но вот при нажатии на конкретный объект открывается окно с картой и зависает — маршрут не строится, ну либо строится очень долго. Это при том, что я тестирую дома с WiFi подключением к скоростному Интернету — в дороге будет еще хуже и пользоваться приложением просто невозможно.
                0
                Идея показалась интересной. Скачал, запустил. Приложение обнаружило заправку, однако посмотреть маршрут, и тем более поехать не удалось: при клике на карточку зачем-то пытается запуститься браузер. А браузеру у меня геолокация не разрешена. На самой же карточке маршрут виден весьма условно (картинка тут). Возможно, это особенности OSM в окрестностях меня.
                Я верно понял, что в самом приложении карты с маршрутом нет?
                В общем, пока не прочувствовал пользу.
                  0
                  Да, приложение кидает не нативный диплинк, а по сути дергает браузер. А тот уже запускает дефолтную на девайсе навигацию — гугл или эппл карты. И видимо, это плохая идея) поправлю, спасибо
                  –1
                  Возможно я что-то не понял, но почему вы считаете, что изохрона это дешево, а построение маршрутов дорого? Мне казалось, изохрона предполагает, что вы строите маршруты.
                    0
                    Думаю, да — это одинаковые по стоимости операции. Обе работают с графом и грузят одни и те же файлы. А вот поиск по полигону в эластике гораздо дешевле.
                    Так что в моем случае лучше пару раз дернуть изохрону+эластик и сузить радиус поиска, чем 20 раз прокладывать маршрут
                      0
                      Хм. Я точно чего-то недопонимаю. Для меня изохрона — это линия, каждой точки которой мы можем достичь за равное время (или проехав одинаковое расстояние). Фактически, если ее тупо «в лоб» реализовывать, то мы строим N маршрутов, каждый из которых отрезаем, как только достигли либо времени, либо расстояния.

                      Наверное это можно как-то оптимизировать, но как N маршрутов могут быть одинаковы по стоимости одному маршруту?
                        +1
                        Там в OSRM используются хитрости типа предпросчитанных таблиц расстояний, поэтому оно такое и быстрое. И поэтому нужен этап конвертации — он генерирует промежуточные файлы со всеми этими таблицами. Но в детали алгоритмов я не погружался.
                        Кроме того, можно поиграться с параметрами изохроны, подобрать оптимальную детализацию например. По сути же результат алгоритма это облако точек, которые обводятся (concave hull). При низком разрешении изохроны игнорируются некоторые второстепенные дороги, точек в облаке меньше, и обводка грубее.
                          0
                          А, ну так да, понятнее — т.е. изохрона это более простые маршруты, и строятся они быстрее. Конвертация кстати по-моему есть у всех. Во всяком случае то что я видел у graphopper, так они тоже строят свой граф на базе OSM, причем отдельно для каждого типа транспортного средства (по той понятной причине, что грузовик не ездит по пешеходным дорожкам и наоборот).
                            0
                            да, при экстракте данных ты указываешь «профиль» — машина, пешеход, велосипед и тд. В профилях разные веса и инструкции к ребрам графа. Для общественного транспорта можно подключить таблицы с координатами остановок и часами прибытия (GTFS)
                    0
                    Дизайн симпатичный и идея не плохая. У меня миниатюры карт, к сожалению, так и не загрузились. И еще, я бы заменил прогресс-бары на мерцающие плейсхолдеры, а то когда на экране сразу несколько «крутилок» — это выглядит странно.
                      0

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое