06.08.2018 Улучшение веб-ГИС "VerySimpleGis". Выделение объектов на карте.

Перед тем как начать

Данная статья является логическим продолжением предыдущей статьи Создание простой веб-ГИС "VerySimpleGis", в которой мы создали нашу первую самую простую веб-ГИС с возможностью получения информации об объекте по клику на нем с использованием библиотеки GDAL/OGR. Что это за библиотека и как ее подключить к своему проекту на платформе .NET Framework, вы можете ознакомиться в статье Программируем с использованием GDAL/OGR на C# (часть 1). Установка и настройка. Ну а в этой статье мы дополним нашу интерактивную карту функцией выделения объекта.

Подготавливаем проект

Если вы хотите повторить все мои действия самостоятельно, то вам нужно клонировать репозиторий и перейти на предыдущую ревизию. Отмечу, что у вас должен быть установлен Git. Первым делом клонируем репозиторий с VerySimpleGis командой и переходим на нужную ревизию. Заходим в нужную директорию и выполняем следующие команды:

git clone https://github.com/freewindsv/VerySimpleGis.git VerySimpleGis
cd VerySimpleGis
git reset --hard 440f695f755e6e5cc2eb815e0ab95da0201dec20

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

Теперь открываем проект в Visual Studio 2017. Первым делом обновим все установленные nuget пакеты. Открываем консоль диспетчера пакетов командой из меню: Средства --> Диспетчер пактов NuGet --> Консоль диспетчера пакетов. И в консоли набираем команду:

Update-Package

После некоторого времени необходимые пакеты будут обновлены. Далее, удалим ссылки на внешние библиотеки. Для этого развернем объект "Ссылки" в обозревателе решений, выделим все указанные сборки (зажав клавишу Ctrl и кликая по ним мышью), как показано на скриншоте ниже, и жмем клавишу Delete

Удаление ненужных сборок
Удаление ненужных сборок

Теперь перейдем в консоль диспетчера пакетов и установим необходимый нам пакет GDAL Native. Для этого выполним команду:

Install-Package Gdal.Native -Version 1.11.1

Следующим действием заходим в свойства проекта VerySimpleGis, переходим на вкладку "Сборка" и в качестве целевой платформы указываем значение Any CPU:

Смена целевой платформы проекта
Смена целевой платформы проекта

Теперь открываем файл Global.asax.cs и приводим его к виду:

using System.Web.Mvc;
using System.Web.Routing;

namespace VerySimpleGis
{
    public class MvcApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            RouteConfig.RegisterRoutes(RouteTable.Routes);

            GdalConfiguration.ConfigureOgr();  //register all vector drivers
        }
    }
}

Если зайти в данный метод, то можно увидеть, что он выполняем регистрацию всех доступных драйверов для доступа к векторным данным. Далее, пересобираем решение. Если все выполнено верно, то пересборка решения должна завершиться успешно. Запустим наш проект и мы увидим, что все работает как положено: при клике на страну мы получаем с сервера ее название. Т.е. пока что мы имеем функциональность, как и в самой первой статье о VerySimpleGis.

Теперь заменим используемую в проекте библиотеку Leaflet, на самую свежую версию библиотеки OpenLayers. Переход на OpenLayers я решил осуществить лишь по одной причине - она обладает большими возможностями, да и я уже успел привыкнуть к ней, если честно )) Перейдем на официальный сайт OpenLayers, откроем ссылку Get the Code и загрузим архив v5.1.3-dist.zip (последняя версия на момент публикации) Данную версию библиотеки можно также загрузить с моего ресурса по ссылке: v5.1.3-dist.zip. Распакуем архив в директорию проекта Content/js/ol-v5.1.3, а каталог Content/js/leaflet удалим вместе со всем его содержимым. Включим новые файлы в проект. В результате у вас должна получиться такая структура:

Добавление библиотеки OpenLayers в проект
Добавление библиотеки OpenLayers в проект

Теперь пришло время поправим ссылки на нашу библиотеку в файле Views/Home/Index.cshtml. Указанный файл теперь будет выглядеть таким образом:

@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <link href="~/Content/js/ol-v5.1.3/ol.css" rel="stylesheet" />
    <link href="~/Content/css/style.css" rel="stylesheet" />
    <script src="https://code.jquery.com/jquery-3.2.1.min.js" 
            integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" 
            crossorigin="anonymous"></script>
    <script src="~/Content/js/ol-v5.1.3/ol.js"></script>
    <script src="~/Content/js/main.js"></script>
    <title>VerySimpleGis</title>
</head>
<body>
    <div id="map" class="map">
        <div id="info">VerySimpleGis</div>
    </div>
    <script type="text/javascript">
        var App = {};
        App.ROOT = '@Url.Content("~/")';
    </script>
</body>
</html>

В нем мы заменили ссылку на новую таблицу стилей и javascript-файл библиотеки. Откроем файл Content/js/main.js и адаптируем его содержимое под новую библиотеку.

function initMap() {
    var map = new ol.Map({
        target: 'map',
        layers: [
            new ol.layer.Tile({
                source: new ol.source.OSM()
            }),
            new ol.layer.Tile({
                source: new ol.source.TileWMS({
                    projection: 'EPSG:4326',
                    url: 'http://localhost:8888/cgi-bin/mapserv.exe?map=../htdocs/mydemo/wms_ol.map&',
                    params: { 'LAYERS': 'world_poly', 'TILED': true, 'VERSION': '1.1.1' },
                }),
                opacity: 0.7
            })
        ],
        view: new ol.View({
            center: ol.proj.fromLonLat([37.41, 8.82]),
            zoom: 4
        })
    });

    map.on('click', function (event) {
        var coord = ol.proj.toLonLat(event.coordinate);
        $.ajax({
            url: App.ROOT + 'Home/GetGeoData',
            data: JSON.stringify([coord[1], coord[0]])
        }).done(function (data) {
            showInfo(data);
        });
    });
}

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

Добавляем выделение объектов

Ну вот, все шаги по настройке и обновлению проекта уже позади и теперь мы приступаем к расширению функциональности нашего решения. Первым делом, внесем изменения в файл модели Models/Entities/Country.cs. Добавим к этому классу свойство, которое будет содержать координаты нашего объекта страны в формате WKT. WKT (Well-known text) - это текстовый формат описания геометрических объектов. В этом формате, например, точка будет описываться следующей строкой:

POINT(1 2)
namespace VerySimpleGis.Models.Entities
{
    public class Country
    {
        public string Name { get; set; }
        public string GeomWKT { get; set; }
    }
}

В классе Country мы добавили свойство GeomWKT, которое будет хранить координаты выделяемой страны. Теперь откроем файл Models/GisAccess.cs и приведем содержимое метода public Country GetCountryByCoordinates(double x, double y) к виду:

public Country GetCountryByCoordinates(double x, double y)
{
    x = Normalize(x);
    string wkt = $"POINT({x.ToString(nfi)} {y.ToString(nfi)})";
    Geometry filter = null;
    Feature feature = null;
    try
    {
        filter = Ogr.CreateGeometryFromWkt(ref wkt, layer.GetLayerDefn().GetGeomFieldDefn(GEOM_FIELD_IDX).GetSpatialRef());
        layer.SetSpatialFilter(filter);
        feature = layer.GetNextFeature();
        if (feature != null)
        {
            string featureWkt = null;
            int error = feature.GetGeomFieldRef(GEOM_FIELD_IDX).ExportToWkt(out featureWkt);
            if (error == OGRERR_NONE)
            {
                return new Country() { Name = feature.GetFieldAsString(COUNTRY_ATTR_NAME_IDX), GeomWKT = featureWkt };
            }
        }
    }
    catch(Exception ex)
    {
        throw new GisException("Error getting feature by coordinates", ex);
    }
    finally
    {
        if (filter != null)
        {
            filter.Dispose();
        }
        if (feature != null)
        {
            feature.Dispose();
        }
    }
    return null;
}

А в самом классе GisAccess добавим константу: private const int OGRERR_NONE = 0;, которая содержит значение, указывающее на то, что вызов метода ExportToWkt завершился успешно.

Итак, разберем обновленный метод GetCountryByCoordinates. Самым значимым событием является то, что мы добавили получение координат объекта в формате WKT с помощью строки int error = feature.GetGeomFieldRef(GEOM_FIELD_IDX).ExportToWkt(out featureWkt);. Здесь мы получаем ссылку на объект типа Geometry, по индексу поля, хранящего пространственные данные объекта. В нашем случае такое поле единственное, поэтому в вызов метода мы передаем 0. И у полученного объекта геометрии мы вызываем метод ExportToWkt, в который мы передаем строку, куда будет записано значение координат в WKT-формате. Затем проверяем наличие ошибки и, если все в порядке, то возвращаем объект страны, присвоив полю GeomWKT полученное ранее значение. Среди прочих изменений следует отметить тот факт, что вызов методов мы обернули в блок try - catch - finally. И в блоке finally теперь выполняем освобождение ресурсов более ненужных объектов filter и feature. Если исключение в процессе выполнения блока try будет выброшено, то оно будет обработано в блоке catch, который просто выбрасывает новое исключение типа GisException, снабжая его собственным описанием. А еще мы поправили название метода Normalize, сделав первую букву заглавной, чтобы Visual Studio не придиралась )

Для проверки временно добавим в функцию function showInfo(obj) вывод свойства obj.GeomWKT. Итак, обновленный метод будет выглядеть следующим образом:

function showInfo(obj) {
    console.log(obj.GeomWKT);
    alert(typeof (obj.Name) != 'undefined' ? obj.Name : 'Нет объекта!');
}

Пересоберем проект и запустим. Теперь кликнем на какую-нибудь страну и в консоли браузера увидим описание геометрии объекта в формате WKT:

WKT-формат в консоли браузера
WKT-формат в консоли браузера

Обновляем клиентскую часть

Мы вышли уже на финишную прямую и нам осталось только внести изменеия в файл Content/js/main.js, в которм собственно и будет происходить выделение нужного нам объекта. Но сперва давайте избавимся от надоедливого сообщения через alert и заменим отображение информации о выделенной стране через специальный слой div с id="info", который пылился у нас без дела в файле View/Home/Index.cshtml. В конец файла Content/js/main.js добавим объект UI, отвечающий за взаимодействие с интерфейсом. В нем определен единственный метод showInfo, который и выполняет отображение переданной ему строки в указанном выше div'е.

UI = {
    showInfo: function (str) {
        $('#info').text(str);
    }
};

Далее, перед вызовом initAjax(); добавим код:

var siteSource = new ol.source.Vector();
var selectInteraction = new ol.interaction.Select({
    condition: function (mapBrowserEvent) {
        return false;
    },
    removeCondition: function (mapBrowserEvent) {
        return false;
    }
});

initAjax();

О том, что в нем происходит я расскажу чуть позже. А пока что внесем следующие изменения в функцию initMap():

function initMap() {
    var map = new ol.Map({
        target: 'map',
        layers: [
            new ol.layer.Tile({
                source: new ol.source.OSM()
            }),
            new ol.layer.Tile({
                source: new ol.source.TileWMS({
                    projection: 'EPSG:4326',
                    url: 'http://localhost:8888/cgi-bin/mapserv.exe?map=../htdocs/mydemo/wms_ol.map&',
                    params: { 'LAYERS': 'world_poly', 'TILED': true, 'VERSION': '1.1.1' },
                }),
                opacity: 0.7
            }),
            new ol.layer.Vector({
                source: siteSource
            }),
        ],
        view: new ol.View({
            center: ol.proj.fromLonLat([37.41, 8.82]),
            zoom: 4
        })
    });

    map.addInteraction(selectInteraction);
    map.on('click', function (event) {
        selectInteraction.getFeatures().clear();
        var coord = ol.proj.toLonLat(event.coordinate);
        $.ajax({
            url: App.ROOT + 'Home/GetGeoData',
            data: JSON.stringify([coord[1], coord[0]])
        }).done(function (data) {
            showInfo(data);
        });
    });
}

В данной функции мы добавили новый векторный слой, в качестве источника данных слоя используется ранее определенный объект var siteSource = new ol.source.Vector();. Строкой map.addInteraction(selectInteraction); мы добавляем к карте ранее созданный объект взаимодействия selectInteraction, который используется специально для выделения объектов. Чуть позже я несколько подробнее опишу параметры которые были использованы при его создании. И последнее изменение в данной функции - добавление строки selectInteraction.getFeatures().clear(); в обработчик события клика на карте. методом getFeatures() мы получаем коллекцию выделенных объектов, а последующим методом clear() - очищаем ее. Т.е. сбрасываем выделение со всех объектов.

И, наконец, последнее изменение в функции showInfo(obj):

function showInfo(obj) {
    if (typeof (obj.Name) != 'undefined') {
        var format = new ol.format.WKT();
        var feature = format.readFeature(obj.GeomWKT, {
            dataProjection: 'EPSG:4326',
            featureProjection: 'EPSG:3857' });
        feature.set('name', obj.Name);
        siteSource.clear();
        siteSource.addFeature(feature);
        selectInteraction.getFeatures().push(feature);
        UI.showInfo(obj.Name);
    }
    else {
        UI.showInfo('Нет объекта!');
    }
}

Убедившись, что объект существует, мы создаем объект для чтения и записи формата геометрии типа WKT. В нашем случае мы будем создавать картографический объект библиотеки OpenLayers (feature) из WKT-представления геометрии. Собственно, это и выполняет строка var feature = format.readFeature(obj.GeomWKT, { dataProjection: 'EPSG:4326', featureProjection: 'EPSG:3857' });. В первом аргументе мы передаем WKT-строку с описанием геометрии, а во втором - мы указываем идентификатор системы координат исходных данных - это EPSG:4326 и идентификатор системы координат нашего объекта - это 'EPSG:3857', т.к. подложка OSM, указанная как самый первый слой, представлена именно в данной проекции. Затем в качестве атрибутивных данных мы устанавливаем имя нашего объекта. (Хотя они нами здесь пока что не используются.) Строкой siteSource.clear(); мы очищаем наш векторный слой, а следующей строкой: siteSource.addFeature(feature); - мы добавляем наш объект к векторному слою. Таким образом, при каждом новом клике, старый объект удаляется, а новый - добавляется. Строкой selectInteraction.getFeatures().push(feature); мы добавляем наш объект к коллекции выделенных объектов, благодаря такому действию к объекту применяются специальные стили, которые визуально выделяют объект среди остальных. Ну и, наконец, строкой UI.showInfo(obj.Name); мы отображаем имя выделенного объекта в нашем специальном div'е, который располагается в правом верхнем углу.

Итак, если кто-то запутался, то обновленный файл Content/js/main.js будет выглядеть следующим образом:

$(function () {
    var siteSource = new ol.source.Vector();
    var selectInteraction = new ol.interaction.Select({
        condition: function (mapBrowserEvent) {
            return false;
        },
        removeCondition: function (mapBrowserEvent) {
            return false;
        }
    });

    initAjax();
    initMap();

    function initAjax() {
        $.ajaxSetup({
            global: true,   //trigger global Ajax event handlers for requests
            method: 'POST',
            timeout: 30000,
            dataType: 'json',
            contentType: 'application/json; charset=utf-8',
            error: function (jqXHR, textStatus, errorThrown) {
                if (textStatus == 'timeout') {
                    alert('При выполнении запроса произошла ошибка.\r\nПроверьте соединение с интернет.');
                    return;
                }
                if (jqXHR.responseText) {
                    alert(jqXHR.responseText, 'Ошибка на сервере', [800, 800]);
                }
            }
        });
    }

    function initMap() {
        var map = new ol.Map({
            target: 'map',
            layers: [
                new ol.layer.Tile({
                    source: new ol.source.OSM()
                }),
                new ol.layer.Tile({
                    source: new ol.source.TileWMS({
                        projection: 'EPSG:4326',
                        url: 'http://localhost:8888/cgi-bin/mapserv.exe?map=../htdocs/mydemo/wms_ol.map&',
                        params: { 'LAYERS': 'world_poly', 'TILED': true, 'VERSION': '1.1.1' },
                    }),
                    opacity: 0.7
                }),
                new ol.layer.Vector({
                    source: siteSource
                }),
            ],
            view: new ol.View({
                center: ol.proj.fromLonLat([37.41, 8.82]),
                zoom: 4
            })
        });

        map.addInteraction(selectInteraction);
        map.on('click', function (event) {
            selectInteraction.getFeatures().clear();
            var coord = ol.proj.toLonLat(event.coordinate);
            $.ajax({
                url: App.ROOT + 'Home/GetGeoData',
                data: JSON.stringify([coord[1], coord[0]])
            }).done(function (data) {
                showInfo(data);
            });
        });
    }

    function showInfo(obj) {
        if (typeof (obj.Name) != 'undefined') {
            var format = new ol.format.WKT();
            var feature = format.readFeature(obj.GeomWKT, {
                dataProjection: 'EPSG:4326',
                featureProjection: 'EPSG:3857' });
            feature.set('name', obj.Name);
            siteSource.clear();
            siteSource.addFeature(feature);
            selectInteraction.getFeatures().push(feature);
            UI.showInfo(obj.Name);
        }
        else {
            UI.showInfo('Нет объекта!');
        }
    }
});

UI = {
    showInfo: function (str) {
        $('#info').text(str);
    }
};

А теперь, еще раз вернемся к созданию объекта selectInteraction. В качестве аргумента конструктору передается объект настроек, который задает функции, определяющие следует ли добавлять объект в коллекцию выделенных объектов (свойство condition) или удалять объект из этой коллекции (свойство removeCondition). Таким образом, возможно автоматическое выделение объектов при перемещении над ним мыши, например. Но в нашем случае, мы переопределяем механизм выделения объектов по умолчанию и будем самостоятельно добавлять объекты в коллекцию выделенных объектов и удалять их оттуда. По умолчанию объекты добавляются в коллекцию выделенных при клике на них мышью. Но это действие справедливо только для векторных слоев ol.layer.Vector, объекты которых существуют на клиенте. В то время как тайлы, получаемые с сервера в слоях ol.layer.Tile, являются, по сути, для библиотеки OpenLayers растровыми данными, хотя на сервере они могут хранится в векторном формате. Однако, библиотекой они подгружаются как изображение.

Таким образом, общий механизм выделения объектов работает таким образом. По клику на карте мы очищаем коллекцию выделенных объектов, получаем координаты места клика и передаем их на сервер. На стороне сервера код ищет объект, в который попадает эта координата и, в случае, если объект найден, возвращается его название и описание геометрии в формате WKT. По строке WKT создается объект библиотеки OpenLayers (feature) и добавляется к предварительно очищенному векторному слою путем добавления его к источнику данных (строка siteSource.addFeature(feature);), который был указан при создании этого слоя (фрагмент new ol.layer.Vector({ source: siteSource })). Теперь мы выделяем объект, добавляя его в коллекцию выделенных объектов строкой selectInteraction.getFeatures().push(feature);.

И финальный штрих. Чуть-чуть поправим стили нашей карты:

html, body {
    margin: 0;
    padding: 0;
    height: 100%;
    font-family: Arial;
}

#info {
    position: absolute;
    z-index: 500;
    background-color: #cce6ff;
    right: 0;
    padding:5px;
}

#map {
    height: 100%;
    cursor: auto !important;
}

Скриншот обновленной веб-ГИС представлен ниже.

Смена целевой платформы проекта
Смена целевой платформы проекта

В принципе, визуально эффект выделения может быть достигнут и без участия объекта selectInteraction. Для этого вы можете самостоятельно закомментировать нужные строки (//selectInteraction.getFeatures().push(feature); и //selectInteraction.getFeatures().clear();) и посмотреть на результат.

Если какие-то моменты вам остались непонятны, то можете просмотреть видеоролик, в котором я выполняю те же самые действия. Записывал одним дублем без подготовки, поэтому не судите строго ))


Файлы для загрузки:

Предыдущие статьи по теме разработки веб-ГИС VerySimpleGis с использованием GDAL/OGR: