27.04.2017 Создание простой веб-ГИС "VerySimpleGis"

Вступление

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

Предполагая, что читатель обладает всеми вышеуказанными знаниями, можно приступать к разработке нашей первой ГИС.

Несколько слов о проекте VerySimpleGis

VerySimpleGis - это обычное ASP.NET MVC веб-приложение, которое позволяет по клику мыши на картографическом объекте получить о нем определенную информацию. В данном случае я буду в качестве геоданных использовать слой стран мира, который будет храниться в БД MS SQL Server в таблице tm_world_borders_simpl в виде объектов типа geometry. При клике мыши на объекте с помощью jQuery ajax посылается запрос на сервер, содержащий координаты места клика, а серверный код с помощью библиотеки GDAL/OGR определяет объект по заданным координатам и возвращает объект, содержащий единственное поле - название страны. При создании приложения я использовал ручную установку GDAL/OGR.

Начальная настройка проекта VerySimpleGis

Первым делом запускаем Visual Studio 2017 (я использую Visual Studio 2017 Community, которая является бесплатной для учащихся, разработчиков открытого ПО и отдельных разработчиков), далее создаем новый проект по типу ASP.NET Web Application (.NET Framework) и выберем вид проекта - MVC, как показано на скриншотах ниже.

Создание проекта по типу ASP.NET Web Application (.NET Framework)
Создание проекта по типу ASP.NET Web Application (.NET Framework)
Выбор MVC проекта
Выбор MVC проекта

Я использую данный тип проекта, поскольку подключаемые библиотеки GDAL/OGR опираются именно на платформу .NET Framework, в то время как ASP.NET Core Web Application (.NET Core) базируется на новой среде выполнения .NET Core Runtime. При создании проекта по типу ASP.NET Core Web Application (.NET Framework) и смене целевой платформы на x64, у меня возникли проблемы с запуском веб-приложения, а именно - на этапе запуска веб-хоста методом host.Run() возникло исключение: BadImageFormatException: Была сделана попытка загрузить программу, имеющую неверный формат. (Исключение из HRESULT: 0x8007000B). Похоже на то, что пытающийся запуститься веб-сервер Kestrel, является x86 приложением.

Следующим шагом в свойствах проекта на вкладке Build меняем целевую платформу на x64. Далее, добавляем в проект ссылки на библиотеки GDAL/OGR с помощью контекстного меню проекта Add Reference. О процессе установки этой библиотеки посвящена статья Программируем с использованием GDAL/OGR на C# (часть 1). Установка и настройка.

О серверной части

В корне проекта в директории Models создадим файл GisAccess.cs, который будет содержать одноименный класс. Содержимое данного файла будет иметь следующий вид:

using OSGeo.OGR;
using System;
using System.Globalization;
using VerySimpleGis.Models.Entities;

namespace VerySimpleGis.Models
{
    public class GisAccess : IDisposable
    {
        private const int GEOM_FIELD_IDX = 0;
        private const int COUNTRY_ATTR_NAME_IDX = 4;

        private NumberFormatInfo nfi = new NumberFormatInfo() { CurrencyDecimalSeparator = "." };

        protected bool disposed = false;
        protected DataSource ds;
        protected Layer layer;

        public GisAccess(string dataSource)
        {
            ds = Ogr.Open(dataSource, 0);

            if (ds == null)
            {
                throw new GisException("DataSource object is null");
            }

            if (ds.GetLayerCount() == 0)
            {
                ds.Dispose();
                ds = null;
                throw new GisException("Datasource has no layers");
            }

            layer = ds.GetLayerByIndex(0);
        }

        public Country GetCountryByCoordinates(double x, double y)
        {
            x = normalize(x);
            string wkt = $"POINT({x.ToString(nfi)} {y.ToString(nfi)})";
            Geometry filter = Ogr.CreateGeometryFromWkt(ref wkt, layer.GetLayerDefn().GetGeomFieldDefn(GEOM_FIELD_IDX).GetSpatialRef());
            layer.SetSpatialFilter(filter);
            Feature feature = layer.GetNextFeature();
            if (feature != null)
            {
                return new Country() { Name = feature.GetFieldAsString(COUNTRY_ATTR_NAME_IDX) };
            }
            return null;
        }

        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        // Защищенная реализация метода Dispose
        protected virtual void Dispose(bool disposing)
        {
            if (disposed)
                return;

            if (disposing)
            {
                // Очистить все управляемые объекты
                if (layer != null)
                {
                    layer.Dispose();
                    layer = null;
                }

                if (ds != null)
                {
                    ds.Dispose();
                    ds = null;
                }
            }

            // Освободить все неуправляемые ресурсы
            // ...
            disposed = true;
        }

        private double normalize(double x)
        {
            x = x % 360;
            if (x > 180)
            {
                x -= 360;
            }
            else if (x < -180)
            {
                x += 360;
            }
            return x;
        }

        ~GisAccess()
        {
            Dispose(false);
        }
    }
}

В конструкторе класса GisAccess происходит подключение к источнику пространственных данных с помощью метода Ogr.Open(dataSource, 0). Об этом методе я рассказывал в статье Программируем с использованием GDAL/OGR на C# (часть 2). Получение пространственных данных. Далее, выполнив проверку переменной ds на null, проверяем, есть ли слои в источнике данных, если слои существуют, то присваиваем ссылку на самый первый слой переменной layer.

Класс GisAccess реализует интерфейс IDisposable и стандартный шаблон Dispose, который хорошо описан в MSDN.

Метод GetCountryByCoordinates(double x, double y) осуществляет получение страны по указанным координатам. Вначале мы, в случае необходимости, корректируем координату долготы, чтобы она лежала в пределах от -180° до 180°. В этом нам помогает метод normalize(double x). В следующей строке формируется WKT представление точки, в которой нужно определить страну. Далее, по ссылке на WKT строку и по заданной системе координат создаем объект геометрии с помощью метода Ogr.CreateGeometryFromWkt(ref wkt, layer.GetLayerDefn().GetGeomFieldDefn(GEOM_FIELD_IDX).GetSpatialRef()). Метод layer.GetLayerDefn() позволяет получить объект класса FeatureDefn, содержащий информацию о слое. Метод GetGeomFieldDefn(GEOM_FIELD_IDX) предоставляет доступ к объекту, который содержит пространственную информацию по указанному индексу поля. Здесь я предполагаю, что источник данных содержит только одно поле геоданных. А методом GetSpatialRef() уже получаем ссылку на объект системы координат нашего слоя. В следующей строке layer.SetSpatialFilter(filter) устанавливается пространственный фильтр для слоя. Это приведет к тому, что метод GetNextFeature() будет возвращать только те объекты, которые пересекают объект геометрии, установленный в фильтре. А далее, собственно, мы получаем объект, который попадает в наш фильтр и, если такой объект существует, то создаем объект страны, в название мы заносим строку из атрибутивной информации объекта.

Класс Country располагается в файле Model/Entities/Country.cs и имеет очень простой вид:

namespace VerySimpleGis.Models.Entities
{
    public class Country
    {
        public string Name { get; set; }
    }
}

Также в проекте задействован использован класс исключения GisException, расположенный в каталоге Models.

using System;

namespace VerySimpleGis.Models
{
    public class GisException : Exception
    {
        public GisException() { }
        public GisException(string message) : base(message) { }
        public GisException(string message, Exception innerException) : base(message, innerException) { }
    }
}

Прежде чем рассмотреть код класса контроллера, давайте добавим в секцию appSettings файла Web.config следующую строку:

<add key="SpatialDataSource" value="MSSQL:server=.\SQLEXPRESS;uid=sa;pwd=12345;database=MySpatialDb;Integrated Security=false;tables=tm_world_borders_simpl(ogr_geometry)" />

В случае необходимости, измените значения параметров uid (логин пользователя для подключения к БД), pwd (пароль пользователя для подключения к БД) и server (имя вашего экземпляра MS SQL).

А теперь в каталоге Controllers создайте файл HomeController.cs, где мы разместим одноименный файл со следующим содержимым:

using System.Configuration;
using System.Web.Mvc;
using VerySimpleGis.Models;
using VerySimpleGis.Models.Entities;

namespace VerySimpleGis.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            return View();
        }

        public JsonResult GetGeoData(double[] latlng)
        {
            using (GisAccess gis = new GisAccess(ConfigurationManager.AppSettings["SpatialDataSource"]))
            {
                Country country = gis.GetCountryByCoordinates(latlng[1], latlng[0]);
                return country != null ? Json(country) : Json(new object());
            }
        }
    }
}

Метод Index() просто вызывает метод View() без параметров, поэтому инфраструктура MVC будет использовать представление по умолчанию для данного метода контроллера.

Метод GetGeoData(double[] latlng) будет вызываться посредством ajax из js-файла. В методе извлекается из секции пользовательских настроек строка подключения к источнику данных и передается в конструктор класса GisAccess. Благодаря тому, что данный класс реализует интерфейс IDisposable, мы можем применить блок using. При выходе из блока автоматически будет вызван метод Dispose, который освободит необходимые ресурсы. В блоке, собственно, происходит получение объекта страны по указанным координатам и возвращение результата в виде объекта JsonResult.

Теперь приступим к настройке и программированию веб-ГИС со стороны клиента.

О клиентской части

Вначале добавим в наш проект необходимые библиотеки. Для этого в обозревателе решений (Solution Explorer) добавим в каталог Content новый каталог js, в котором будут храниться наши javascript-файлы, а также сторонние файлы javascript-библиотек. В созданном каталоге js создадим еще один каталог с именем leaflet, куда мы распакуем содержимое архива с одноименной библиотеки, которую можно скачать либо с официального сайта Leaflet, либо с моего ресурса. В каталоге Content/js создадим новый файл с именем main.js и поместим в него следующее содержимое:

$(function () {
    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 mymap = L.map('map', {
            minZoom: 2,
            maxBounds: [[-90, -180],
            [90, 180]]
        }).setView([37.41, 8.82], 4);
        var osm = new L.TileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(mymap);
        var wmsLayer = L.tileLayer.wms('http://localhost:8888/cgi-bin/mapserv.exe?map=../htdocs/mydemo/wms_ol.map&', {
            layers: 'world_poly',
            crs: L.CRS.EPSG4326,
            version: '1.1.1',
            opacity: 0.5
        }).addTo(mymap);
        mymap.on('click', function (e) {
            $.ajax({
                url: App.ROOT + 'Home/GetGeoData',
                data: JSON.stringify([e.latlng.lat, e.latlng.lng])
            }).done(function (data) {
                showInfo(data);
            });
        });
    }

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

Как можно заметить, на стороне клиента мы задействовали популярную библиотеку jQuery. После окончания загрузки документа вызывается функция по настройке объекта Ajax для выполнения асинхронных запросов к серверу (функция initAjax()), а также инициализация и отображение карты (функция initMap()). initAjax() устанавливает для всех ajax-запросов http-метод запросов типа POST, таймаут запроса в 30 сек., тип данных в виде json и заголовок запроса contentType в значение application/json; charset=utf-8. Также в функции настраивается глобальный обработчик ошибок.

Код функции initMap() во многом заимствован из предыдущего примера с Leaflet и описан в статье Подключаемся к MapServer WMS с помощью Leaflet. Новыми особенностями здесь являются только установка во втором аргументе L.map параметров minZoom и maxBounds, которые определяют минимальный масштаб и границы карты соответственно и подключение обработчика клика на карте. В обработчике с помощью библиотеки jQuery выполняется ajax-запрос на сервер для определения страны по координатам. В функции обратного вызова отображаем название страны благодаря вызову функции showInfo, в которую передается полученный от сервера объект страны. Если у объекта отсутствует свойство Name, то это означает, что по указанным координатам нет объекта.

Данный код является сердцем клиентской части, и нам остается только рассмотреть файл стилей Content/css/style.css и файл представления Views/Home/Index.cshtml. style.css имеет вид:

html, body {
    margin: 0;
    padding: 0;
    height: 100%;
}

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

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

Стоит отметить строку cursor: auto !important;, которая устанавливает курсор мыши в виде указателя.

И, наконец, разметка файла представления Index.cshtml имеет следующий вид:

@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <link href="~/Content/js/leaflet/leaflet.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/leaflet/leaflet.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>

Как можно заметить, здесь я решил не загружать библиотеку jQuery, а подключить ее из CDN. Также в секции <script> я присваиваю свойству ROOT объекта App строку, содержащую путь к корню веб-приложения.

Итоги

На данном этапе можно собирать приложение и запускать нашу первую веб-ГИС! Конечно, многие вещи при разработке архитектуры приложения были упрощены. В реальном веб-приложении вы скорей всего будете использовать какой-либо IoC-контейнер, например, Ninject. Кроме того, на клиенте вы, возможно, захотите подключить еще какой-нибудь framework и т.д. Однако, цель статьи - показать основные этапы в разработке ГИС, которая может обеспечить доступ к пользовательским картам, которые хранятся на собственном сервере в том или ином векторном формате.

Также вы можете загрузить с github исходники проекта VerySimpleGis.

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