Геоинформационные системы (ГИС) - достаточно узкоспециализированный класс приложений, однако их алгоритмы из-за тесной своей связи с естественными науками носят важный вспомогательный характер, уменьшая риск ошибок и багов при встрече с предметными задачами или близкими к ним, оправдывая тем самым потерю времени на эксперименты. В этот раз настал черёд библиотеки GeoTools, к которой я давно уже присматривался.
У проекта есть несколько качеств, повышающих его экспериментальную привлекательность:
- Специализированная и достаточно сложная область намекает на выбор объектного языка высокой степени абстракции и продвинутых IDE, что всё вместе должно сильно облегчить и ускорить разработку (теоретически).
- Разработчик в лице Open Source Geospatial Foundation выглядит авторитетным для знакомства со всеми основными свободными стандартами.
- Библиотека относительно неплохо документирована, достаточно много вопросов на SO и примеров кода.
- Появляется возможность использовать её в JavaFX, а также интегрировать Groovy.
Конечно же, в дикой природе встречается огромное количество разных проектов на самых разных языках, так что выбор GeoTools скорее субъективен. Эту библиотеку хотелось бы сравнить с NetTopologySuite или же с sdk от ArcGIS, но тесная дружба с Unix-системами делает общение с C# хотя и возможным, но проблемным, а ArcGIS закрыл регистрацию аккаунта разработчика из некоторых стран, что тоже создаёт проблемы, особенно для использования всего многообразия их сервисов.
К сожалению, у эксперимента сразу же появляется критически слабое место - полезность его результата. Для какой-либо игры с геоинформатикой можно использовать многочисленные существующие программы, например, QGIS Desktop, дублирование такого функционала выглядит бессмысленным. При беглом поиске в сети полезных геосервисов и серверов какой-то уникальной полезной комбинации найдено не было, отсюда цели эксперимента сводятся к оценке удобства этой библиотеки и её основным недостаткам для облегчения выбора стека для геоинформационных задач, если таковые внезапно встретятся, не более. Можно назвать его разведочным тестированием.
Первая попытка экспериментов уже была на стыке модульных Java-миров, но из-за огромного количества различных зависимостей в модульном приложении возникали многочисленные конфликты и решено было подождать обновления. Обновление пришло, но ситуация особенно не изменилась, а так ждать можно бесконечно, что проще убрать модульность из приложения.
В части десктопных тулкитов библиотека туториалами и примерами сильно завязана на Swing, в отличие от того же sdk ArcGIS, но интереснее интегрировать её в более продвинутый тулкит. Примеры работы с JavaFX были найдены в официальном репозитории, так и в сети через FXGraphics2D. Имя метода getScreenToWorld не должно вводить в заблуждение: в него передаются именно координаты сцены, getSceneX() и getSceneY(). Однако такой прямой перерасчёт не будет учитывать трансформаций канваса и если он, например, будет лежать в каком-либо контейнере, то появятся смещения из-за других контролов и координаты будут работать неправильно, здесь можно использовать sceneToLocal. Ну и нужно помнить, что javafx.scene.canvas.Canvas сам по себе не изменяет свои размеры.
Для удаления модульности в части JavaFX в IDE нужно дописать модули, как указано в документации тулкита. Запуск получается немного более хитрым т.к. конфликтующие зависимости нельзя загружать из module path (собственно, это и приводит к конфликтам), однако jvm позволяет смешивать загрузку модульных и немодульных jar-файлов, используя последние из class path. Например, как java -cp "директория проблемных либ/*:app.jar" -p "директория javafx либ" --add-modules=javafx.fxml... app.App, разделители путей системоспецифичны, относительные пути в зависимости от логики и способа запуска шелла, конечно же.
Модель пространственного объекта описывается классом Feature. Некоторые очень важные апи вынесены в статические методы класса org.geotools.data.DataUtilities, что немного неочевидно, в этот класс нужно заглянуть обязательно.
В геоинформатике пространственные данные, а значит и свойства Feature делятся на два вида: позиционные (геометрия) и непозиционные. Их разделение может потребоваться, например, для отображения информации об объекте с распечаткой всех его свойств. Какого-либо специализированного апи для их разделения я не встретил, а значит можно использовать особенность иерархии: интерфейс GeometryAttribute подчинён интерфейсу Property, следовательно проверка свойства на instanceof GeometryAttribute должно, по идее, такое разделение создать.
Для работы с координатными системами нужен пакет org.geotools:gt-epsg-hsql, который распаковывает HSQL базу данных либо по временную директорию, либо путь можно передать через системное свойство из поля DIRECTORY_KEY. Без него что-то будет работать, а что-то будет выдавать ошибки, код будет пытаться запросить crs через CRS.decode("EPSG:XXXX"), вываливаясь на исключении.
Система координат описывается классом CRS (coordinate reference system). Здесь может возникнуть расхождение с параметрами эллипсоида и датумом: в СССР активно использовались системы координат на эллипсоиде Красовского, характеристики которого отличаются от эллипсоида ITRF на который (судя по всему, всё же с некоторыми отличиями) опирается распространённая WGS 84. Хотя вроде как осуществляется постепенный переход, но в литературе и статьях могут встречаться самые разные варианты.
Положение координатных осей может быть разным, как x/y, так и y\x для чего есть CRS.getAxisOrder, как в официальном туториале, так и настройки, так и есть булев флаг в CRS.getAuthorityFactory(boolean longitudeFirst).
Из-за возможных ошибок в конвертации координат между тулкитом и библиотекой, в первую очередь есть смысл сразу создать сетку через gt-grid для упрощение отлова багов (удобно и сразу сделать вывод позиции курсора мыши). Да и карта без координатной сетки выглядит как-то странно. Но если очень мелкую сетку создавать для всей карты, то потребление памяти огромно и на мировой карте при минимальном зуме всё это сливается. Вероятно, можно сделать как тут для Индии, ограничивая её для какого-то определённого участка на карте или же временно на отдельном слое.
При зуммировании карты появляется дублирование меток (не только для сетки). Беглый поиск по другим проектам навёл на метод с интересным именем workaroundLabelCacheBug. После опробования он вроде как решил проблему с дублированием, хотя ценой создания побочного эффекта в виде удаления старых меток, но это всяко меньшая беда.
Библиотека поддерживает достаточно большое количество разных форматов файлов, векторных и растровых, но а первую очередь меня интересовала работа OpenStreetMap-карт, которые поддерживаются через пакет gt-tile-client. Они корректно натягиваются на сниппеты кода выше, разве что перемещение по карте не слишком быстрое, возможно, проблема в скорости отдачи тайлов osm-сервером и здесь можно что-то оптимизировать, зум тоже работает. OSM использует проекцию Меркатора (наследование от WebMercatorTileService), отсекая полюсы по -85..85° широты. Поскольку там своя отдельная иерархия слоёв (TileLayer, AsyncTileLayer, а также различные другие типы) от Layer, то это может осложнить завязывание работы со слоями на какой-то свой тип, унаследованный от Layer, требуя обёртки либо какого-то другого решения. Встроенных настроек кэширования вроде как хватает, хотя при очень активном использовании можно легко нарваться на лимиты сервера.
Однако хотелось бы заставить работать их в оффлайне, на ум приходят разные варианты:
- Проксировать запросы и кэшировать png-тайлы, но из-за разнообразного зуммирования и перемещения по карте затея выглядит такой себе, разве что для небольших участков.
- Запускать в программе локальный OSM-сервер, однако удобные найдены не были. Есть формат MBTiles, можно отдельные регионы выгрузить из osm, но внутри там pbf, для которого сходу не нашёлся декодер, в сторонние конверторы выглядят проблемными.
- Выкачать шейп-файлы для региона, но они там в общем случае очень большого размера, тоже нужно как-то нарезать\разрезать.
В итоге вопрос с оффлайном остаётся открытым. Для экспериментов был набросан простейший макет, для тестирования оказался очень удобным шейп-файл GADM Лихтенштейн из-за его небольших размеров. При комбинации слоя OSM-карт с шейп-файлом получается что-то такое:
Слои в ListView, перетаскивание их также реализовать достаточно легко. В данном случае верхний слой соответствует последнему в списке для упрощения, хотя по аналогии со слоями в графических редакторах сортировка должна быть наоборот. Для перемещения слоёв там есть специальное апи org.geotools.map.MapContent.moveLayer, когда как неаккуратная модификация их списка через layers() может приводить к разного рода ошибкам.
Как видно, шейп-файл располагается почти по границам территории на OSM-карте. По клику мыши можно поискать и выделить объекты на слое (со слоя OSM-карт никаких данных геометрии он не выдаёт). Здесь есть нюанс с работой фильтра intersects из туториалов: если пересечение будет проверяться с ReferencedEnvelope, у которого отсутствует длина\ширина (т.е. минимальные и максимальные координаты совпадают), то такой фильтр может не работать.
С сервера тайлов часть объектов уже подписана, вот зум повыше:
Большой шейп-файл уже способен подвесить приложение, а вот загрузку некоторых цветных GeoTIFF мне дождаться так и не удалось, потребление памяти также очень высокое в сравнении с QGIS. Возможно, что-то не так с созданием стилей, который был взят из примера.
C GeoJSON вроде работает штатно, разве что gt-geojson устаревший, как подмечено там в документации. Например, небольшой файл с океаническими течениями вместе с OSM-слоем карт и сеткой выглядит как-то так:
Для интереса попробовал загрузить GRIB, но перемещение по карте, как и зум, выглядит некорректным, так что для этого формата может потребоваться какие-то свои настройки, хотя в QGIS он тоже что-то подтормаживает.
В поисках хотя бы какой-то полезности от карт вспомнился гео-поиск Википедии. Поскольку в параметрах есть радиус поиска, то было бы неплохо отразить его на карте, проставить маркеры по координатам найденных статей и подписать их. Маркер можно получить стилем, а круг через GeometricShapeFactory как-то так.
Немного сплюснутая форма поисковой окружности связана с особенностями картографических проекций. Так как маркер стилизуется, но сам по себе представлен точкой, то это, вероятно, может осложнить поиск пересечения его геометрии с мышью. С другой стороны, полезность такого поиска статей тоже крайне сомнительна из-за более удобной работы на самом портале, да и основной браузер, как правило, намного удобнее чем WebView.
Расчёт расстояния можно сделать с помощью геодезического калькулятора, он также показывает и угол\азимут. Наиболее удобной оказалась отрисовка линий через сам тулкит, а на последнем клике перенос фигуры на карту. Допустим, нужно рассчитать расстояние между столицей княжества Вадуц и деревней Мюлехольц. Сервис distancecalculator вычисляет 1.66 км, калькулятор:
Учитывая, что точки выбраны очень грубо, то где-то близко. Несложно заметить, что расчёт расстояния получается частным случаем расчёта площади. Её можно получить как через геометрию из того же шейп-файла, так и через рисование нового полигона с конвертацией его в автопроекцию. Сам полигон можно построить также тулкитом, а после последнего клика перенести на карту:
Википедия говорит о 160 кв. километрах, что тоже достаточно близко, учитывая очень грубую обводку, создающую множественные погрешности.
Для фильтров библиотека поддерживает DSL-язык CQL и его расширения ECQL. Здесь сразу на ум приходит попробовать интегрировать в такое приложение Groovy, используя RichTextFX для редактора. Хотя сам фильтр можно сделать и обычным текстовым полем, но это не так интересно, как интеграция полноценного скриптового языка. "==" не сработает, хотя он там прямо об этом скажет (WARNING: "==" is deprecated comparison operator. You should use "="), без одинарных кавычек тоже не работает.
Такая интеграция открывает очень широкие возможности, начиная от рисования и заканчивая обработкой данных. Вопросы здесь есть к RichTextFX, которая скудна на настройки и значительная часть поведения реализуется вручную: подсветка, подсказки, фолдинг и т.д. С одной стороны, это достаточно гибко, ибо припоминаю проблемы с этим в RSyntaxTextArea, с другой - пару раз вылетело исключение, создавая угрозу сделать приложение менее стабильным из-за такого редактора.
Экспортировать карту в изображение можно с помощью GTRenderer, сохраняются все слои.
Также было интересно немного потестировать 3D-глобус, часто встречаемый в геоприложениях:
Встроить 3D в общую сцену можно через javafx.scene.SubScene. Однако тут появляется много неопределённостей: маппинг диффузной текстуры на сферу и смещения опорного меридиана для расчёта долготы, расположение камеры, света и т.д. и т.п., намекая на какой-то простенький встроенный отладчик, писать который сейчас нет времени.
Без отладчика остаётся старый добрый метод тыка: отображение структуры сферы через setDrawMode(DrawMode.LINE), чтобы исключить проваливание в неё других фигур, как и выставление везде нулевых координат для уменьшения побочных эффектов. Из сети был рандомно взят способ вычисления координат, который после серии экспериментов был скорректирован. Трудно сказать, какие погрешности там ещё могут быть из-за особенностей текстуры-изображения и всяких разных побочных эффектов, но выглядит оно работоспособным.
Для вращения глобуса был взят сниппет, однако в случае добавления маркера его нужно будет тоже вращать, как и всё остальное. Отсюда намного удобнее показался другой подход: вращение не группы фигур, а камеры через Rotate с центром вращения (pivot) в координатах центра Земли, что многое упрощает.
На этом идеи для эксперимента закончились, каких-либо полезных задач для взаимодействия с базами данных, геосерверами и прочего функционала найдено не было.
Какие итоги можно подвести. В целом, эксперимент был достаточно интересным, хотя потребовал намного больше времени, чем планировалось. Библиотека неплохо себя показала, она действительно очень мощная и хотя у меня не было каких-то серьёзных задач, думается, что гибкость её очень и очень высокая и она хорошо подходит для сложных гео-приложений. Там даже встречается апи для работы с датами, хотя язык документации вызывает вопросы.
С другой стороны, из-за обширных возможностей скорее наблюдается эффект масштаба или что-то похожее: самодокументирование и лёгкое изучение объектного апи уже не так хорошо работает, а вынос апи в статические методы и утилиты делает его обнаружение очень проблемным. Да и в игру вступает специфичность предметной области, понятия которой могут быть неизвестны или не так пониматься, требуя изучения многочисленных стандартов, так что использование этой библиотеки не самое простое. Всё это немного уменьшает преимущества объектного языка и IDE, выдвигая на первый план документацию, примеры и многочисленные эксперименты с апи, что потребует времени.
Остаются некоторые вопросы с производительностью: часть форматов работает достаточно быстро, а к другой части есть определённые вопросы, особенно к растру, так что есть смысл начинать сразу с них и с нагрузочного тестирования. Возможно, как это обычно бывает в других библиотеках, там есть отдельное стриминговое или аналогичное апи для оптимизаций, кэширования и прочего ускорения, которое мне не попалось.
Ещё здесь наиболее полно ощущается отсутствие null-безопасности в языке: из-за сложного устройства соблюсти все инварианты класса достаточно сложно и в процессе использования апи рождается много NPE, часть которых не слишком интуитивна, например, от неправильного использования или отсутствия стиля, что потенциально может сделать приложение более хрупким. Вероятно, пути этих исключений нужно особенно тщательно продумывать, в ином случае может быть беда.
В целом, можно признать эксперимент успешным, было интересно и весело.