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


Вот только процесс работы с ними в какой-то момент начал меня утомлять. Чтобы вытащить данные по какому-то нетривиальному запросу, нужно или изучать язык запросов Overpass, или писать скрипты и ковыряться в OSM XML формате.


Проделывая эти манипуляции в сотый раз, я задумался о создании какого-нибудь более простого и удобного инструмента. И вот он готов — https://yourmaps.io, визуальный редактор описаний экспорта OpenStreetMap. В редакторе можно мышкой натыкать граф, каждый узел которого будет представлять операцию или фильтр над потоком OSM объектов, а затем скачать результат в GeoJSON.


Вот пример графа, который выбирает все школы в границах заданного муниципального округа, и затем строит 300-метровые буферы вокруг них:



В результате работы получим вот такой набор полигонов в GeoJSON формате, которые затем можно импортировать в QGIS или еще какой-либо софт.


Под катом — немного про функционал сервиса, а также мой опыт работы с библиотекой Rete.js, которая позволяет легко вставлять визуальное программирование и редактирование графов в свой веб-проект.


Rete.js


Rete.js — JS библиотека для рисования и редактирования графов, с упором именно на визуальное программирование и создание схем обработки данных. К сожалению, документация там не отличается полнотой и до некоторых вещей мне пришлось додумываться самостоятельно.


В этом разделе я приведу примеры кода, как на Rete сделать графы со сложными узлами с различными видами пользовательских контролов. Если вам интересно только про экспорт из OpenStreetMap — сразу переходите к следующему разделу.


image


Граф состоит из узлов (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>&nbsp;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