Экспортируем данные OpenStreetMap с помощью визуального редактора на rete.js
В своей работе я часто сталкиваюсь с задачей по экспорту данных из OpenStreetMap. OSM — это восхитительный источник данных, откуда можно вытащить хоть достопримечательности, хоть районы города, хоть улицы для исследований пешеходной доступности, и вообще что угодно.
Вот только процесс работы с ними в какой-то момент начал меня утомлять. Чтобы вытащить данные по какому-то нетривиальному запросу, нужно или изучать язык запросов Overpass, или писать скрипты и ковыряться в OSM XML формате.
Проделывая эти манипуляции в сотый раз, я задумался о создании какого-нибудь более простого и удобного инструмента. И вот он готов — https://yourmaps.io, визуальный редактор описаний экспорта OpenStreetMap. В редакторе можно мышкой натыкать граф, каждый узел которого будет представлять операцию или фильтр над потоком OSM объектов, а затем скачать результат в GeoJSON.
Вот пример графа, который выбирает все школы в границах заданного муниципального округа, и затем строит 300-метровые буферы вокруг них:
В результате работы получим вот такой набор полигонов в GeoJSON формате, которые затем можно импортировать в QGIS или еще какой-либо софт.
Под катом — немного про функционал сервиса, а также мой опыт работы с библиотекой Rete.js, которая позволяет легко вставлять визуальное программирование и редактирование графов в свой веб-проект.
Rete.js
Rete.js — JS библиотека для рисования и редактирования графов, с упором именно на визуальное программирование и создание схем обработки данных. К сожалению, документация там не отличается полнотой и до некоторых вещей мне пришлось додумываться самостоятельно.
В этом разделе я приведу примеры кода, как на Rete сделать графы со сложными узлами с различными видами пользовательских контролов. Если вам интересно только про экспорт из OpenStreetMap — сразу переходите к следующему разделу.
Граф состоит из узлов (node), которые создаются на основе компонент (component). При этом у каждого узла есть входы, выходы и контролы (элементы пользовательского ввода). Также к каждому узлу привязано поле data, хранящее его состояние (например, данные, введенные пользователем)
В документации по Rete есть простой пример с полем для ввода чисел. Однако мне быстро потребовались и более сложные варианты: например, селекты для выбора режима работы узла или кнопка для добавления новых полей ввода (чтобы можно было менять количество значений в фильтрах) или входов узла.
Мне пришлось самому додумываться до того, как сделать такие сложные элементы, так что на всякий случай приведу тут код того, что у меня получилось, если вдруг кому-то придется решать схожую задачу.
Код ниже — для компонента фильтра по значению тега, позволяющего добавлять новые значения по нажатию на кнопку, и имеющего селект для выбора режима сравнения (совпадение или несовпадение значения тега). Сразу скажу, что веб-программирование это не мой конек, кто-то может меня тут побить ногами за использование jquery в 2к20, но по другому я не умею. Да и полезен тут принцип работы с контролами рете, а не нюансы джаваскрипта.
Код InputControl взят из примера Rete, это просто текстовое поле ввода.
var SelectComponent = {
// Шаблон - это HTML элементы, которые будут добавляться к нашему узлу графа, и на которые можно ссылаться как на this.root дальше по коду
template: '<select></select>',
data() {
return {
value: ""
};
},
methods: {
update() {
// сохраняем данные о состоянии в наш узел графа
this.putData(this.ikey, $(this.root).val())
}
},
// метод вызовется при привязке компонента к реальному узлу графа
mounted() {
// this.root - это html элемент, созданный по нашему template, т.е. select в данном случае
let jqueryRoot = $(this.root)
// накидаем в селект нужных значений
for (let idx = 0; idx < this.values.length; ++idx) {
let v = this.values[idx]
jqueryRoot.append($("<option></option>")
.attr("value", v[0])
.text(v[1]));
}
// если мы загружаем уже готовый граф - в данных нашего узла уже будет выбранное значение, восстановим его
let currentVal = this.getData(this.ikey)
if (currentVal === undefined) {
currentVal = this.defaultValue
this.putData(this.ikey, this.defaultValue)
}
jqueryRoot.val(currentVal);
const _self = this;
// на каждое изменение значения селекта будем сохранять его в data
jqueryRoot.change(function() {
_self.root.update()
})
}
}
// Дальше этот контрол можно добавлять к узлу графа как node.addControl(new SelectControl(...))
class SelectControl extends Rete.Control {
constructor(emitter, key, values, defaultValue) {
super(key);
this.key = key;
this.component = SelectComponent
// к этим полям можно получить доступ из кода компоненты контрола, в них можно хранить данные конкретного инстанса
this.props = { emitter, ikey: key, values: values, defaultValue: defaultValue};
}
}
var AddTextFieldComponent = {
// наш шаблон - это кнопка, по нажатию на которую будем добавлять новый InputControl
template: '<button type="button" class="btn btn-outline-light">' +
'<i class="fa fa-plus-circle"></i> Add Value</button>',
data() {
return {
value: ""
};
},
methods: {
// метод для подсчета того, сколько контролов уже есть, считаем InputControlы, у которых id начинается с заданного префикса
getCount(node, prefix) {
let count = 0;
node.controls.forEach((value, key, map) => {
if (key.startsWith(prefix) && value instanceof InputControl) {
++count;
}
});
return count;
},
// по клику на кнопку добавляем новый контрол с именем, состоящем из префикса и индекса
update(e) {
let count = this.methods.getCount(this.node, this.prefix)
this.node.addControl(new InputControl(this.editor, this.prefix + count))
// следующие два метода надо пнуть, чтобы заставить Rete перерисовать узел графа с новым контролом
this.node.update()
this.emitter.view.updateConnections(this)
// дополнительно сохраняем в данные узла графа общее количество контролов, чтобы при загрузке графа из json было ясно, сколько надо полей ввода создать
this.putData(this.iKey, count + 1)
}
},
mounted() {
const _self = this;
this.root.onclick = function(event) {
_self.root.update()
}
}
};
class AddTextFieldControl extends Rete.Control {
constructor(emitter, key, prefix, node, inputPlaceholder) {
super(key);
this.key = key;
this.component = AddTextFieldComponent
this.props = { emitter, iKey: key, prefix: prefix, node: node, inputPlaceholder: inputPlaceholder};
}
}
class FilterByTagValueComponent extends Rete.Component {
constructor(){
super("Filter_by_Tag_Value");
}
builder(node) {
// наш узел фильтрации принимает и выдает потоки объектов карты, для этого у меня заведен тип osm.
// Механизм сокетов позволяет в Rete ограничивать то, какие входы и выходы можно соединять друг с другом
var input = new Rete.Input('osm',"Map Data", osmSocket);
var output = new Rete.Output('osm', "Filtered Map Data", osmSocket);
// контрол для ввода названия тега
var tagNameInput = new InputControl(this.editor, 'tag_name')
// контрол с выбором режима сравнения значения тега
var modeControl = new SelectControl(this.editor,
"mode",
[["EQUAL", "=="], ["NOT_EQUAL", "!="], ["GREATER", ">"], ["LESS", "<"], ["GE", ">="], ["LE", ">="]],
"EQUAL")
// добавляем наши инпуты
node.addInput(input)
.addControl(tagNameInput)
.addControl(modeControl)
.addControl(new AddTextFieldControl(this.editor, "tag_valueCount", "tag_value", node, "Tag Value"))
// Если мы восстанавливаем узел графа из json - надо прочитать, сколько инпутов в нем было, и добавить нужное количество
// Значение data.tag_valueCount записывает AddTextFieldControl, описанный выше
let valuesCount = 1;
if (node.data.tag_valueCount !== undefined) {
valuesCount = node.data.tag_valueCount
}
// Добавляем нужное количество InputControlов
node.addControl(new InputControl(this.editor, 'tag_value'))
for (let i = 1; i < valuesCount; ++i) {
node.addControl(new InputControl(this.editor, 'tag_value' + i))
}
return node
.addOutput(output);
}
}
В итоге мне понадобились дополнительные контролы для:
- Селектов
- Добавления полей ввода
- Добавления входов узла
- Выбора области на карте (для этого в моем контроле по нажатию кнопки открывался поп-ап с картой, нарисованной на leaflet.js и плагином по выбору области). А, еще я использовал апи статических карт Here Maps для отображения превью карты
С их помощью можно сделать узлы графа для решения всех типичных задач обработки данных карт.
После нажатия пользователем кнопки запуска, граф сериализуется в JSON (в Rete уже есть сохранение и загрузка графов), отправляется на сервер, там парсится и обрабатывается.
Примеры экспорта OSM данных
В этом разделе я приведу несколько примеров того, как можно использовать нарисованный граф для решения задач экспорта картографических данных.
Начнем с простого: выберем все парки (объекты с тегом leisure=park, все популярные значения тегов можно найти на вики OSM):
В графе у нас слева — узел, скачивающий OSM данные для указанного района, затем узел, фильтрующий по наличию тега и наконец узел с результатом. Первый узел создает поток картографических объектов (кто хоть немного разбирается с функциональным программированиям и всякими стримами (в терминах Java) — тот легко поймет как оно работает), второй его фильтрует, а третий сохраняет результат, который потом можно скачать или просмотреть.
Полученные объекты выделены синим, можно просмотреть их значения тегов:
Пример посложнее: хотим построить 500-метровые круглые зоны доступности вокруг школ:
Тут мы сперва получаем поток объектов для области, затем фильтруем его по тегу amenity=school, затем для каждой школы от ее геометрии переходим к центроиду (точка — центр масс), затем вокруг центроида строим буфер нужной толщины.
Можно было бы строить буфер сразу вокруг школы, но тогда его форма зависела бы от формы здания школы. А буфер вокруг точки-центроида всегда будет круглым.
Что делать, если мы хотим получить не только буфера, но и сами здания школ? Все просто: разделяем поток после фильтра по тегу на два (оба потока будут копиями друг друга и будут содержать те же значения), один обрабатываем буфером, другой оставляем как есть, затем объединяем их с помощью узла Union. Этот узел просто сливает все входные потоки в один выходной:
Получаем результат… упс. Некоторые школы показаны полигонами-зданиями, а некоторые — маркерами, т.е. точками. Оказывается, некоторые объекты с amenity=school это не здания школ, а точки, находящиеся внутри полигонов зданий. Так обычно мапят тогда, когда объект не занимает все здание целиком.
В зависимости от того, что нам нужно, мы можем либо отбросить такие точечные объекты вообще с помощью узла-фильтра по геометрии. Или можем немного извратиться вот так:
Это довольно сложный пример с переносом тегов с точечных объектов на здания. Похожий пример я подробно описал в документации по нашему проекту. Вкратце — мы оставляем только те здания из ветки 4, которые пересекаются хотя бы с одной школой из ветки 3. Потом сливаем их в один поток вместе с этими школами. И затем объединяем в этом потоке пересекающиеся
объекты в один. Т.е. мы объединим точки-школы и полигоны-здания, в которые они попадают.
В результате получаем полигоны зданий и зон доступности вокруг них:
Заключение
Вот так с помощью простого визуального редактора на Rete.js наш сервис YourMaps позволяет просто выполнять достаточно сложные задачи экспорта и преобразования картографических объектов.
В дальнейшем я планирую туда добавить еще больше всего — например, возможность загружать данные не только из OSM, но и из своих GeoJSON файлов, больше типов операций и фильтров и т.п.
Мне лично этот сервис уже неплохо помогает. Например, когда надо студенту что-то быстро показать на OSM карте — мне не надо больше запускать QGIS и вспоминать сложный язык запросов Overpass, я в пару движений мышкой накликиваю нужный граф, за несколько секунд он обрабатывается и можно сразу там же увидеть результат.
Надеюсь, он окажется полезным и кому-то из вас. Как всегда, готов выслушать предложения и пожелания или тут в комментариях, или можете прислать на почту evsmirnov@itmo.ru