├── Docs
├── demo-page.png
└── appsettings.md
├── Src
├── TileMapService
│ ├── Properties
│ │ └── launchSettings.json
│ ├── GeoTiff
│ │ ├── RasterType.cs
│ │ ├── AngularUnits.cs
│ │ ├── TileCoordinates.cs
│ │ ├── LinearUnits.cs
│ │ ├── ModelType.cs
│ │ ├── CoordinateTransformation.cs
│ │ └── Key.cs
│ ├── Wms
│ │ ├── ServiceProperties.cs
│ │ ├── Version.cs
│ │ ├── Layer.cs
│ │ ├── ServiceExceptionReport .cs
│ │ ├── ExceptionReport.cs
│ │ ├── Identifiers.cs
│ │ ├── QueryUtility.cs
│ │ └── WmsHelper.cs
│ ├── Wmts
│ │ ├── ServiceProperties.cs
│ │ ├── ExceptionReport.cs
│ │ ├── Identifiers.cs
│ │ └── QueryUtility.cs
│ ├── Tms
│ │ ├── Capabilities.cs
│ │ ├── TileMapServerError.cs
│ │ └── Identifiers.cs
│ ├── Models
│ │ ├── TileDataset.cs
│ │ ├── TileCoordinates.cs
│ │ ├── GeographicalPoint.cs
│ │ ├── GeographicalPointWithZoom.cs
│ │ ├── RasterProperties.cs
│ │ ├── Layer.cs
│ │ ├── GeographicalBounds.cs
│ │ └── Bounds.cs
│ ├── ImageFormats.cs
│ ├── ErrorLoggingMiddleware.cs
│ ├── Controllers
│ │ ├── ApiController.cs
│ │ ├── TmsController.cs
│ │ └── XyzController.cs
│ ├── SimpleAuthenticationHandler.cs
│ ├── ITileSourceFabric.cs
│ ├── Utils
│ │ ├── UrlHelper.cs
│ │ ├── MathHelper.cs
│ │ ├── SrsCodes.cs
│ │ ├── ResponseHelper.cs
│ │ ├── EntitiesConverter.cs
│ │ └── WebMercator.cs
│ ├── TileMapService.csproj
│ ├── HttpRequestExtensions.cs
│ ├── ITileSource.cs
│ ├── MediaTypeNames.cs
│ ├── wwwroot
│ │ ├── index.html
│ │ ├── index.js
│ │ ├── ol-layerswitcher.css
│ │ └── ol.css
│ ├── ServiceProperties.cs
│ ├── Startup.cs
│ ├── Program.cs
│ ├── MBTiles
│ │ ├── MetadataItem.cs
│ │ ├── Metadata.cs
│ │ └── Repository.cs
│ ├── TileSourceFabric.cs
│ ├── appsettings.json
│ ├── TileSources
│ │ ├── PostGISTileSource.cs
│ │ ├── MBTilesTileSource.cs
│ │ └── LocalFilesTileSource.cs
│ └── SourceConfiguration.cs
├── TileMapService.Tests
│ ├── GeoTiff
│ │ ├── sample1-epsg-3857.tiff
│ │ └── sample1-epsg-4326.tiff
│ ├── Properties
│ │ └── launchSettings.json
│ ├── Expected
│ │ ├── tms_capabilities_Services.xml
│ │ ├── tms_capabilities_TileMap3.xml
│ │ ├── tms_capabilities_TileMapService.xml
│ │ ├── tms_capabilities_TileMap2.xml
│ │ └── tms_capabilities_TileMap1.xml
│ ├── TestConfiguration.cs
│ ├── UtilsTests.cs
│ ├── TileDataStub.cs
│ ├── TileMapService.Tests.csproj
│ ├── TestsUtility.cs
│ └── ImageHelperTests.cs
└── TileMapService.sln
├── .gitignore
├── LICENSE
└── README.md
/Docs/demo-page.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apdevelop/tile-map-service/HEAD/Docs/demo-page.png
--------------------------------------------------------------------------------
/Src/TileMapService/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "profiles": {
3 | "Develop": {
4 | "commandName": "Project"
5 | }
6 | }
7 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | Src/.vs/
2 | Src/**/.config/
3 |
4 | Src/**/[Bb]in/
5 | Src/**/[Oo]bj/
6 |
7 | Src/**/*.suo
8 | Src/**/*.user
9 |
10 | *.pubxml
11 |
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/GeoTiff/sample1-epsg-3857.tiff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apdevelop/tile-map-service/HEAD/Src/TileMapService.Tests/GeoTiff/sample1-epsg-3857.tiff
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/GeoTiff/sample1-epsg-4326.tiff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/apdevelop/tile-map-service/HEAD/Src/TileMapService.Tests/GeoTiff/sample1-epsg-4326.tiff
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "profiles": {
3 | "TileMapService.Tests": {
4 | "commandName": "Project",
5 | "commandLineArgs": "--port 5000"
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/Expected/tms_capabilities_Services.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/Src/TileMapService/GeoTiff/RasterType.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.GeoTiff
2 | {
3 | ///
4 | /// Raster type.
5 | ///
6 | enum RasterType
7 | {
8 | RasterPixelIsArea = 1,
9 | RasterPixelIsPoint = 2,
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Src/TileMapService/Wms/ServiceProperties.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.Wms
2 | {
3 | class ServiceProperties
4 | {
5 | public string? Title { get; set; }
6 |
7 | public string? Abstract { get; set; }
8 |
9 | public string[]? Keywords { get; set; }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Src/TileMapService/Wmts/ServiceProperties.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.Wmts
2 | {
3 | class ServiceProperties
4 | {
5 | public string? Title { get; set; }
6 |
7 | public string? Abstract { get; set; }
8 |
9 | public string[]? Keywords { get; set; }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Src/TileMapService/GeoTiff/AngularUnits.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.GeoTiff
2 | {
3 | enum AngularUnits
4 | {
5 | Radian = 9101,
6 | Degree = 9102,
7 | ArcMinute = 9103,
8 | ArcSecond = 9104,
9 | Grad = 9105,
10 | Gon = 9106,
11 | DMS = 9107,
12 | DMSHemisphere = 9108,
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Src/TileMapService/Wms/Version.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.Wms
2 | {
3 | ///
4 | /// WMS protocol version.
5 | ///
6 | public enum Version
7 | {
8 | ///
9 | /// Version 1.1.1
10 | ///
11 | Version111,
12 |
13 | ///
14 | /// Version 1.3.0
15 | ///
16 | Version130,
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Src/TileMapService/Tms/Capabilities.cs:
--------------------------------------------------------------------------------
1 | using System;
2 |
3 | namespace TileMapService.Tms
4 | {
5 | class Capabilities
6 | {
7 | public string ServiceTitle { get; set; } = String.Empty;
8 |
9 | public string ServiceAbstract { get; set; } = String.Empty;
10 |
11 | public string? BaseUrl { get; set; }
12 |
13 | public Models.Layer[] Layers { get; set; } = Array.Empty();
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Src/TileMapService/GeoTiff/TileCoordinates.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.GeoTiff
2 | {
3 | class TileCoordinates
4 | {
5 | public int X { get; set; }
6 |
7 | public int Y { get; set; }
8 |
9 | public TileCoordinates()
10 | {
11 |
12 | }
13 |
14 | public TileCoordinates(int x, int y)
15 | {
16 | this.X = x;
17 | this.Y = y;
18 | }
19 |
20 | public override string ToString() => $"X = {this.X} Y = {this.Y}";
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/TestConfiguration.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 |
3 | namespace TileMapService.Tests
4 | {
5 | ///
6 | /// Test environment configuration (port number, path to temporary files)
7 | ///
8 | internal static class TestConfiguration
9 | {
10 | public static int PortNumber => 5000;
11 |
12 | public static string BaseUrl => $"http://localhost:{PortNumber}";
13 |
14 | public static string DataPath => Path.Join(Path.GetTempPath(), "TileMapServiceTestData");
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Src/TileMapService/Wms/Layer.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.Wms
2 | {
3 | ///
4 | /// Represents layer properties in WMS Capabilities XML document.
5 | ///
6 | class Layer // TODO: ? use shared TileMapService.Models.Layer DTO
7 | {
8 | public string? Title { get; set; }
9 |
10 | public string? Name { get; set; }
11 |
12 | public string? Abstract { get; set; }
13 |
14 | public bool IsQueryable { get; set; }
15 |
16 | public Models.GeographicalBounds? GeographicalBounds { get; set; }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Src/TileMapService/GeoTiff/LinearUnits.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.GeoTiff
2 | {
3 | enum LinearUnits
4 | {
5 | Meter = 9001,
6 | Foot = 9002,
7 | FootUSSurvey = 9003,
8 | FootModifiedAmerican = 9004,
9 | FootClarke = 9005,
10 | FootIndian = 9006,
11 | Link = 9007,
12 | LinkBenoit = 9008,
13 | LinkSears = 9009,
14 | ChainBenoit = 9010,
15 | ChainSears = 9011,
16 | YardSears = 9012,
17 | YardIndian = 9013,
18 | Fathom = 9014,
19 | MileInternationalNautical = 9015,
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Src/TileMapService/GeoTiff/ModelType.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.GeoTiff
2 | {
3 | ///
4 | /// Model type.
5 | ///
6 | enum ModelType
7 | {
8 | ///
9 | /// Projected coordinate system.
10 | ///
11 | Projected = 1,
12 |
13 | ///
14 | /// Geographic (latitude-longitude) coordinate system.
15 | ///
16 | Geographic = 2,
17 |
18 | ///
19 | /// Geocentric (X,Y,Z) coordinate system.
20 | ///
21 | Geocentric = 3,
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Src/TileMapService/Models/TileDataset.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.Models
2 | {
3 | class TileDataset
4 | {
5 | public int X { get; set; }
6 |
7 | public int Y { get; set; }
8 |
9 | public int Z { get; set; }
10 |
11 | public byte[]? ImageData { get; set; }
12 |
13 | public TileDataset()
14 | {
15 |
16 | }
17 |
18 | public TileDataset(int x, int y, int z, byte[] imageData)
19 | {
20 | this.X = x;
21 | this.Y = y;
22 | this.Z = z;
23 | this.ImageData = imageData;
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Src/TileMapService/Models/TileCoordinates.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.Models
2 | {
3 | public class TileCoordinates
4 | {
5 | public int X { get; set; }
6 |
7 | public int Y { get; set; }
8 |
9 | public int Z { get; set; }
10 |
11 | public TileCoordinates()
12 | {
13 |
14 | }
15 |
16 | public TileCoordinates(int x, int y, int z)
17 | {
18 | this.X = x;
19 | this.Y = y;
20 | this.Z = z;
21 | }
22 |
23 | public override string ToString()
24 | {
25 | return $"X={this.X} Y={this.Y} Z={this.Z}";
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Src/TileMapService/ImageFormats.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService
2 | {
3 | ///
4 | /// Image formats.
5 | ///
6 | public static class ImageFormats
7 | {
8 | ///
9 | /// PNG (Portable Network Graphincs).
10 | ///
11 | public const string Png = "png";
12 |
13 | ///
14 | /// JPEG.
15 | ///
16 | public const string Jpeg = "jpg";
17 |
18 | ///
19 | /// Mapbox Vector Tile.
20 | ///
21 | public const string MapboxVectorTile = "mvt";
22 |
23 | ///
24 | /// Protobuf.
25 | ///
26 | public const string Protobuf = "pbf";
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Src/TileMapService/Models/GeographicalPoint.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Globalization;
3 |
4 | namespace TileMapService.Models
5 | {
6 | public class GeographicalPoint
7 | {
8 | public double Longitude { get; set; }
9 |
10 | public double Latitude { get; set; }
11 |
12 | public GeographicalPoint()
13 | {
14 |
15 | }
16 |
17 | public GeographicalPoint(double longitude, double latitude)
18 | {
19 | this.Longitude = longitude;
20 | this.Latitude = latitude;
21 | }
22 |
23 | public override string ToString()
24 | {
25 | return String.Format(CultureInfo.InvariantCulture, "{0} {1}", this.Longitude, this.Latitude);
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Src/TileMapService/ErrorLoggingMiddleware.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Diagnostics;
3 | using System.Threading.Tasks;
4 |
5 | using Microsoft.AspNetCore.Http;
6 |
7 | namespace TileMapService
8 | {
9 | class ErrorLoggingMiddleware
10 | {
11 | private readonly RequestDelegate next;
12 |
13 | public ErrorLoggingMiddleware(RequestDelegate next)
14 | {
15 | this.next = next;
16 | }
17 |
18 | public async Task Invoke(HttpContext context)
19 | {
20 | try
21 | {
22 | await this.next(context);
23 | }
24 | catch (Exception e)
25 | {
26 | Debug.WriteLine($"The following error happened: {e.Message}");
27 | throw;
28 | }
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Src/TileMapService/Tms/TileMapServerError.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Xml;
3 |
4 | namespace TileMapService.Tms
5 | {
6 | class TileMapServerError
7 | {
8 | private readonly string message;
9 |
10 | public TileMapServerError(string message)
11 | {
12 | this.message = message;
13 | }
14 |
15 | public XmlDocument ToXml()
16 | {
17 | var doc = new XmlDocument();
18 | var rootElement = doc.CreateElement(String.Empty, "TileMapServerError", String.Empty);
19 |
20 | var messageElement = doc.CreateElement("Message");
21 | messageElement.AppendChild(doc.CreateTextNode(this.message));
22 | rootElement.AppendChild(messageElement);
23 |
24 | doc.AppendChild(rootElement);
25 |
26 | return doc;
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/Expected/tms_capabilities_TileMap3.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Tile Source 3
4 |
5 | OSGEO:41001
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/Src/TileMapService/Models/GeographicalPointWithZoom.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Globalization;
3 |
4 | namespace TileMapService.Models
5 | {
6 | class GeographicalPointWithZoom
7 | {
8 | public double Longitude { get; set; }
9 |
10 | public double Latitude { get; set; }
11 |
12 | public int ZoomLevel { get; set; }
13 |
14 | public static GeographicalPointWithZoom FromMBTilesMetadataString(string s)
15 | {
16 | // TODO: check input format
17 | var items = s.Split(',');
18 | return new GeographicalPointWithZoom
19 | {
20 | Longitude = Double.Parse(items[0], CultureInfo.InvariantCulture),
21 | Latitude = Double.Parse(items[1], CultureInfo.InvariantCulture),
22 | ZoomLevel = Int32.Parse(items[2], CultureInfo.InvariantCulture),
23 | };
24 | }
25 | }
26 | }
27 |
28 |
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/Expected/tms_capabilities_TileMapService.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | WMTS Service
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/Src/TileMapService/Models/RasterProperties.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.Models
2 | {
3 | public class RasterProperties // TODO: store source parameters as-is
4 | {
5 | public int Srid { get; set; }
6 |
7 | public int ImageWidth { get; set; }
8 |
9 | public int ImageHeight { get; set; }
10 |
11 | public int TileWidth { get; set; }
12 |
13 | public int TileHeight { get; set; }
14 |
15 | public int TileSize { get; set; }
16 |
17 | ///
18 | /// Bounds in EPSG:4326 SRS.
19 | ///
20 | public GeographicalBounds? GeographicalBounds { get; set; }
21 |
22 | ///
23 | /// Bounds in EPSG:3857 SRS.
24 | ///
25 | public Bounds? ProjectedBounds { get; set; }
26 |
27 | public double PixelWidth { get; set; }
28 |
29 | public double PixelHeight { get; set; }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Src/TileMapService/Controllers/ApiController.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Mvc;
2 |
3 | namespace TileMapService.Controllers
4 | {
5 | ///
6 | /// Custom API endpoint for managing tile sources.
7 | ///
8 | [Route("api")]
9 | public class ApiController : ControllerBase
10 | {
11 | private readonly ITileSourceFabric tileSourceFabric;
12 |
13 | public ApiController(ITileSourceFabric tileSourceFabric)
14 | {
15 | this.tileSourceFabric = tileSourceFabric;
16 | }
17 |
18 | [HttpGet("sources")]
19 | public IActionResult GetSources()
20 | {
21 | var result = this.tileSourceFabric.Sources;
22 | return Ok(result);
23 | }
24 |
25 | // TODO: full set of CRUD actions for sources
26 | // Simple authorization - allow only for local requests
27 | ////if (Request.IsLocal()) { } else { return Forbid(); }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Src/TileMapService/Models/Layer.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.Models
2 | {
3 | ///
4 | /// Represents source properties in TMS and WMTS Capabilities XML document.
5 | ///
6 | class Layer
7 | {
8 | public string? Identifier { get; set; }
9 |
10 | public string? Title { get; set; }
11 |
12 | public string? Abstract { get; set; }
13 |
14 | public string? ContentType { get; set; }
15 |
16 | ///
17 | /// Name of image format ("png", "jpg", "pbf").
18 | ///
19 | public string? Format { get; set; }
20 |
21 | public string? Srs { get; set; }
22 |
23 | public int MinZoom { get; set; }
24 |
25 | public int MaxZoom { get; set; }
26 |
27 | public GeographicalBounds? GeographicalBounds { get; set; }
28 |
29 | public int TileWidth { get; set; } = Utils.WebMercator.DefaultTileWidth;
30 |
31 | public int TileHeight { get; set; } = Utils.WebMercator.DefaultTileHeight;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Src/TileMapService/SimpleAuthenticationHandler.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Authentication;
2 | using Microsoft.Extensions.Logging;
3 | using Microsoft.Extensions.Options;
4 | using System.Text.Encodings.Web;
5 | using System.Threading.Tasks;
6 |
7 | namespace TileMapService
8 | {
9 | class SimpleAuthenticationHandler : AuthenticationHandler
10 | {
11 | #if NET5_0
12 | public SimpleAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
13 | : base(options, logger, encoder, clock)
14 | {
15 | }
16 | #elif NET8_0
17 | public SimpleAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder)
18 | : base(options, logger, encoder)
19 | {
20 | }
21 | #endif
22 |
23 | protected override async Task HandleAuthenticateAsync()
24 | {
25 | return AuthenticateResult.NoResult();
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Src/TileMapService/GeoTiff/CoordinateTransformation.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.GeoTiff
2 | {
3 | enum CoordinateTransformation
4 | {
5 | TransverseMercator = 1,
6 | TransvMercatorModifiedAlaska = 2,
7 | ObliqueMercator = 3,
8 | ObliqueMercatorLaborde = 4,
9 | ObliqueMercatorRosenmund = 5,
10 | ObliqueMercatorSpherical = 6,
11 | Mercator = 7,
12 | LambertConfConic2SP = 8,
13 | LambertConfConicHelmert = 9,
14 | LambertAzimEqualArea = 10,
15 | AlbersEqualArea = 11,
16 | AzimuthalEquidistant = 12,
17 | EquidistantConic = 13,
18 | Stereographic = 14,
19 | PolarStereographic = 15,
20 | ObliqueStereographic = 16,
21 | Equirectangular = 17,
22 | CassiniSoldner = 18,
23 | Gnomonic = 19,
24 | MillerCylindrical = 20,
25 | Orthographic = 21,
26 | Polyconic = 22,
27 | Robinson = 23,
28 | Sinusoidal = 24,
29 | VanDerGrinten = 25,
30 | NewZealandMapGrid = 26,
31 | TransvMercatorSouthOriented = 27,
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Src/TileMapService/ITileSourceFabric.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Threading.Tasks;
3 |
4 | namespace TileMapService
5 | {
6 | public interface ITileSourceFabric
7 | {
8 | Task InitAsync();
9 |
10 | ///
11 | /// Returns true, if source with given id exists in Sources.
12 | ///
13 | ///
14 | ///
15 | bool Contains(string id);
16 |
17 | ///
18 | /// Gets the tile source by given identifier .
19 | ///
20 | /// Identifier of tile source.
21 | ///
22 | ITileSource Get(string id);
23 |
24 | ///
25 | /// Returns list of sources configuration and properties.
26 | ///
27 | List Sources { get; }
28 |
29 | ///
30 | /// Returns entire service properties.
31 | ///
32 | ServiceProperties ServiceProperties { get; }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Andrey P.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Src/TileMapService/Wmts/ExceptionReport.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Xml;
3 |
4 | namespace TileMapService.Wmts
5 | {
6 | class ExceptionReport
7 | {
8 | private readonly string message;
9 |
10 | private readonly string exceptionCode;
11 |
12 | public ExceptionReport(string exceptionCode, string message)
13 | {
14 | this.exceptionCode = exceptionCode;
15 | this.message = message;
16 | }
17 |
18 | public XmlDocument ToXml()
19 | {
20 | var doc = new XmlDocument();
21 | var rootElement = doc.CreateElement(String.Empty, Identifiers.ExceptionReportElement, Identifiers.OwsNamespaceUri);
22 |
23 | var exceptionElement = doc.CreateElement(String.Empty, Identifiers.ExceptionElement, Identifiers.OwsNamespaceUri);
24 | exceptionElement.SetAttribute(Identifiers.ExceptionCodeAttribute, this.exceptionCode);
25 | exceptionElement.AppendChild(doc.CreateTextNode(this.message));
26 | rootElement.AppendChild(exceptionElement);
27 |
28 | doc.AppendChild(rootElement);
29 |
30 | return doc;
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Src/TileMapService/Utils/UrlHelper.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 |
5 | using Microsoft.AspNetCore.WebUtilities;
6 |
7 | namespace TileMapService.Utils
8 | {
9 | static class UrlHelper
10 | {
11 | public static string GetQueryBase(string url)
12 | {
13 | var uri = new Uri(url);
14 | var baseUri = uri.GetComponents(UriComponents.Scheme | UriComponents.Host | UriComponents.Port | UriComponents.Path, UriFormat.UriEscaped);
15 |
16 | return baseUri;
17 | }
18 |
19 | public static List> GetQueryParameters(string url)
20 | {
21 | var uri = new Uri(url);
22 | var items = QueryHelpers.ParseQuery(uri.Query)
23 | .SelectMany(
24 | kvp => kvp.Value,
25 | (kvp, value) => new KeyValuePair(kvp.Key.ToLower(), value ?? String.Empty))
26 | .ToList();
27 |
28 | return items;
29 | }
30 |
31 | public static string[] GetSegments(string url)
32 | {
33 | var uri = new Uri(url);
34 | var result = uri.Segments;
35 |
36 | return result;
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Src/TileMapService/TileMapService.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net5.0
6 | enable
7 | f052d8ac-cdc5-4dfb-86b5-763554895d34
8 | tms
9 | TileMapService
10 |
11 |
12 |
13 | net5.0;net8.0
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/Src/TileMapService/Utils/MathHelper.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Runtime.CompilerServices;
3 |
4 | namespace TileMapService.Utils
5 | {
6 | static class MathHelper
7 | {
8 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
9 | public static double Clip(double value, double minValue, double maxValue) => Math.Min(Math.Max(value, minValue), maxValue);
10 |
11 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
12 | public static double DegreesToRadians(double degrees) => degrees * (Math.PI / 180);
13 |
14 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
15 | public static double RadiansToDegrees(double radians) => radians * (180 / Math.PI);
16 |
17 | ///
18 | /// Returns the inverse hyperbolic tangent of a specified number.
19 | ///
20 | /// The number whose inverse hyperbolic tangent is to be found.
21 | /// See https://en.wikipedia.org/wiki/Inverse_hyperbolic_function#Inverse_hyperbolic_tangent
22 | /// The inverse hyperbolic tangent of a specified number, measured in radians.
23 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
24 | public static double Artanh(double x) => 0.5 * Math.Log((1 + x) / (1 - x));
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Src/TileMapService/HttpRequestExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Http;
2 | using System.Net;
3 |
4 | namespace TileMapService
5 | {
6 | public static class HttpRequestExtensions
7 | {
8 | ///
9 | /// Returns true if request is local, otherwise false.
10 | ///
11 | /// Request.
12 | /// True if request is local, otherwise false.
13 | public static bool IsLocal(this HttpRequest request)
14 | {
15 | // Request.IsLocal in ASP.NET Core
16 | // https://www.strathweb.com/2016/04/request-islocal-in-asp-net-core/
17 |
18 | var connection = request.HttpContext.Connection;
19 | if (connection.RemoteIpAddress != null)
20 | {
21 | return connection.LocalIpAddress != null
22 | ? connection.RemoteIpAddress.Equals(connection.LocalIpAddress)
23 | : IPAddress.IsLoopback(connection.RemoteIpAddress);
24 | }
25 | else if (connection.RemoteIpAddress == null && connection.LocalIpAddress == null)
26 | {
27 | return true;
28 | }
29 | else
30 | {
31 | return false;
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Src/TileMapService/ITileSource.cs:
--------------------------------------------------------------------------------
1 | using System.Threading;
2 | using System.Threading.Tasks;
3 |
4 | namespace TileMapService
5 | {
6 | ///
7 | /// Represents common set of methods of tile source of any type.
8 | ///
9 | public interface ITileSource
10 | {
11 | ///
12 | /// Asynchronously performs initialization of tile source, building actual tile source configuration.
13 | ///
14 | /// A task representing the asynchronous operation.
15 | Task InitAsync();
16 |
17 | ///
18 | /// Asynchronously gets tile image contents with given coordinates from source.
19 | ///
20 | /// Tile X coordinate (column).
21 | /// Tile Y coordinate (row), Y axis goes up from the bottom (TMS scheme).
22 | /// Tile Z coordinate (zoom level).
23 | /// A task representing the asynchronous operation.
24 | Task GetTileAsync(int x, int y, int z, CancellationToken cancellationToken);
25 |
26 | ///
27 | /// Gets the actual tile source configuration.
28 | ///
29 | SourceConfiguration Configuration { get; }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/Expected/tms_capabilities_TileMap2.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | World Satellite Imagery
4 |
5 | OSGEO:41001
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/Src/TileMapService/MediaTypeNames.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService
2 | {
3 | ///
4 | /// Media type identifiers.
5 | ///
6 | ///
7 | /// Structure is similar to class.
8 | ///
9 | public static class MediaTypeNames
10 | {
11 | public static class Image
12 | {
13 | public const string Png = "image/png";
14 |
15 | public const string Jpeg = "image/jpeg";
16 |
17 | public const string Tiff = "image/tiff";
18 |
19 | public const string Webp = "image/webp";
20 | }
21 |
22 | public static class Text
23 | {
24 | public const string Xml = "text/xml";
25 |
26 | public const string XmlUtf8 = "text/xml; charset=utf-8"; // TODO: better way?
27 |
28 | public const string Plain = "text/plain";
29 | }
30 |
31 | public static class Application
32 | {
33 | public const string Xml = "application/xml";
34 |
35 | public const string MapboxVectorTile = "application/vnd.mapbox-vector-tile";
36 |
37 | public const string XProtobuf = "application/x-protobuf";
38 |
39 | public const string OgcServiceExceptionXml = "application/vnd.ogc.se_xml";
40 |
41 | public const string OgcWmsCapabilitiesXml = "application/vnd.ogc.wms_xml";
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Src/TileMapService/wwwroot/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Tile Map Service
6 |
7 |
8 |
9 |
10 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/Src/TileMapService/Tms/Identifiers.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.Tms
2 | {
3 | static class Identifiers
4 | {
5 | public const string Tms = "tms";
6 |
7 | public const string Services = "Services";
8 |
9 | public const string TileMapService = "TileMapService";
10 |
11 | public const string TileMapElement = "TileMap";
12 |
13 | public const string VersionAttribute = "version";
14 |
15 | public const string HRefAttribute = "href";
16 |
17 | public const string TitleAttribute = "title";
18 |
19 | public const string UnitsPerPixelAttribute = "units-per-pixel";
20 |
21 | public const string Version100 = "1.0.0";
22 |
23 | public const string TitleElement = "Title";
24 |
25 | public const string AbstractElement = "Abstract";
26 |
27 | public const string SrsElement = "SRS";
28 |
29 | public const string BoundingBoxElement = "BoundingBox";
30 |
31 | public const string TileFormatElement = "TileFormat";
32 |
33 | public const string TileSetElement = "TileSet";
34 |
35 | // TODO: ? private const string ProfileNone = "none";
36 |
37 | ///
38 | /// EPSG:4326
39 | ///
40 | public const string ProfileGlobalGeodetic = "global-geodetic";
41 |
42 | ///
43 | /// OSGEO:41001
44 | ///
45 | public const string ProfileGlobalMercator = "global-mercator";
46 |
47 | // TODO: ? private const string ProfileLocal = "local";
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Src/TileMapService/ServiceProperties.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Text.Json.Serialization;
3 |
4 | namespace TileMapService
5 | {
6 | ///
7 | /// Represents entire service properties.
8 | ///
9 | public class ServiceProperties
10 | {
11 | ///
12 | /// User-friendly title (displayed name) of service.
13 | ///
14 | [JsonPropertyName("title")]
15 | public string Title { get; set; } = String.Empty;
16 |
17 | ///
18 | /// Detailed text description of service.
19 | ///
20 | [JsonPropertyName("abstract")]
21 | public string Abstract { get; set; } = String.Empty;
22 |
23 | ///
24 | /// Keywords describing service.
25 | ///
26 | [JsonPropertyName("keywords")]
27 | public string Keywords { get; set; } = String.Empty;
28 |
29 | public string[]? KeywordsList
30 | {
31 | get
32 | {
33 | return String.IsNullOrWhiteSpace(this.Keywords)
34 | ? null
35 | : this.Keywords.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
36 | }
37 | }
38 |
39 | ///
40 | /// Quality (compression level) for JPEG output in WMS endpoint.
41 | ///
42 | [JsonPropertyName("jpegQuality")] // TODO: ? separate output options config ?
43 | public int JpegQuality { get; set; } = 90;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Src/TileMapService/Wms/ServiceExceptionReport .cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Xml;
3 |
4 | namespace TileMapService.Wms
5 | {
6 | class ServiceExceptionReport
7 | {
8 | private readonly string? code;
9 |
10 | private readonly string message;
11 |
12 | private readonly string version;
13 |
14 | public ServiceExceptionReport(string? code, string message, string version)
15 | {
16 | this.code = code;
17 | this.message = message;
18 | this.version = version;
19 | }
20 |
21 | public XmlDocument ToXml()
22 | {
23 | var doc = new XmlDocument();
24 | var rootElement = doc.CreateElement(String.Empty, Identifiers.ServiceExceptionReportElement, Identifiers.OgcNamespaceUri);
25 | rootElement.SetAttribute("xmlns", Identifiers.OgcNamespaceUri);
26 | rootElement.SetAttribute(Identifiers.VersionAttribute, this.version);
27 | // TODO: ? xsi:schemaLocation
28 |
29 | var exceptionElement = doc.CreateElement(String.Empty, Identifiers.ServiceExceptionElement, Identifiers.OgcNamespaceUri);
30 | if (!String.IsNullOrEmpty(this.code))
31 | {
32 | exceptionElement.SetAttribute(Identifiers.CodeAttribute, this.code);
33 | }
34 |
35 | exceptionElement.AppendChild(doc.CreateTextNode(this.message));
36 |
37 | rootElement.AppendChild(exceptionElement);
38 |
39 | doc.AppendChild(rootElement);
40 |
41 | return doc;
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Src/TileMapService/Utils/SrsCodes.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.Utils
2 | {
3 | public static class SrsCodes // TODO: class name ?
4 | {
5 | ///
6 | /// EPSG:3857
7 | ///
8 | ///
9 | /// WGS 84 / Pseudo-Mercator -- Spherical Mercator, Google Maps, OpenStreetMap, Bing, ArcGIS, ESRI
10 | /// https://epsg.io/3857
11 | ///
12 | public const string EPSG3857 = "EPSG:3857";
13 |
14 | internal const int _3857 = 3857;
15 |
16 | ///
17 | /// EPSG:4326
18 | ///
19 | ///
20 | /// WGS 84 -- WGS84 - World Geodetic System 1984, used in GPS
21 | /// https://epsg.io/4326
22 | ///
23 | public const string EPSG4326 = "EPSG:4326";
24 |
25 | internal const int _4326 = 4326;
26 |
27 | ///
28 | /// EPSG:900913
29 | ///
30 | ///
31 | /// Google Maps Global Mercator -- Spherical Mercator (unofficial - used in open source projects / OSGEO)
32 | /// https://epsg.io/900913
33 | ///
34 | public const string EPSG900913 = "EPSG:900913";
35 |
36 | ///
37 | /// EPSG:41001
38 | ///
39 | ///
40 | /// WGS84 / Simple Mercator - Spherical Mercator (unofficial deprecated OSGEO / Tile Map Service)
41 | /// https://epsg.io/41001
42 | ///
43 | public const string OSGEO41001 = "OSGEO:41001";
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/UtilsTests.cs:
--------------------------------------------------------------------------------
1 | using System.Linq;
2 |
3 | using NUnit.Framework;
4 |
5 | using U = TileMapService.Utils;
6 |
7 | namespace TileMapService.Tests
8 | {
9 | [TestFixture]
10 | public class UtilsTests
11 | {
12 | [Test]
13 | public void WebMercatorIsInsideBBox()
14 | {
15 | Assert.Multiple(() =>
16 | {
17 | Assert.That(U.WebMercator.IsInsideBBox(0, 0, 0, U.SrsCodes.EPSG3857));
18 | Assert.That(U.WebMercator.IsInsideBBox(0, 0, 2, U.SrsCodes.EPSG3857));
19 | Assert.That(U.WebMercator.IsInsideBBox(1, 1, 2, U.SrsCodes.EPSG3857));
20 | Assert.That(U.WebMercator.IsInsideBBox(3, 3, 2, U.SrsCodes.EPSG3857));
21 | Assert.That(U.WebMercator.IsInsideBBox(1, 2, 0, U.SrsCodes.EPSG3857), Is.False);
22 | Assert.That(U.WebMercator.IsInsideBBox(2, 2, 1, U.SrsCodes.EPSG3857), Is.False);
23 | });
24 | }
25 |
26 | [Test]
27 | public void BuildTileCoordinatesList()
28 | {
29 | // BBOX is larger than standard EPSG:3857 bounds
30 | var boundingBox = Models.Bounds.FromCommaSeparatedString("-20037508,-25776731,20037508,25776731");
31 | var coordinates = Wms.WmsHelper.BuildTileCoordinatesList(boundingBox, 546);
32 | Assert.That(coordinates, Has.Length.EqualTo(4));
33 | Assert.That(coordinates.All(c => c.Z == 1));
34 | Assert.That(coordinates[0].X, Is.EqualTo(0));
35 | Assert.That(coordinates[0].Y, Is.EqualTo(0));
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Src/TileMapService/Startup.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Builder;
2 | using Microsoft.Extensions.DependencyInjection;
3 |
4 | namespace TileMapService
5 | {
6 | public class Startup
7 | {
8 | public void ConfigureServices(IServiceCollection services)
9 | {
10 | services.AddAuthentication(options =>
11 | {
12 | options.DefaultAuthenticateScheme = "ForbidScheme";
13 | options.DefaultForbidScheme = "ForbidScheme";
14 | options.AddScheme("ForbidScheme", "Handle Forbidden");
15 | });
16 |
17 | services.AddCors();
18 | services.AddControllers();
19 | services.AddSingleton();
20 | }
21 |
22 | public void Configure(IApplicationBuilder app)
23 | {
24 | app.UseAuthentication();
25 | app.UseMiddleware();
26 | app.UseDefaultFiles();
27 | app.UseStaticFiles();
28 | app.UseRouting();
29 | // TODO: custom exception
30 | ////app.UseExceptionHandler(appError =>
31 | ////{
32 | //// appError.Run(async context =>
33 | //// {
34 | //// context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
35 | //// context.Response.ContentType = "application/json";
36 |
37 | //// var contextFeature = context.Features.Get();
38 | //// if (contextFeature != null)
39 | //// {
40 | //// await context.Response.WriteAsync();
41 | //// }
42 | //// });
43 | ////});
44 |
45 | app.UseCors(builder => builder.AllowAnyOrigin());
46 | app.UseEndpoints(endpoints => endpoints.MapControllers());
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Src/TileMapService/Wms/ExceptionReport.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Xml;
3 |
4 | namespace TileMapService.Wms
5 | {
6 | class ExceptionReport
7 | {
8 | private readonly string exceptionCode;
9 |
10 | private readonly string message;
11 |
12 | private readonly string locator;
13 |
14 | public ExceptionReport(string exceptionCode, string message, string locator)
15 | {
16 | this.exceptionCode = exceptionCode;
17 | this.message = message;
18 | this.locator = locator;
19 | }
20 |
21 | public XmlDocument ToXml()
22 | {
23 | var doc = new XmlDocument();
24 | var rootElement = doc.CreateElement(Identifiers.OwsPrefix, Identifiers.ExceptionReportElement, Identifiers.OwsNamespaceUri);
25 | rootElement.SetAttribute("xmlns:xs", "http://www.w3.org/2001/XMLSchema");
26 | rootElement.SetAttribute("xmlns:ows", Identifiers.OwsNamespaceUri);
27 | rootElement.SetAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
28 | rootElement.SetAttribute(Identifiers.VersionAttribute, "1.0.0");
29 | // TODO: ? xsi:schemaLocation
30 |
31 | var exceptionElement = doc.CreateElement(Identifiers.OwsPrefix, Identifiers.ExceptionElement, Identifiers.OwsNamespaceUri);
32 | exceptionElement.SetAttribute(Identifiers.ExceptionCodeAttribute, this.exceptionCode);
33 | exceptionElement.SetAttribute(Identifiers.LocatorAttribute, this.locator);
34 |
35 | var exceptionTextElement = doc.CreateElement(Identifiers.OwsPrefix, Identifiers.ExceptionTextElement, Identifiers.OwsNamespaceUri);
36 | exceptionTextElement.AppendChild(doc.CreateTextNode(this.message));
37 |
38 | exceptionElement.AppendChild(exceptionTextElement);
39 | rootElement.AppendChild(exceptionElement);
40 |
41 | doc.AppendChild(rootElement);
42 |
43 | return doc;
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Src/TileMapService.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 17
4 | VisualStudioVersion = 17.5.33627.172
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{BA9C4EF9-3990-46B6-9354-EA789EDF404A}"
7 | ProjectSection(SolutionItems) = preProject
8 | ..\Docs\appsettings.md = ..\Docs\appsettings.md
9 | ..\README.md = ..\README.md
10 | EndProjectSection
11 | EndProject
12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TileMapService", "TileMapService\TileMapService.csproj", "{37F1E4F6-A0BD-408A-82EC-AB00A3425243}"
13 | EndProject
14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TileMapService.Tests", "TileMapService.Tests\TileMapService.Tests.csproj", "{6B956BBE-0085-439E-B8C0-650BA1A5AFDF}"
15 | EndProject
16 | Global
17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
18 | Debug|Any CPU = Debug|Any CPU
19 | Release|Any CPU = Release|Any CPU
20 | EndGlobalSection
21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
22 | {37F1E4F6-A0BD-408A-82EC-AB00A3425243}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
23 | {37F1E4F6-A0BD-408A-82EC-AB00A3425243}.Debug|Any CPU.Build.0 = Debug|Any CPU
24 | {37F1E4F6-A0BD-408A-82EC-AB00A3425243}.Release|Any CPU.ActiveCfg = Release|Any CPU
25 | {37F1E4F6-A0BD-408A-82EC-AB00A3425243}.Release|Any CPU.Build.0 = Release|Any CPU
26 | {6B956BBE-0085-439E-B8C0-650BA1A5AFDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
27 | {6B956BBE-0085-439E-B8C0-650BA1A5AFDF}.Debug|Any CPU.Build.0 = Debug|Any CPU
28 | {6B956BBE-0085-439E-B8C0-650BA1A5AFDF}.Release|Any CPU.ActiveCfg = Release|Any CPU
29 | {6B956BBE-0085-439E-B8C0-650BA1A5AFDF}.Release|Any CPU.Build.0 = Release|Any CPU
30 | EndGlobalSection
31 | GlobalSection(SolutionProperties) = preSolution
32 | HideSolutionNode = FALSE
33 | EndGlobalSection
34 | GlobalSection(ExtensibilityGlobals) = postSolution
35 | SolutionGuid = {365E53B5-709A-4E72-89BF-194215EDD13D}
36 | EndGlobalSection
37 | EndGlobal
38 |
--------------------------------------------------------------------------------
/Src/TileMapService/Wmts/Identifiers.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.Wmts
2 | {
3 | static class Identifiers
4 | {
5 | public const string WMTS = "WMTS";
6 |
7 | public const string WMS = "WMS";
8 |
9 | public const string OwsNamespaceUri = "http://www.opengis.net/ows/1.1";
10 |
11 | public const string XlinkNamespaceUri = "http://www.w3.org/1999/xlink";
12 |
13 | public const string GetCapabilities = "GetCapabilities";
14 |
15 | public const string GetTile = "GetTile";
16 |
17 | public const string Version100 = "1.0.0";
18 |
19 | public const string ExceptionReportElement = "ExceptionReport";
20 |
21 | public const string ExceptionElement = "Exception";
22 |
23 | public const string ExceptionCodeAttribute = "exceptionCode";
24 |
25 | public const string MissingParameter = "MissingParameter";
26 |
27 | public const string MissingParameterValue = "MissingParameterValue";
28 |
29 | public const string InvalidParameterValue = "InvalidParameterValue";
30 |
31 | public const string NotFound = "Not Found";
32 |
33 | #region Element names
34 |
35 | public const string TitleElement = "Title";
36 |
37 | public const string AbstractElement = "Abstract";
38 |
39 | public const string KeywordsElement = "Keywords";
40 |
41 | public const string KeywordElement = "Keyword";
42 |
43 | public const string OperationsMetadataElement = "OperationsMetadata";
44 |
45 | public const string LayerElement = "Layer";
46 |
47 | public const string ResourceURLElement = "ResourceURL";
48 |
49 | public const string LowerCornerElement = "LowerCorner";
50 |
51 | public const string UpperCornerElement = "UpperCorner";
52 |
53 | public const string TileWidthElement = "TileWidth";
54 |
55 | public const string TileHeightElement = "TileHeight";
56 |
57 | #endregion
58 |
59 | #region Operations syntax identifiers
60 |
61 | public const string RESTful = "RESTful";
62 |
63 | public const string KVP = "KVP";
64 |
65 | #endregion
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Src/TileMapService/GeoTiff/Key.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.GeoTiff
2 | {
3 | enum Key
4 | {
5 | // 6.2.1 GeoTIFF Configuration Keys
6 | GTModelTypeGeoKey = 1024,
7 | GTRasterTypeGeoKey = 1025,
8 | GTCitationGeoKey = 1026,
9 |
10 | // 6.2.2 Geographic CS Parameter Keys
11 | GeographicTypeGeoKey = 2048,
12 | GeogCitationGeoKey = 2049, // documentation
13 | GeogGeodeticDatumGeoKey = 2050,
14 | GeogPrimeMeridianGeoKey = 2051,
15 | GeogLinearUnitsGeoKey = 2052,
16 | GeogLinearUnitSizeGeoKey = 2053, // meters
17 | GeogAngularUnitsGeoKey = 2054,
18 | GeogAngularUnitSizeGeoKey = 2055, // radians
19 | GeogEllipsoidGeoKey = 2056, // Section 6.3.2.3 Codes
20 | GeogSemiMajorAxisGeoKey = 2057, // GeogLinearUnits
21 | GeogSemiMinorAxisGeoKey = 2058, // GeogLinearUnits
22 | GeogInvFlatteningGeoKey = 2059,
23 | GeogAzimuthUnitsGeoKey = 2060,
24 | GeogPrimeMeridianLongGeoKey = 2061,
25 |
26 | // 6.2.3 Projected CS Parameter Keys
27 | ProjectedCSTypeGeoKey = 3072,
28 | PCSCitationGeoKey = 3073,
29 | ProjCoordTransGeoKey = 3075,
30 | ProjLinearUnitsGeoKey = 3076,
31 | ProjLinearUnitSizeGeoKey = 3077,
32 | ProjStdParallel1GeoKey = 3078,
33 | ProjStdParallel2GeoKey = 3079,
34 | ProjNatOriginLongGeoKey = 3080,
35 | ProjNatOriginLatGeoKey = 3081,
36 | ProjFalseEastingGeoKey = 3082,
37 | ProjFalseNorthingGeoKey = 3083,
38 | ProjFalseOriginLongGeoKey = 3084,
39 | ProjFalseOriginLatGeoKey = 3085,
40 | ProjFalseOriginEastingGeoKey = 3086,
41 | ProjFalseOriginNorthingGeoKey = 3087,
42 | ProjCenterLongGeoKey = 3088,
43 | ProjCenterLatGeoKey = 3089,
44 | ProjCenterEastingGeoKey = 3090,
45 | ProjCenterNorthingGeoKey = 3091,
46 | ProjScaleAtNatOriginGeoKey = 3092,
47 | ProjScaleAtCenterGeoKey = 3093,
48 | ProjAzimuthAngleGeoKey = 3094,
49 | ProjStraightVertPoleLongGeoKey = 3095,
50 |
51 | // 6.2.4 Vertical CS Keys
52 | VerticalCSTypeGeoKey = 4096,
53 | VerticalCitationGeoKey = 4097,
54 | VerticalDatumGeoKey = 4098,
55 | VerticalUnitsGeoKey = 4099,
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/TileDataStub.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Text;
3 | using System.Text.Json;
4 |
5 | namespace TileMapService.Tests
6 | {
7 | class TileDataStub : IEquatable
8 | {
9 | private readonly int tileColumn;
10 |
11 | private readonly int tileRow;
12 |
13 | private readonly int zoomLevel;
14 |
15 | public TileDataStub(int tileColumn, int tileRow, int zoomLevel)
16 | {
17 | this.tileColumn = tileColumn;
18 | this.tileRow = tileRow;
19 | this.zoomLevel = zoomLevel;
20 | }
21 |
22 | class TileDataStubCoordinates
23 | {
24 | public int X { get; set; }
25 |
26 | public int Y { get; set; }
27 |
28 | public int Z { get; set; }
29 | }
30 |
31 | public TileDataStub(byte[] tileData)
32 | {
33 | var s = Encoding.UTF8.GetString(tileData);
34 | var coodinates = JsonSerializer.Deserialize(s);
35 |
36 | this.tileColumn = coodinates.X;
37 | this.tileRow = coodinates.Y;
38 | this.zoomLevel = coodinates.Z;
39 | }
40 |
41 | public byte[] ToByteArray()
42 | {
43 | // Tiles aren't actually raster images, but stubs (binary raw data with coordinate values)
44 | var coordinates = new TileDataStubCoordinates
45 | {
46 | X = this.tileColumn,
47 | Y = this.tileRow,
48 | Z = this.zoomLevel,
49 | };
50 |
51 | var json = JsonSerializer.Serialize(coordinates);
52 |
53 | return Encoding.UTF8.GetBytes(json);
54 | }
55 |
56 | public override bool Equals(object obj)
57 | {
58 | return Equals(obj as TileDataStub);
59 | }
60 |
61 | public bool Equals(TileDataStub other)
62 | {
63 | return other != null &&
64 | this.tileColumn == other.tileColumn &&
65 | this.tileRow == other.tileRow &&
66 | this.zoomLevel == other.zoomLevel;
67 | }
68 |
69 | public override int GetHashCode()
70 | {
71 | return HashCode.Combine(this.tileColumn, this.tileRow, this.zoomLevel);
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/Src/TileMapService/Program.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Diagnostics;
3 | using System.Globalization;
4 | using System.Runtime.InteropServices;
5 | using System.Threading.Tasks;
6 |
7 | using Microsoft.AspNetCore.Hosting;
8 | using Microsoft.Extensions.Hosting;
9 | using Microsoft.Extensions.Logging;
10 |
11 | namespace TileMapService
12 | {
13 | static class Program
14 | {
15 | public static async Task Main()
16 | {
17 | var host = Host.CreateDefaultBuilder()
18 | .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup())
19 | .Build();
20 |
21 | // TODO: check and log configuration errors
22 | // https://stackoverflow.com/questions/56077346/asp-net-core-call-async-init-on-singleton-service
23 |
24 | using var loggerFactory = LoggerFactory.Create(loggingBuilder => loggingBuilder
25 | .SetMinimumLevel(LogLevel.Trace)
26 | .AddConsole());
27 |
28 | var logger = loggerFactory.CreateLogger(typeof(Program));
29 | logger.LogInformation($"System info: {Environment.NewLine}{String.Join(Environment.NewLine, GetEnvironmentInfo())}");
30 |
31 | if (host.Services.GetService(typeof(ITileSourceFabric)) is not ITileSourceFabric service)
32 | {
33 | throw new InvalidOperationException();
34 | }
35 |
36 | await service.InitAsync();
37 | await host.RunAsync();
38 | }
39 |
40 | private static string[] GetEnvironmentInfo() =>
41 | new[]
42 | {
43 | $"MachineName='{Environment.MachineName}' Domain='{Environment.UserDomainName}' User='{Environment.UserName}'",
44 | $"CPU={Environment.ProcessorCount} OS='{Environment.OSVersion}' ('{RuntimeInformation.OSDescription.Trim()}')",
45 | $"OS x64={Environment.Is64BitOperatingSystem} Process x64={Environment.Is64BitProcess} .NET='{Environment.Version}' Culture='{CultureInfo.CurrentCulture.DisplayName}' ({CultureInfo.CurrentCulture.Name})",
46 | $"UtcOffset={TimeZoneInfo.Local.GetUtcOffset(DateTime.Now)} TZ='{TimeZoneInfo.Local.StandardName}'",
47 | $"UtcNow={DateTime.UtcNow} Uptime={TimeSpan.FromMilliseconds(Environment.TickCount64)}",
48 | $"Process [PID={Environment.ProcessId}]='{Process.GetCurrentProcess()?.MainModule?.FileName}'",
49 | $"Assembly='{typeof(Program).Assembly.Location}'",
50 | $"CurrentDirectory='{Environment.CurrentDirectory}'",
51 | };
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/TileMapService.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Library
5 | net5.0
6 | TileMapService.Tests
7 |
8 |
9 |
10 | net5.0;net8.0
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | all
48 | runtime; build; native; contentfiles; analyzers; buildtransitive
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/Src/TileMapService/Utils/ResponseHelper.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 |
5 | namespace TileMapService.Utils
6 | {
7 | static class ResponseHelper
8 | {
9 | private static readonly string[] SupportedTileFormats =
10 | new[]
11 | {
12 | MediaTypeNames.Image.Png,
13 | MediaTypeNames.Image.Jpeg,
14 | MediaTypeNames.Image.Webp,
15 | };
16 |
17 | public static bool IsTileFormatSupported(string mediaType)
18 | {
19 | return ResponseHelper.IsFormatInList(SupportedTileFormats, mediaType);
20 | }
21 |
22 | public static bool IsFormatInList(IList mediaTypes, string mediaType)
23 | {
24 | return mediaTypes.Any(mt =>
25 | String.Compare(mediaType, mt, StringComparison.OrdinalIgnoreCase) == 0);
26 | }
27 |
28 | public static FileResponse? CreateFileResponse(byte[]? imageContents, string mediaType, string? sourceContentType, int quality)
29 | {
30 | if (imageContents != null && imageContents.Length > 0)
31 | {
32 | if (String.Compare(mediaType, sourceContentType, StringComparison.OrdinalIgnoreCase) == 0)
33 | {
34 | // Return original source image
35 | return new FileResponse { FileContents = imageContents, ContentType = mediaType };
36 | }
37 | else
38 | {
39 | var isFormatSupported = ResponseHelper.IsTileFormatSupported(mediaType);
40 | // Convert source image to requested output format, if possible
41 | if (isFormatSupported)
42 | {
43 | var outputImage = ImageHelper.ConvertImageToFormat(imageContents, mediaType, quality);
44 | return outputImage != null
45 | ? new FileResponse { FileContents = outputImage, ContentType = mediaType }
46 | : null;
47 | }
48 | else
49 | {
50 | // Conversion was not possible
51 | return new FileResponse { FileContents = imageContents, ContentType = mediaType };
52 | }
53 | }
54 | }
55 | else
56 | {
57 | return null;
58 | }
59 | }
60 | }
61 |
62 | class FileResponse
63 | {
64 | #pragma warning disable CS8618
65 | public byte[] FileContents { get; set; }
66 |
67 | public string ContentType { get; set; }
68 | #pragma warning restore CS8618
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Src/TileMapService/Wms/Identifiers.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.Wms
2 | {
3 | static class Identifiers
4 | {
5 | public const string OwsPrefix = "ows";
6 |
7 | public const string OgcNamespaceUri = "http://www.opengis.net/ogc";
8 |
9 | public const string OwsNamespaceUri = "http://www.opengis.net/ows";
10 |
11 | public const string Wms = "WMS";
12 |
13 | public const string GetCapabilities = "GetCapabilities";
14 |
15 | public const string GetMap = "GetMap";
16 |
17 | public const string GetFeatureInfo = "GetFeatureInfo";
18 |
19 | public const string Version111 = "1.1.1";
20 |
21 | public const string Version130 = "1.3.0";
22 |
23 | public const string EPSG3857 = "EPSG:3857";
24 |
25 | public const string DefaultBackgroundColor = "0xFFFFFF";
26 |
27 | public const string WMT_MS_CapabilitiesElement = "WMT_MS_Capabilities";
28 |
29 | public const string WMS_CapabilitiesElement = "WMS_Capabilities";
30 |
31 | public const string Srs = "SRS";
32 |
33 | public const string Crs = "CRS";
34 |
35 | public const string ServiceElement = "Service";
36 |
37 | public const string CapabilityElement = "Capability";
38 |
39 | public const string TitleElement = "Title";
40 |
41 | public const string NameElement = "Name";
42 |
43 | public const string AbstractElement = "Abstract";
44 |
45 | public const string LayerElement = "Layer";
46 |
47 | public const string QueryableAttribute = "queryable";
48 |
49 | public const string ExceptionReportElement = "ExceptionReport";
50 |
51 | public const string ServiceExceptionReportElement = "ServiceExceptionReport";
52 |
53 | public const string ExceptionElement = "Exception";
54 |
55 | public const string ServiceExceptionElement = "ServiceException";
56 |
57 | public const string ExceptionTextElement = "ExceptionText";
58 |
59 | public const string VersionAttribute = "version";
60 |
61 | public const string LocatorAttribute = "locator";
62 |
63 | public const string ExceptionCodeAttribute = "exceptionCode";
64 |
65 | public const string CodeAttribute = "code";
66 |
67 | public const string InvalidParameterValue = "InvalidParameterValue";
68 |
69 | public const string InvalidFormat = "InvalidFormat";
70 |
71 | public const string OperationNotSupported = "OperationNotSupported";
72 |
73 | public const string MissingOrInvalidParameter = "MissingOrInvalidParameter";
74 |
75 | public const string InvalidSRS = "InvalidSRS";
76 |
77 | public const string MissingBBox = "MissingBBox";
78 |
79 | public const string LayerNotDefined = "LayerNotDefined";
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/Expected/tms_capabilities_TileMap1.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | World Countries
4 |
5 | OSGEO:41001
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/Src/TileMapService/Models/GeographicalBounds.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Globalization;
3 |
4 | namespace TileMapService.Models
5 | {
6 | ///
7 | /// Represents bounds in geographical (longitude-latitude) coordinates.
8 | ///
9 | public class GeographicalBounds
10 | {
11 | private readonly GeographicalPoint pointMin;
12 |
13 | private readonly GeographicalPoint pointMax;
14 |
15 | public GeographicalBounds(GeographicalPoint pointMin, GeographicalPoint pointMax)
16 | {
17 | this.pointMin = pointMin;
18 | this.pointMax = pointMax;
19 | }
20 |
21 | public GeographicalBounds(double minLongitude, double minLatitude, double maxLongitude, double maxLatitude)
22 | {
23 | this.pointMin = new GeographicalPoint(minLongitude, minLatitude);
24 | this.pointMax = new GeographicalPoint(maxLongitude, maxLatitude);
25 | }
26 |
27 | ///
28 | /// Creates from string.
29 | ///
30 | /// OpenLayers Bounds format: left, bottom, right, top), string of comma-separated numbers.
31 | ///
32 | public static GeographicalBounds FromCommaSeparatedString(string s)
33 | {
34 | if (s == null)
35 | {
36 | throw new ArgumentNullException(nameof(s));
37 | }
38 |
39 | var items = s.Split(',');
40 | if (items.Length != 4)
41 | {
42 | throw new FormatException("String should contain 4 comma-separated values");
43 | }
44 |
45 | return new GeographicalBounds(
46 | new GeographicalPoint(
47 | Double.Parse(items[0], CultureInfo.InvariantCulture),
48 | Double.Parse(items[1], CultureInfo.InvariantCulture)),
49 | new GeographicalPoint(
50 | Double.Parse(items[2], CultureInfo.InvariantCulture),
51 | Double.Parse(items[3], CultureInfo.InvariantCulture))
52 | );
53 | }
54 |
55 | public GeographicalPoint Min
56 | {
57 | get
58 | {
59 | return this.pointMin;
60 | }
61 | }
62 |
63 | public GeographicalPoint Max
64 | {
65 | get
66 | {
67 | return this.pointMax;
68 | }
69 | }
70 |
71 | public double MinLongitude
72 | {
73 | get
74 | {
75 | return this.pointMin.Longitude;
76 | }
77 | }
78 |
79 | public double MinLatitude
80 | {
81 | get
82 | {
83 | return this.pointMin.Latitude;
84 | }
85 | }
86 |
87 | public double MaxLongitude
88 | {
89 | get
90 | {
91 | return this.pointMax.Longitude;
92 | }
93 | }
94 |
95 | public double MaxLatitude
96 | {
97 | get
98 | {
99 | return this.pointMax.Latitude;
100 | }
101 | }
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/TestsUtility.cs:
--------------------------------------------------------------------------------
1 | using System.Globalization;
2 | using System.IO;
3 | using System.Linq;
4 | using System.Net;
5 | using System.Text;
6 | using System.Threading.Tasks;
7 |
8 | using Microsoft.AspNetCore.Hosting;
9 | using Microsoft.Extensions.Configuration;
10 | using Microsoft.Extensions.Hosting;
11 | using NUnit.Framework;
12 |
13 | namespace TileMapService.Tests
14 | {
15 | class TestsUtility
16 | {
17 | public static async Task CreateAndRunServiceHostAsync(string json, int port)
18 | {
19 | // https://docs.microsoft.com/ru-ru/aspnet/core/fundamentals/configuration/?view=aspnetcore-5.0
20 | var host = Host.CreateDefaultBuilder()
21 | .ConfigureAppConfiguration(configurationBuilder =>
22 | {
23 | var configuration = new ConfigurationBuilder()
24 | .AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json)))
25 | .Build();
26 |
27 | // https://stackoverflow.com/a/58594026/1182448
28 | configurationBuilder.Sources.Clear();
29 | configurationBuilder.AddConfiguration(configuration);
30 | })
31 | .ConfigureWebHostDefaults(webHostBuilder =>
32 | {
33 | webHostBuilder.UseStartup();
34 | webHostBuilder.UseKestrel(options =>
35 | {
36 | options.Listen(IPAddress.Loopback, port);
37 | });
38 | })
39 | .Build();
40 |
41 | await (host.Services.GetService(typeof(ITileSourceFabric)) as ITileSourceFabric).InitAsync();
42 |
43 | var _ = host.RunAsync();
44 |
45 | return host;
46 | }
47 |
48 | public static string UpdateXmlContents(string xml, int portNumber)
49 | {
50 | var s11 = "http://localhost:5000";
51 | var s12 = "http://localhost:" + portNumber.ToString(CultureInfo.InvariantCulture);
52 |
53 | return xml.Replace(s11, s12);
54 | }
55 |
56 | public static void CompareXml(string expectedXml, string actualXml)
57 | {
58 | var comparer = new NetBike.XmlUnit.XmlComparer
59 | {
60 | NormalizeText = true,
61 | Analyzer = NetBike.XmlUnit.XmlAnalyzer.Custom()
62 | .SetEqual(NetBike.XmlUnit.XmlComparisonType.NodeListSequence)
63 | };
64 |
65 | using var expectedReader = new StringReader(expectedXml);
66 | using var actualReader = new StringReader(actualXml);
67 |
68 | var result = comparer.Compare(expectedReader, actualReader);
69 |
70 | if (!result.IsEqual)
71 | {
72 | Assert.Fail(result.Differences.First().Difference.ToString());
73 | }
74 | }
75 |
76 | public static byte[] ReadResource(string id)
77 | {
78 | var assembly = typeof(TestsUtility).Assembly;
79 | var resourceName = typeof(TestsUtility).Namespace + "." + id;
80 |
81 | byte[] data = null;
82 | using (var stream = assembly.GetManifestResourceStream(resourceName))
83 | {
84 | data = new byte[stream.Length];
85 | stream.Read(data, 0, (int)stream.Length);
86 | }
87 |
88 | return data;
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/Src/TileMapService/Models/Bounds.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Globalization;
3 |
4 | namespace TileMapService.Models
5 | {
6 | ///
7 | /// Represents rectangular area, defined by two opposite corners.
8 | ///
9 | public class Bounds
10 | {
11 | ///
12 | /// X coordinate of bottom left corner.
13 | ///
14 | public double Left { get; set; }
15 |
16 | ///
17 | /// Y coordinate of bottom left corner.
18 | ///
19 | public double Bottom { get; set; }
20 |
21 | ///
22 | /// X coordinate of top right corner.
23 | ///
24 | public double Right { get; set; }
25 |
26 | ///
27 | /// Y coordinate of top right corner.
28 | ///
29 | public double Top { get; set; }
30 |
31 | ///
32 | /// Initializes a new instance of with zero coordinates.
33 | ///
34 | public Bounds()
35 | {
36 |
37 | }
38 |
39 | ///
40 | /// Initializes a new instance of from given coordinates of corners.
41 | ///
42 | /// X coordinate of bottom left corner.
43 | /// Y coordinate of bottom left corner.
44 | /// X coordinate of top right corner.
45 | /// Y coordinate of top right corner.
46 | /// The new instance of .
47 | public Bounds(double left, double bottom, double right, double top)
48 | {
49 | this.Left = left;
50 | this.Bottom = bottom;
51 | this.Right = right;
52 | this.Top = top;
53 | }
54 |
55 | ///
56 | /// Creates from string.
57 | ///
58 | /// OpenLayers Bounds format: left, bottom, right, top), string of comma-separated numbers.
59 | ///
60 | public static Bounds FromCommaSeparatedString(string s)
61 | {
62 | if (s == null)
63 | {
64 | throw new ArgumentNullException(nameof(s));
65 | }
66 |
67 | var items = s.Split(',');
68 | if (items.Length != 4)
69 | {
70 | throw new FormatException("String should contain 4 comma-separated values");
71 | }
72 |
73 | return new Bounds
74 | {
75 | Left = Double.Parse(items[0], CultureInfo.InvariantCulture),
76 | Bottom = Double.Parse(items[1], CultureInfo.InvariantCulture),
77 | Right = Double.Parse(items[2], CultureInfo.InvariantCulture),
78 | Top = Double.Parse(items[3], CultureInfo.InvariantCulture),
79 | };
80 | }
81 |
82 | ///
83 | /// Converts instance to its string representation.
84 | ///
85 | /// The string representation of the instance.
86 | public string ToBBoxString()
87 | {
88 | return String.Format(
89 | CultureInfo.InvariantCulture,
90 | "{0},{1},{2},{3}",
91 | this.Left,
92 | this.Bottom,
93 | this.Right,
94 | this.Top);
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/Src/TileMapService/MBTiles/MetadataItem.cs:
--------------------------------------------------------------------------------
1 | namespace TileMapService.MBTiles
2 | {
3 | ///
4 | /// Represents single key/value item in 'metadata' table of MBTiles database.
5 | ///
6 | ///
7 | /// See https://github.com/mapbox/mbtiles-spec/blob/master/1.3/spec.md#metadata
8 | ///
9 | public class MetadataItem
10 | {
11 | ///
12 | /// Name of item (key).
13 | ///
14 | public string Name { get; private set; }
15 |
16 | ///
17 | /// String value of item.
18 | ///
19 | public string? Value { get; private set; }
20 |
21 | ///
22 | /// Creates new metadata item instance with given name and value.
23 | ///
24 | public MetadataItem(string name, string? value)
25 | {
26 | this.Name = name;
27 | this.Value = value;
28 | }
29 |
30 | ///
31 | /// Converts instance to its string representation.
32 | ///
33 | /// The string representation of the instance.
34 | public override string ToString() => $"\"{this.Name}\": \"{this.Value}\"";
35 |
36 | #region Names of standard items.
37 |
38 | ///
39 | /// The human-readable name of the tileset.
40 | ///
41 | public const string KeyName = "name";
42 |
43 | ///
44 | /// The file format of the tile data: pbf, jpg, png, webp, or an IETF media type for other formats.
45 | ///
46 | public const string KeyFormat = "format";
47 |
48 | ///
49 | /// The maximum extent of the rendered map area (as WGS 84 latitude and longitude values, in the OpenLayers Bounds format: left, bottom, right, top), string of comma-separated numbers.
50 | ///
51 | public const string KeyBounds = "bounds";
52 |
53 | ///
54 | /// The longitude, latitude, and zoom level of the default view of the map, string of comma-separated numbers.
55 | ///
56 | public const string KeyCenter = "center";
57 |
58 | ///
59 | /// The lowest zoom level (number) for which the tileset provides data.
60 | ///
61 | public const string KeyMinZoom = "minzoom";
62 |
63 | ///
64 | /// The highest zoom level (number) for which the tileset provides data.
65 | ///
66 | public const string KeyMaxZoom = "maxzoom";
67 |
68 | ///
69 | /// An attribution (HTML) string, which explains the sources of data and/or style for the map.
70 | ///
71 | public const string KeyAttribution = "attribution";
72 |
73 | ///
74 | /// A description of the tileset content.
75 | ///
76 | public const string KeyDescription = "description";
77 |
78 | ///
79 | /// Type of tileset: "overlay" or "baselayer".
80 | ///
81 | public const string KeyType = "type";
82 |
83 | ///
84 | /// The version (revision) of the tileset.
85 | ///
86 | public const string KeyVersion = "version";
87 |
88 | ///
89 | /// Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers in case of pbf format.
90 | ///
91 | public const string KeyJson = "json";
92 |
93 | #endregion
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/Src/TileMapService/Utils/EntitiesConverter.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.IO;
4 | using System.Linq;
5 | using System.Text;
6 | using System.Xml;
7 |
8 | using TileMapService.Models;
9 |
10 | namespace TileMapService.Utils
11 | {
12 | ///
13 | /// Various utility functions.
14 | ///
15 | static class EntitiesConverter
16 | {
17 | public static string TileFormatToContentType(string format) =>
18 | format switch
19 | {
20 | ImageFormats.Png => MediaTypeNames.Image.Png,
21 | ImageFormats.Jpeg => MediaTypeNames.Image.Jpeg,
22 | ImageFormats.MapboxVectorTile => MediaTypeNames.Application.MapboxVectorTile,
23 | ImageFormats.Protobuf => MediaTypeNames.Application.XProtobuf,
24 | // TODO: other possible types
25 | _ => format,
26 | };
27 |
28 | public static string ExtensionToMediaType(string extension) =>
29 | extension switch
30 | {
31 | "png" => MediaTypeNames.Image.Png,
32 | "jpg" => MediaTypeNames.Image.Jpeg,
33 | "jpeg" => MediaTypeNames.Image.Jpeg,
34 | "webp" => MediaTypeNames.Image.Webp,
35 | "tif" => MediaTypeNames.Image.Tiff,
36 | "tiff" => MediaTypeNames.Image.Tiff,
37 | "mvt" => MediaTypeNames.Application.MapboxVectorTile,
38 | "pbf" => MediaTypeNames.Application.XProtobuf,
39 | // TODO: other possible types
40 | _ => extension,
41 | };
42 |
43 | public static Layer[] SourcesToLayers(IEnumerable sources) =>
44 | sources.Select(SourceConfigurationToLayer).ToArray();
45 |
46 | private static Layer SourceConfigurationToLayer(SourceConfiguration c) =>
47 | new()
48 | {
49 | Identifier = c.Id,
50 | Title = c.Title,
51 | Abstract = c.Abstract,
52 | ContentType = c.ContentType,
53 | Format = c.Format,
54 | Srs = c.Srs,
55 | MinZoom = c.MinZoom != null ? c.MinZoom.Value : 0,
56 | MaxZoom = c.MaxZoom != null ? c.MaxZoom.Value : 24,
57 | GeographicalBounds = c.GeographicalBounds,
58 | TileWidth = c.TileWidth,
59 | TileHeight = c.TileHeight,
60 | };
61 |
62 | ///
63 | /// Saves document with header to byte array using UTF-8 encoding.
64 | ///
65 | /// XML Document.
66 | /// Contents of XML document.
67 | public static byte[] XmlDocumentToUTF8ByteArray(XmlDocument xml)
68 | {
69 | using var ms = new MemoryStream();
70 | using (var xw = XmlWriter.Create(new StreamWriter(ms, Encoding.UTF8)))
71 | {
72 | xml.Save(xw);
73 | }
74 |
75 | return ms.ToArray();
76 | }
77 |
78 | public static uint ArgbColorFromString(string rgbHexColor, bool isTransparent)
79 | {
80 | if (rgbHexColor.StartsWith("0x"))
81 | {
82 | rgbHexColor = rgbHexColor[2..];
83 | }
84 |
85 | return BitConverter.ToUInt32(
86 | new[]
87 | {
88 | Convert.ToByte(rgbHexColor.Substring(4, 2), 16),
89 | Convert.ToByte(rgbHexColor.Substring(2, 2), 16),
90 | Convert.ToByte(rgbHexColor[..2], 16),
91 | (byte)(isTransparent ? 0x00 : 0xFF),
92 | },
93 | 0);
94 | }
95 |
96 | public static GeographicalBounds MapRectangleToGeographicalBounds(Bounds rectangle) =>
97 | new(
98 | new GeographicalPoint(WebMercator.Longitude(rectangle.Left), WebMercator.Latitude(rectangle.Bottom)),
99 | new GeographicalPoint(WebMercator.Longitude(rectangle.Right), WebMercator.Latitude(rectangle.Top)));
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/Src/TileMapService.Tests/ImageHelperTests.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 |
3 | using NUnit.Framework;
4 |
5 | using U = TileMapService.Utils;
6 |
7 | namespace TileMapService.Tests
8 | {
9 | [TestFixture]
10 | public class ImageHelperTests
11 | {
12 | private const string Sample1_3857 = "GeoTiff.sample1-epsg-3857.tiff";
13 | private const string Sample1_4326 = "GeoTiff.sample1-epsg-4326.tiff";
14 |
15 | [OneTimeSetUp]
16 | public void Setup()
17 | {
18 | RemoveTestData();
19 | PrepareTestData();
20 | }
21 |
22 | [OneTimeTearDown]
23 | public void TearDown()
24 | {
25 | // Skip deleting files to analyze them
26 | if (!System.Diagnostics.Debugger.IsAttached)
27 | {
28 | RemoveTestData();
29 | }
30 | }
31 |
32 | private static void PrepareTestData()
33 | {
34 | if (!Directory.Exists(TestConfiguration.DataPath))
35 | {
36 | Directory.CreateDirectory(TestConfiguration.DataPath);
37 | }
38 |
39 | foreach (var name in new[] { Sample1_3857, Sample1_4326 })
40 | {
41 | File.WriteAllBytes(Path.Combine(TestConfiguration.DataPath, name), TestsUtility.ReadResource(name));
42 | }
43 | }
44 |
45 | private static void RemoveTestData()
46 | {
47 | if (Directory.Exists(TestConfiguration.DataPath))
48 | {
49 | new DirectoryInfo(TestConfiguration.DataPath).Delete(true);
50 | }
51 | }
52 |
53 | [Test]
54 | public void ReadGeoTiffProperties3857()
55 | {
56 | var props = U.ImageHelper.ReadGeoTiffProperties(Path.Combine(TestConfiguration.DataPath, Sample1_3857));
57 |
58 | Assert.Multiple(() =>
59 | {
60 | Assert.That(props.ImageWidth, Is.EqualTo(80));
61 | Assert.That(props.ImageHeight, Is.EqualTo(60));
62 | Assert.That(props.Srid, Is.EqualTo(3857));
63 | Assert.That(props.ProjectedBounds.Left, Is.EqualTo(616131.764).Within(0.001));
64 | Assert.That(props.ProjectedBounds.Right, Is.EqualTo(637857.616).Within(0.001));
65 | Assert.That(props.ProjectedBounds.Bottom, Is.EqualTo(-166644.204).Within(0.001));
66 | Assert.That(props.ProjectedBounds.Top, Is.EqualTo(-150349.815).Within(0.001));
67 | });
68 | }
69 |
70 | [Test]
71 | public void ReadGeoTiffProperties4326()
72 | {
73 | var props = U.ImageHelper.ReadGeoTiffProperties(Path.Combine(TestConfiguration.DataPath, Sample1_4326));
74 |
75 | Assert.Multiple(() =>
76 | {
77 | Assert.That(props.ImageWidth, Is.EqualTo(80));
78 | Assert.That(props.ImageHeight, Is.EqualTo(60));
79 | Assert.That(props.Srid, Is.EqualTo(4326));
80 | Assert.That(props.GeographicalBounds.MinLongitude, Is.EqualTo(5.534806).Within(0.001));
81 | Assert.That(props.GeographicalBounds.MaxLongitude, Is.EqualTo(5.729972).Within(0.001));
82 | Assert.That(props.GeographicalBounds.MinLatitude, Is.EqualTo(-1.496728).Within(0.001));
83 | Assert.That(props.GeographicalBounds.MaxLatitude, Is.EqualTo(-1.350353).Within(0.001));
84 | });
85 | }
86 |
87 | [Test]
88 | public void CreateAndReadGeoTiffProperties()
89 | {
90 | var path = Path.Combine(TestConfiguration.DataPath, "generated-image.tiff");
91 |
92 | var pixels = new byte[64 * 32 * 4]; // 64x32 RGBA
93 | var image = U.ImageHelper.CreateTiffImage(pixels, 64, 32, new Models.Bounds(10, 5, 650, 325), true);
94 | File.WriteAllBytes(path, image);
95 |
96 | var props = U.ImageHelper.ReadGeoTiffProperties(path);
97 | Assert.Multiple(() =>
98 | {
99 | Assert.That(props.ImageWidth, Is.EqualTo(64));
100 | Assert.That(props.ImageHeight, Is.EqualTo(32));
101 | Assert.That(props.Srid, Is.EqualTo(3857));
102 | });
103 | }
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/Src/TileMapService/TileSourceFabric.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Threading.Tasks;
5 |
6 | using Microsoft.Extensions.Configuration;
7 | using Microsoft.Extensions.Logging;
8 |
9 | using TileMapService.TileSources;
10 |
11 | namespace TileMapService
12 | {
13 | class TileSourceFabric : ITileSourceFabric
14 | {
15 | private readonly ILogger logger;
16 |
17 | private readonly ILoggerFactory loggerFactory;
18 |
19 | private readonly Dictionary tileSources;
20 |
21 | private readonly ServiceProperties serviceProperties;
22 |
23 | public TileSourceFabric(IConfiguration configuration, ILogger logger)
24 | {
25 | this.logger = logger;
26 | this.loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
27 |
28 | var sources = configuration
29 | .GetSection("Sources")
30 | .Get>();
31 |
32 | if (sources == null)
33 | {
34 | throw new InvalidOperationException($"Sources section is not defined in configuration file.");
35 | }
36 | else
37 | {
38 | this.tileSources = sources
39 | .Where(c => !String.IsNullOrEmpty(c.Id)) // Skip disabled sources
40 | .ToDictionary(c => c.Id, c => CreateTileSource(c));
41 | }
42 |
43 | var serviceProps = configuration
44 | .GetSection("Service")
45 | .Get();
46 |
47 | this.serviceProperties = serviceProps ?? new ServiceProperties();
48 | }
49 |
50 | #region ITileSourceFabric implementation
51 |
52 | async Task ITileSourceFabric.InitAsync()
53 | {
54 | foreach (var tileSource in this.tileSources)
55 | {
56 | // TODO: ? execute in parallel
57 | // TODO: ? exclude or set flag if initialization error
58 | try
59 | {
60 | await tileSource.Value.InitAsync();
61 | }
62 | catch (Exception ex)
63 | {
64 | this.logger.LogError(ex, $"Error initializing '{tileSource.Value.Configuration.Id}' source.");
65 | }
66 | }
67 | }
68 |
69 | bool ITileSourceFabric.Contains(string id) => this.tileSources.ContainsKey(id);
70 |
71 | ITileSource ITileSourceFabric.Get(string id) => this.tileSources[id];
72 |
73 | List ITileSourceFabric.Sources => this.tileSources
74 | .Select(s => s.Value.Configuration)
75 | .ToList();
76 |
77 | ServiceProperties ITileSourceFabric.ServiceProperties => this.serviceProperties;
78 |
79 | #endregion
80 |
81 | private ITileSource CreateTileSource(SourceConfiguration config)
82 | {
83 | if (config == null)
84 | {
85 | throw new ArgumentNullException(nameof(config));
86 | }
87 |
88 | if (String.IsNullOrEmpty(config.Type))
89 | {
90 | throw new InvalidOperationException("config.Type is null or empty");
91 | }
92 |
93 | return config.Type.ToLowerInvariant() switch
94 | {
95 | SourceConfiguration.TypeLocalFiles => new LocalFilesTileSource(config),
96 | SourceConfiguration.TypeMBTiles => new MBTilesTileSource(config),
97 | SourceConfiguration.TypePostGIS => new PostGisTileSource(config),
98 | SourceConfiguration.TypeXyz => new HttpTileSource(config, loggerFactory.CreateLogger()),
99 | SourceConfiguration.TypeTms => new HttpTileSource(config, loggerFactory.CreateLogger()),
100 | SourceConfiguration.TypeWmts => new HttpTileSource(config, loggerFactory.CreateLogger()),
101 | SourceConfiguration.TypeWms => new HttpTileSource(config, loggerFactory.CreateLogger()),
102 | SourceConfiguration.TypeGeoTiff => new RasterTileSource(config),
103 | _ => throw new InvalidOperationException($"Unknown tile source type '{config.Type}'"),
104 | };
105 | }
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/Docs/appsettings.md:
--------------------------------------------------------------------------------
1 | ### Configuration file structure
2 |
3 | ### Common service properties
4 |
5 | Common service properties are defined in `Service` section of `appsettings.json` file and mostly common used in WMTS and WMS capabilities documents.
6 |
7 | #### title
8 | Type: `String`
9 | Required: `false`
10 |
11 | User-friendly title (displayed name) of service.
12 |
13 | #### abstract
14 | Type: `String`
15 | Required: `false`
16 |
17 | Detailed text description of service.
18 |
19 | #### keywords
20 | Type: `String`
21 | Required: `false`
22 |
23 | Keywords describing service.
24 |
25 | #### jpegQuality
26 | Type: `Number`
27 | Required: `false`
28 |
29 | Quality (compression level) for JPEG output in WMS endpoint, in 0..100 range, default 90.
30 |
31 |
32 | ### Tile sources
33 |
34 | Tile sources are defined in `Sources` section of `appsettings.json` file.
35 |
36 | Configuration values priority:
37 | * Default values for given tile source type.
38 | * Actual values (`MBTiles` metadata, files properties).
39 | * Values from configuration file - highest priority, overrides given above, if provided.
40 |
41 | #### type
42 | Type: `String`
43 | Required: `true`
44 |
45 | Used to define source type, must be one of `file`, `mbtiles`, `postgis`, `xyz`, `tms`, `wmts`, `wms`, `geotiff` (case insensitive).
46 |
47 | #### id
48 | Type: `String`
49 | Required: `true`
50 |
51 | String identifier of tile source (case sensitive).
52 |
53 | #### format
54 | Type: `String`
55 | Required: `false`
56 |
57 | Name of tiles raster image format (must be `png` or `jpg`).
58 |
59 | #### title
60 | Type: `String`
61 | Required: `false`
62 |
63 | User-friendly title (displayed name) of tile source.
64 |
65 | #### abstract
66 | Type: `String`
67 | Required: `false`
68 |
69 | Detailed text description of tile source.
70 |
71 | #### location
72 | Type: `String`
73 | Required: `true`
74 |
75 | Location of tiles.
76 | Path template for `file`, full path for `mbtiles`, `geotiff` types, url template for `xyz` and `tms`, base url for `wmts` and `wms`.
77 | Template string uses `{x}`, `{y}`, `{z}` as placeholders for corresponding coordinate values.
78 |
79 | WMS location should contain base url of WMS service along with `version`, `layers`, `srs`/`crs` values.
80 | Other values, like `styles`, `transparent`, `bgcolor` and so on are optional. The only `srs`/`crs` currently supported is `EPSG:3857` or compatible.
81 |
82 | PostGIS location should contain connection string for PostgreSQL database.
83 |
84 | #### srs
85 | Type: `String`
86 | Required: `false`
87 | Default: `EPSG:3857`
88 |
89 | Spatial reference system (SRS), EPSG code. Possible values are `EPSG:3857` and `EPSG:4326`.
90 |
91 | #### tms
92 | Type: `Boolean`
93 | Required: `false`
94 |
95 | TMS type Y coordinate (true: Y going from bottom to top; false: from top to bottom, like in OSM tiles).
96 |
97 | #### minzoom
98 | Type: `Number`
99 | Required: `false`
100 |
101 | Minimum zoom of tile source.
102 |
103 | #### maxzoom
104 | Type: `Number`
105 | Required: `false`
106 |
107 | Maximum zoom of tile source.
108 |
109 | #### cache
110 | Type: `Object`
111 | Required: `false`
112 |
113 | Caching option for sources of type `xyz`, `tms`, `wmts`, `wms`.
114 |
115 | #### type
116 | Type: `String`
117 | Required: `false`
118 | Must be `mbtiles` string.
119 |
120 | #### dbfile
121 | Type: `String`
122 | Required: `true`
123 | Full path to `mbtiles` database file to store cached tiles. File will be created automatically, if not exists.
124 |
125 |
126 | #### wmts
127 | Type: `Object`
128 | Required: `false`
129 |
130 | Source of type `wmts` (WMTS service and layer) properties. This values has priority over `location` url parameters.
131 |
132 | #### capabilitiesurl
133 | Type: `String`
134 | Required: `true`
135 | WMTS Capabilities document url like `http://example.com/wmts/1.0.0/WMTSCapabilities.xml` or just base WMTS service url like `http://example.com/wmts/`.
136 |
137 | #### layer
138 | Type: `String`
139 | Required: `true`
140 | Layer identifier.
141 |
142 | #### style
143 | Type: `String`
144 | Required: `false`
145 | Style identifier, `default` if not defined.
146 |
147 | #### tilematrixset
148 | Type: `String`
149 | Required: `true`
150 | TileMatrixSet identifier.
151 |
152 |
153 | #### wms
154 | Type: `Object`
155 | Required: `false`
156 |
157 | Source of type `wms` (WMS service and layer) properties. This values has priority over `location` url parameters.
158 |
159 | #### layer
160 | Type: `String`
161 | Required: `true`
162 | Layer identifier (`layers` parameter is WMS request).
163 |
164 | #### version
165 | Type: `String`
166 | Required: `true`
167 | WMS version identifier (must be `1.1.1` or `1.3.0`).
168 |
169 |
170 | #### postgis
171 | Type: `Object`
172 | Required: `false`
173 |
174 | Table options for source of type `postgis` only.
175 |
176 | #### table
177 | Type: `String`
178 | Required: `true`
179 | Name of table with features in database.
180 |
181 | #### geometry
182 | Type: `String`
183 | Required: `true`
184 | Name of column with `geometry` data (with `EPSG:3857` SRS only) in table.
185 |
186 | #### fields
187 | Type: `String`
188 | Required: `false`
189 | Comma-separated string with column names with additional attributes of features, like id, name and so on.
190 | Empty string or not defined, if no additional attributes is required.
--------------------------------------------------------------------------------
/Src/TileMapService/MBTiles/Metadata.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Globalization;
4 | using System.Linq;
5 |
6 | namespace TileMapService.MBTiles
7 | {
8 | ///
9 | /// Represents metadata set from 'metadata' table of MBTiles database.
10 | ///
11 | class Metadata
12 | {
13 | private readonly List metadata;
14 |
15 | public Metadata(IEnumerable metadata)
16 | {
17 | this.metadata = metadata.ToList();
18 |
19 | this.Name = this.GetItem(MetadataItem.KeyName)?.Value;
20 | this.Format = this.GetItem(MetadataItem.KeyFormat)?.Value;
21 |
22 | var bounds = this.GetItem(MetadataItem.KeyBounds);
23 | if (bounds != null)
24 | {
25 | if (!String.IsNullOrEmpty(bounds.Value))
26 | {
27 | this.Bounds = Models.GeographicalBounds.FromCommaSeparatedString(bounds.Value);
28 | }
29 | }
30 |
31 | var center = this.GetItem(MetadataItem.KeyCenter);
32 | if (center != null)
33 | {
34 | if (!String.IsNullOrEmpty(center.Value))
35 | {
36 | this.Center = Models.GeographicalPointWithZoom.FromMBTilesMetadataString(center.Value);
37 | }
38 | }
39 |
40 | var minzoom = this.GetItem(MetadataItem.KeyMinZoom);
41 | if (minzoom != null)
42 | {
43 | if (Int32.TryParse(minzoom.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int minZoomValue))
44 | {
45 | this.MinZoom = minZoomValue;
46 | }
47 | }
48 |
49 | var maxzoom = this.GetItem(MetadataItem.KeyMaxZoom);
50 | if (maxzoom != null)
51 | {
52 | if (Int32.TryParse(maxzoom.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int maxZoomValue))
53 | {
54 | this.MaxZoom = maxZoomValue;
55 | }
56 | }
57 |
58 | this.Attribution = this.GetItem(MetadataItem.KeyAttribution)?.Value;
59 | this.Description = this.GetItem(MetadataItem.KeyDescription)?.Value;
60 | this.Type = this.GetItem(MetadataItem.KeyType)?.Value;
61 | this.Version = this.GetItem(MetadataItem.KeyVersion)?.Value;
62 | this.Json = this.GetItem(MetadataItem.KeyJson)?.Value;
63 | }
64 |
65 | ///
66 | /// The human-readable name of the tileset.
67 | ///
68 | public string? Name { get; private set; }
69 |
70 | ///
71 | /// The file format of the tile data: pbf, jpg, png, webp, or an IETF media type for other formats.
72 | ///
73 | public string? Format { get; private set; }
74 |
75 | ///
76 | /// The maximum extent of the rendered map area (as WGS 84 latitude and longitude values, in the OpenLayers Bounds format: left, bottom, right, top), string of comma-separated numbers.
77 | ///
78 | public Models.GeographicalBounds? Bounds { get; private set; }
79 |
80 | ///
81 | /// The longitude, latitude, and zoom level of the default view of the map, string of comma-separated numbers.
82 | ///
83 | public Models.GeographicalPointWithZoom? Center { get; private set; }
84 |
85 | ///
86 | /// The lowest zoom level (number) for which the tileset provides data.
87 | ///
88 | public int? MinZoom { get; private set; }
89 |
90 | ///
91 | /// The highest zoom level (number) for which the tileset provides data.
92 | ///
93 | public int? MaxZoom { get; private set; }
94 |
95 | ///
96 | /// An attribution (HTML) string, which explains the sources of data and/or style for the map.
97 | ///
98 | public string? Attribution { get; private set; }
99 |
100 | ///
101 | /// A description of the tileset content.
102 | ///
103 | public string? Description { get; private set; }
104 |
105 | ///
106 | /// Type of tileset: "overlay" or "baselayer".
107 | ///
108 | public string? Type { get; private set; }
109 |
110 | ///
111 | /// The version (revision) of the tileset.
112 | ///
113 | ///
114 | /// Version is a number, according to specification, but actually can be a string in real datasets.
115 | ///
116 | public string? Version { get; private set; }
117 |
118 | ///
119 | /// Lists the layers that appear in the vector tiles and the names and types of the attributes of features that appear in those layers in case of pbf format.
120 | ///
121 | ///
122 | /// See https://github.com/mapbox/mbtiles-spec/blob/master/1.3/spec.md#vector-tileset-metadata
123 | ///
124 | public string? Json { get; private set; }
125 |
126 | private MetadataItem? GetItem(string name) => this.metadata.FirstOrDefault(i => i.Name == name);
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/Src/TileMapService/Controllers/TmsController.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Linq;
3 | using System.Net;
4 | using System.Threading;
5 | using System.Threading.Tasks;
6 |
7 | using Microsoft.AspNetCore.Mvc;
8 |
9 | using TileMapService.Utils;
10 |
11 | using EC = TileMapService.Utils.EntitiesConverter;
12 |
13 | namespace TileMapService.Controllers
14 | {
15 | ///
16 | /// TMS endpoint - serving tiles using Tile Map Service protocol (Tile Map Service Specification).
17 | ///
18 | [Route("tms")]
19 | public class TmsController : ControllerBase
20 | {
21 | private const string Version = "1.0.0";
22 |
23 | private readonly ITileSourceFabric tileSourceFabric;
24 |
25 | public TmsController(ITileSourceFabric tileSourceFabric)
26 | {
27 | this.tileSourceFabric = tileSourceFabric;
28 | }
29 |
30 | [HttpGet("")]
31 | public IActionResult GetRootResource()
32 | {
33 | // TODO: services/root.xml
34 | var capabilities = this.GetCapabilities();
35 | var xmlDoc = new Tms.CapabilitiesUtility(capabilities).GetRootResource();
36 |
37 | return File(EC.XmlDocumentToUTF8ByteArray(xmlDoc), MediaTypeNames.Text.Xml);
38 | }
39 |
40 | [HttpGet(Version)]
41 | public IActionResult GetTileMapService()
42 | {
43 | // TODO: services/tilemapservice.xml
44 | var capabilities = this.GetCapabilities();
45 | var xmlDoc = new Tms.CapabilitiesUtility(capabilities).GetTileMapService();
46 |
47 | return File(EC.XmlDocumentToUTF8ByteArray(xmlDoc), MediaTypeNames.Text.Xml);
48 | }
49 |
50 | [HttpGet(Version + "/{tileset}")]
51 | public IActionResult GetTileMap(string tileset)
52 | {
53 | // TODO: services/basemap.xml
54 | var capabilities = this.GetCapabilities();
55 | var layer = capabilities.Layers.SingleOrDefault(l => l.Identifier == tileset);
56 | if (layer == null)
57 | {
58 | return NotFound(); // TODO: errors in XML format
59 | }
60 | else
61 | {
62 | var xmlDoc = new Tms.CapabilitiesUtility(capabilities).GetTileMap(layer);
63 | return File(EC.XmlDocumentToUTF8ByteArray(xmlDoc), MediaTypeNames.Text.Xml);
64 | }
65 | }
66 |
67 | ///
68 | /// Get tile from tileset with specified coordinates.
69 | ///
70 | /// Tileset (source) name.
71 | /// Tile X coordinate (column).
72 | /// Tile Y coordinate (row), Y axis goes up from the bottom.
73 | /// Tile Z coordinate (zoom level).
74 | /// File extension.
75 | /// Response with tile contents.
76 | [HttpGet(Version + "/{tileset}/{z}/{x}/{y}.{extension}")]
77 | public async Task GetTileAsync(string tileset, int x, int y, int z, string extension, CancellationToken cancellationToken)
78 | {
79 | // TODO: z can be a string, not integer number
80 | if (String.IsNullOrEmpty(tileset) || String.IsNullOrEmpty(extension))
81 | {
82 | return BadRequest();
83 | }
84 |
85 | if (this.tileSourceFabric.Contains(tileset))
86 | {
87 | // TODO: ? convert source format to requested output format
88 | var tileSource = this.tileSourceFabric.Get(tileset);
89 |
90 | if (!WebMercator.IsInsideBBox(x, y, z, tileSource.Configuration.Srs))
91 | {
92 | return ResponseWithNotFoundError("The requested tile is outside the bounding box of the tile map.");
93 | }
94 |
95 | var data = await tileSource.GetTileAsync(x, y, z, cancellationToken);
96 | var result = ResponseHelper.CreateFileResponse(
97 | data,
98 | EC.ExtensionToMediaType(extension),
99 | tileSource.Configuration.ContentType,
100 | this.tileSourceFabric.ServiceProperties.JpegQuality);
101 |
102 | return result != null
103 | ? File(result.FileContents, result.ContentType)
104 | : NotFound();
105 | }
106 | else
107 | {
108 | return NotFound($"Specified tileset '{tileset}' not found");
109 | }
110 | }
111 |
112 | private Tms.Capabilities GetCapabilities()
113 | {
114 | return new Tms.Capabilities
115 | {
116 | BaseUrl = this.BaseUrl,
117 | Layers = EC.SourcesToLayers(this.tileSourceFabric.Sources),
118 | ServiceTitle = this.tileSourceFabric.ServiceProperties.Title,
119 | ServiceAbstract = this.tileSourceFabric.ServiceProperties.Abstract,
120 | };
121 | }
122 |
123 | private string BaseUrl => $"{this.Request.Scheme}://{this.Request.Host}{this.Request.PathBase}";
124 |
125 | private FileContentResult ResponseWithNotFoundError(string message)
126 | {
127 | var xmlDoc = new Tms.TileMapServerError(message).ToXml();
128 | Response.ContentType = MediaTypeNames.Text.XmlUtf8;
129 | Response.StatusCode = (int)HttpStatusCode.NotFound;
130 | return File(EC.XmlDocumentToUTF8ByteArray(xmlDoc), Response.ContentType);
131 | }
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/Src/TileMapService/Wmts/QueryUtility.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Globalization;
3 | using System.Linq;
4 | using System.Runtime.CompilerServices;
5 |
6 | using Microsoft.AspNetCore.Http.Extensions;
7 |
8 | namespace TileMapService.Wmts
9 | {
10 | class QueryUtility
11 | {
12 | private const string WmtsQueryService = "service";
13 | private const string WmtsQueryVersion = "version";
14 | private const string WmtsQueryRequest = "request";
15 | private const string WmtsQueryFormat = "format";
16 |
17 | internal const string WmtsQueryLayer = "layer";
18 | private const string WmtsQueryStyle = "style";
19 | private const string WmtsQueryTilematrixSet = "tilematrixset";
20 |
21 | private const string WmtsQueryTileMatrix = "tilematrix";
22 | private const string WmtsQueryTileCol = "tilecol";
23 | private const string WmtsQueryTileRow = "tilerow";
24 |
25 | public static string GetCapabilitiesKvpUrl(string url)
26 | {
27 | var baseUri = Utils.UrlHelper.GetQueryBase(url);
28 |
29 | var qb = new QueryBuilder
30 | {
31 | { WmtsQueryService, Identifiers.WMTS },
32 | { WmtsQueryRequest, Identifiers.GetCapabilities },
33 | { WmtsQueryVersion, Identifiers.Version100 },
34 | };
35 |
36 | return baseUri + qb.ToQueryString();
37 | }
38 |
39 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
40 | public static string GetTileKvpUrl(
41 | SourceConfiguration configuration,
42 | int x, int y, int z)
43 | {
44 | if (String.IsNullOrWhiteSpace(configuration.Location))
45 | {
46 | throw new ArgumentException("Location must be valid string");
47 | }
48 |
49 | // TODO: choose WMTS query with parameters or ResourceUrl with placeholders
50 | var baseUrl = configuration.Location;
51 | if (IsResourceUrl(baseUrl))
52 | {
53 | return baseUrl
54 | .Replace("{" + WmtsQueryTileCol + "}", x.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)
55 | .Replace("{" + WmtsQueryTileRow + "}", y.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)
56 | .Replace("{" + WmtsQueryTileMatrix + "}", z.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase);
57 | }
58 | else
59 | {
60 | var baseUri = Utils.UrlHelper.GetQueryBase(baseUrl);
61 | var items = Utils.UrlHelper.GetQueryParameters(baseUrl);
62 |
63 | // Layer
64 | var layer = String.Empty;
65 | if (configuration.Wmts != null && !String.IsNullOrWhiteSpace(configuration.Wmts.Layer))
66 | {
67 | layer = configuration.Wmts.Layer;
68 | }
69 | else if (items.Any(kvp => kvp.Key == WmtsQueryLayer))
70 | {
71 | layer = items.First(kvp => kvp.Key == WmtsQueryLayer).Value;
72 | }
73 |
74 | // Style
75 | var style = "normal";
76 | if (configuration.Wmts != null && !String.IsNullOrWhiteSpace(configuration.Wmts.Style))
77 | {
78 | style = configuration.Wmts.Style;
79 | }
80 | else if (items.Any(kvp => kvp.Key == WmtsQueryStyle))
81 | {
82 | style = items.First(kvp => kvp.Key == WmtsQueryStyle).Value;
83 | }
84 |
85 | // TileMatrixSet
86 | var tileMatrixSet = String.Empty;
87 | if (configuration.Wmts != null && !String.IsNullOrWhiteSpace(configuration.Wmts.TileMatrixSet))
88 | {
89 | tileMatrixSet = configuration.Wmts.TileMatrixSet;
90 | }
91 | else if (items.Any(kvp => kvp.Key == WmtsQueryTilematrixSet))
92 | {
93 | tileMatrixSet = items.First(kvp => kvp.Key == WmtsQueryTilematrixSet).Value;
94 | }
95 |
96 | // Format
97 | var format = MediaTypeNames.Image.Png;
98 | if (!String.IsNullOrWhiteSpace(configuration.ContentType))
99 | {
100 | format = configuration.ContentType;
101 | }
102 |
103 | var qb = new QueryBuilder
104 | {
105 | { WmtsQueryService, Identifiers.WMTS },
106 | { WmtsQueryRequest, Identifiers.GetTile },
107 | { WmtsQueryVersion, Identifiers.Version100 },
108 | { WmtsQueryLayer, layer },
109 | { WmtsQueryStyle, style },
110 | { WmtsQueryTilematrixSet, tileMatrixSet },
111 | { WmtsQueryFormat, format },
112 | { WmtsQueryTileMatrix, z.ToString(CultureInfo.InvariantCulture) },
113 | { WmtsQueryTileCol, x.ToString(CultureInfo.InvariantCulture) },
114 | { WmtsQueryTileRow, y.ToString(CultureInfo.InvariantCulture) },
115 | };
116 |
117 | return baseUri + qb.ToQueryString();
118 | }
119 | }
120 |
121 | private static bool IsResourceUrl(string url)
122 | {
123 | return
124 | (url.IndexOf("{" + WmtsQueryTileMatrix + "}", StringComparison.OrdinalIgnoreCase) > 0) &&
125 | (url.IndexOf("{" + WmtsQueryTileRow + "}", StringComparison.OrdinalIgnoreCase) > 0) &&
126 | (url.IndexOf("{" + WmtsQueryTileCol + "}", StringComparison.OrdinalIgnoreCase) > 0);
127 | }
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/Src/TileMapService/Controllers/XyzController.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading;
3 | using System.Threading.Tasks;
4 |
5 | using Microsoft.AspNetCore.Mvc;
6 |
7 | using TileMapService.Utils;
8 |
9 | using EC = TileMapService.Utils.EntitiesConverter;
10 |
11 | namespace TileMapService.Controllers
12 | {
13 | ///
14 | /// XYZ endpoint - serving tiles using minimalistic REST API, similar to OSM, Google Maps.
15 | ///
16 | [Route("xyz")]
17 | public class XyzController : ControllerBase
18 | {
19 | private readonly ITileSourceFabric tileSourceFabric;
20 |
21 | public XyzController(ITileSourceFabric tileSourceFabric)
22 | {
23 | this.tileSourceFabric = tileSourceFabric;
24 | }
25 |
26 | ///
27 | /// Get tile from tileset with specified coordinates.
28 | /// Url template: xyz/{tileset}/?x={x}&y={y}&z={z}
29 | ///
30 | /// Tileset identifier.
31 | /// Tile X coordinate (column).
32 | /// Tile Y coordinate (row), Y axis goes down from the top.
33 | /// Tile Z coordinate (zoom level).
34 | /// Response with tile contents.
35 | [HttpGet("{id}")]
36 | public async Task GetTileWithUrlQueryParametersAsync(string id, int x, int y, int z, CancellationToken cancellationToken)
37 | {
38 | if (String.IsNullOrEmpty(id))
39 | {
40 | return BadRequest();
41 | }
42 |
43 | if (!this.tileSourceFabric.Contains(id))
44 | {
45 | return NotFound($"Specified tileset '{id}' not found");
46 | }
47 |
48 | var tileSource = this.tileSourceFabric.Get(id);
49 | var mediaType = tileSource.Configuration.ContentType;
50 |
51 | return await this.GetTileAsync(id, x, y, z, mediaType, this.tileSourceFabric.ServiceProperties.JpegQuality, cancellationToken);
52 | }
53 |
54 | ///
55 | /// Get tile from tileset with specified coordinates.
56 | /// Url template: xyz/{tileset}/{z}/{x}/{y}.{extension}
57 | ///
58 | /// Tileset identifier.
59 | /// Tile X coordinate (column).
60 | /// Tile Y coordinate (row), Y axis goes down from the top.
61 | /// Tile Z coordinate (zoom level).
62 | /// File extension.
63 | /// Response with tile contents.
64 | [HttpGet("{id}/{z}/{x}/{y}.{extension}")]
65 | public async Task GetTileWithUrlPathAsync(string id, int x, int y, int z, string extension, CancellationToken cancellationToken)
66 | {
67 | if (String.IsNullOrEmpty(id) || String.IsNullOrEmpty(extension))
68 | {
69 | return BadRequest();
70 | }
71 |
72 | if (!this.tileSourceFabric.Contains(id))
73 | {
74 | return NotFound($"Specified tileset '{id}' not found.");
75 | }
76 |
77 | return await this.GetTileAsync(id, x, y, z, EC.ExtensionToMediaType(extension), this.tileSourceFabric.ServiceProperties.JpegQuality, cancellationToken);
78 | }
79 |
80 | ///
81 | /// Get tile from tileset with specified coordinates.
82 | /// Url template: xyz/{tileset}/{z}/{x}/{y}
83 | ///
84 | /// Tileset identifier.
85 | /// Tile X coordinate (column).
86 | /// Tile Y coordinate (row), Y axis goes down from the top.
87 | /// Tile Z coordinate (zoom level).
88 | /// Response with tile contents.
89 | [HttpGet("{tileset}/{z}/{x}/{y}")]
90 | public async Task GetTileWithUrlPathAsync(string id, int x, int y, int z, CancellationToken cancellationToken)
91 | {
92 | if (String.IsNullOrEmpty(id))
93 | {
94 | return BadRequest();
95 | }
96 |
97 | if (!this.tileSourceFabric.Contains(id))
98 | {
99 | return NotFound($"Specified tileset '{id}' not found.");
100 | }
101 |
102 | var tileSource = this.tileSourceFabric.Get(id);
103 | var mediaType = tileSource.Configuration.ContentType;
104 |
105 | return await this.GetTileAsync(id, x, y, z, mediaType, this.tileSourceFabric.ServiceProperties.JpegQuality, cancellationToken);
106 | }
107 |
108 | private async Task GetTileAsync(string id, int x, int y, int z, string? mediaType, int quality, CancellationToken cancellationToken)
109 | {
110 | var tileSource = this.tileSourceFabric.Get(id);
111 |
112 | if (!WebMercator.IsInsideBBox(x, y, z, tileSource.Configuration.Srs))
113 | {
114 | return NotFound();
115 | }
116 |
117 | if (String.IsNullOrEmpty(mediaType))
118 | {
119 | mediaType = MediaTypeNames.Image.Png;
120 | }
121 |
122 | var data = await tileSource.GetTileAsync(x, WebMercator.FlipYCoordinate(y, z), z, cancellationToken);
123 | var result = ResponseHelper.CreateFileResponse(
124 | data,
125 | mediaType,
126 | tileSource.Configuration.ContentType,
127 | quality);
128 |
129 | return result != null
130 | ? File(result.FileContents, result.ContentType)
131 | : NotFound();
132 | }
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/Src/TileMapService/wwwroot/index.js:
--------------------------------------------------------------------------------
1 | (() => {
2 |
3 | fetch('/api/sources')
4 | .then((response) => response.json())
5 | .then((sources) => {
6 | createOlMap3857('mapOL3857', sources);
7 | createOlMap4326('mapOL4326', sources);
8 | });
9 |
10 | // TODO: WMS layers
11 |
12 | const createOlMap3857 = (target, sources) => {
13 | const rasterTileLayers = filterRasterSources(sources, 'EPSG:3857')
14 | .map((s) => new ol.layer.Tile({
15 | title: s.title,
16 | visible: false,
17 | type: 'base',
18 | source: new ol.source.XYZ({
19 | url: '/xyz/' + s.id + '/?x={x}&y={y}&z={z}',
20 | attributions: s.attribution
21 | }),
22 | }));
23 |
24 | // https://openlayers.org/workshop/en/vectortile/map.html
25 | const vectorTileLayers = filterVectorSources(sources, 'EPSG:3857')
26 | .map((s) => new ol.layer.VectorTile({
27 | title: s.title,
28 | visible: false,
29 | type: 'overlay',
30 | source: new ol.source.VectorTile({
31 | attributions: s.attribution,
32 | format: new ol.format.MVT(),
33 | url: '/xyz/' + s.id + '/?x={x}&y={y}&z={z}',
34 | }),
35 | style: new ol.style.Style({
36 | fill: new ol.style.Fill({
37 | color: 'rgba(255, 255, 255, 0.1)',
38 | }),
39 | stroke: new ol.style.Stroke({
40 | color: '#319FD3',
41 | width: 2,
42 | }),
43 | image: new ol.style.Circle({
44 | radius: 6,
45 | stroke: new ol.style.Stroke({
46 | color: 'black',
47 | width: 1
48 | }),
49 | fill: new ol.style.Fill({ color: 'rgba(255, 0, 0, 0.5)' }),
50 | }),
51 | }),
52 | }));
53 |
54 | const debugLayer = new ol.layer.Tile({
55 | source: new ol.source.TileDebug({
56 | template: 'z:{z} x:{x} y:{y}',
57 | zDirection: 1,
58 | }),
59 | });
60 |
61 | const allLayers = [].concat(rasterTileLayers, vectorTileLayers, [debugLayer]);
62 |
63 | createOlMap(target, 'EPSG:3857', allLayers);
64 | }
65 |
66 | const createOlMap4326 = (target, sources) => {
67 | fetch('/wmts/?SERVICE=WMTS&REQUEST=GetCapabilities')
68 | .then((response) => response.text())
69 | .then((text) => {
70 | const parser = new ol.format.WMTSCapabilities();
71 | const result = parser.read(text);
72 |
73 | const tileLayers = filterRasterSources(sources, 'EPSG:4326')
74 | .map((s) => {
75 | const options = ol.source.WMTS.optionsFromCapabilities(result, {
76 | layer: s.id,
77 | matrixSet: 'EPSG:4326',
78 | crossOrigin: true,
79 | });
80 |
81 | return new ol.layer.Tile({
82 | title: s.title,
83 | visible: false,
84 | type: 'base',
85 | source: new ol.source.WMTS(options),
86 | opacity: 0.5,
87 | });
88 |
89 | // TODO: WMS with EPSG:4326
90 | ////return new ol.layer.Image({
91 | //// title: s.title,
92 | //// visible: false,
93 | //// type: 'base',
94 | //// source: new ol.source.ImageWMS({
95 | //// url: '/wms',
96 | //// params: { 'LAYERS': s.id },
97 | //// ratio: 1
98 | //// }),
99 | ////});
100 | });
101 |
102 | createOlMap(target, 'EPSG:4326', tileLayers);
103 | });
104 | }
105 |
106 | const createOlMap = (target, projection, layers) => {
107 | const map = new ol.Map({
108 | target: target,
109 | controls: ol.control.defaults.defaults({
110 | zoom: true,
111 | attribution: true,
112 | rotate: false,
113 | }),
114 | layers: layers,
115 | interactions: ol.interaction.defaults.defaults({
116 | zoomDelta: 1,
117 | zoomDuration: 0,
118 | }),
119 | view: new ol.View({
120 | projection: projection,
121 | center: projection === 'EPSG:3857'
122 | ? ol.proj.fromLonLat([0, 30])
123 | : [0, 30],
124 | zoom: 0,
125 | }),
126 | });
127 |
128 | layers[0].setVisible(true);
129 |
130 | // https://github.com/walkermatt/ol-layerswitcher
131 | map.addControl(new ol.control.LayerSwitcher({
132 | startActive: true,
133 | activationMode: 'click',
134 | reverse: false,
135 | tipLabel: 'Layers',
136 | collapseLabel: '',
137 | }));
138 | }
139 |
140 | const filterRasterSources = (list, srs) =>
141 | list.filter((s) => s.format !== 'mvt' && s.format !== 'pbf' && s.srs === srs);
142 |
143 | const filterVectorSources = (list, srs) =>
144 | list.filter((s) => (s.format === 'mvt' || s.format === 'pbf') && s.srs === srs);
145 |
146 | })();
147 |
--------------------------------------------------------------------------------
/Src/TileMapService/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "Kestrel": {
3 | "Endpoints": {
4 | "Http": {
5 | "Url": "http://localhost:5000"
6 | },
7 | "Https": {
8 | "Url": "https://localhost:5001"
9 | }
10 | }
11 | },
12 | "Service": {
13 | "title": "Tile Service TMS/WMTS/WMS",
14 | "abstract": "Tile Service project for .NET5 / .NET8",
15 | "keywords": ".net5,.net8,ogc,tile,tms,wmts,service",
16 | "jpegQuality": 85
17 | },
18 | "Sources": [
19 | {
20 | "type": "mbtiles",
21 | "id": "world-countries",
22 | "title": "World Countries",
23 | "abstract": "World Countries map",
24 | "attribution": "Esri",
25 | "location": "D:\\SQLDB\\SQLite\\world_countries.mbtiles"
26 | },
27 | {
28 | "type": "mbtiles",
29 | "id": "countries-raster",
30 | "title": "Countries",
31 | "abstract": "Countries map",
32 | "location": "D:\\SQLDB\\SQLite\\countries-raster.mbtiles"
33 | },
34 | {
35 | "type": "mbtiles",
36 | "title": "Countries",
37 | "abstract": "Satellite low resolution world map",
38 | "location": "D:\\SQLDB\\SQLite\\satellite-lowres-v1.2-z0-z5.mbtiles"
39 | },
40 | {
41 | "type": "mbtiles",
42 | "id": "caspiansea",
43 | "title": "Caspian Sea",
44 | "location": "D:\\SQLDB\\MBTiles\\CaspianSea.mbtiles"
45 | },
46 | {
47 | "type": "mbtiles",
48 | "id": "countries-pbf",
49 | "location": "D:\\SQLDB\\MBTiles\\countries.mbtiles"
50 | },
51 | {
52 | "type": "mbtiles",
53 | "id": "zurich-pbf",
54 | "abstract": "Zurich vector tiles map",
55 | "location": "D:\\SQLDB\\MBTiles\\zurich.mbtiles"
56 | },
57 | {
58 | "type": "tms",
59 | "id": "countries-ol-pbf",
60 | "title": "Countries (PBF)",
61 | "format": "pbf",
62 | "srs": "EPSG:3857",
63 | "abstract": "vector tiles",
64 | "location": "https://ahocevar.com/geoserver/gwc/service/tms/1.0.0/ne:ne_10m_admin_0_countries@EPSG%3A900913@pbf/{z}/{x}/{-y}.pbf"
65 | },
66 | {
67 | "type": "postgis",
68 | "id": "owm-cities",
69 | "title": "OWM Cities (MVT from PostGIS)",
70 | "abstract": "MVT tiles from PostGIS database",
71 | "location": "Server=127.0.0.1;Port=5432;Database=postgistest;User Id=reader;Password=reader;CommandTimeout=30;Timeout=15;",
72 | "postgis": {
73 | "table": "owm_cities",
74 | "geometry": "geom",
75 | "fields": "id, city_name"
76 | }
77 | },
78 | {
79 | "type": "file",
80 | "id": "world-countries-fs",
81 | "format": "png",
82 | "maxzoom": 3,
83 | "title": "World Countries (FS)",
84 | "attribution": "Esri",
85 | "location": "D:\\SQLDB\\MapData\\WorldCountriesFS\\{z}\\{x}\\{y}.png",
86 | "tms": false
87 | },
88 | {
89 | "type": "xyz",
90 | "id": "osm",
91 | "format": "png",
92 | "title": "OSM",
93 | "attribution": "OSM",
94 | "location": "https://tile.openstreetmap.de/{z}/{x}/{y}.png"
95 | },
96 | {
97 | "type": "xyz",
98 | "id": "arcgisonline-NatGeo",
99 | "format": "jpg",
100 | "title": "NatGeo World Map",
101 | "abstract": "NatGeo World Map",
102 | "location": "http://services.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{Z}/{Y}/{X}"
103 | },
104 | {
105 | "type": "tms",
106 | "id": "",
107 | "format": "jpg",
108 | "title": "tms-demo",
109 | "location": "https://tile.geobretagne.fr/gwc02/service/tms/1.0.0/satellite@EPSG%3A3857@jpeg"
110 | },
111 | {
112 | "type": "wmts",
113 | "id": "arcgisonline-wmts-demo",
114 | "format": "png",
115 | "title": "ArcGIS Online WMTS",
116 | "attribution": "Esri",
117 | "location": "https://services.arcgisonline.com/arcgis/rest/services/World_Topo_Map/MapServer/WMTS/?layer=World_Topo_Map&style=default&tilematrixset=EPSG:3857"
118 | },
119 | {
120 | "type": "wmts",
121 | "id": "",
122 | "format": "png",
123 | "title": "basemap.at WMTS bmaphidpi",
124 | "location": "https://maps.wien.gv.at/basemap/bmaphidpi/normal/google3857/{TileMatrix}/{TileRow}/{TileCol}.jpeg",
125 | "cache": {
126 | "type": "mbtiles",
127 | "dbfile": "D:\\SQLDB\\MBTiles\\basemap.at-bmaphidpi.mbtiles"
128 | },
129 | "wmts": {
130 | "capabilitiesurl": "https://basemap.at/wmts/1.0.0/WMTSCapabilities.xml",
131 | "layer": "bmaphidpi",
132 | "style": "normal",
133 | "tilematrixset": "google3857"
134 | }
135 | },
136 | {
137 | "type": "wms",
138 | "id": "wms-with-caching",
139 | "format": "png",
140 | "title": "WMS with Caching",
141 | "location": "http://localhost:8088/cgi-bin/mapserv.exe?map=c:\\ms4w\\apps\\wms\\wms-local.map&VERSION=1.1.1&SRS=EPSG:3857&TRANSPARENT=TRUE&FORMAT=image/png&layers=power-lines",
142 | "cache": {
143 | "type": "mbtiles",
144 | "dbfile": "D:\\SQLDB\\SQLite\\tileserver-cache.mbtiles"
145 | }
146 | },
147 | {
148 | "type": "tms",
149 | "id": "mapcache-geodetic",
150 | "srs": "EPSG:4326",
151 | "maxzoom": 5,
152 | "format": "png",
153 | "title": "mapcache-geodetic",
154 | "location": "http://localhost:8088/mapcache/tms/1.0.0/test@WGS84"
155 | },
156 | {
157 | "type": "geotiff",
158 | "id": "geotiff",
159 | "minzoom": 16,
160 | "maxzoom": 24,
161 | "title": "geotiff",
162 | "abstract": "Tiles from single GeoTIFF image",
163 | "location": "d:\\SQLDB\\GeoTIFF\\4543a14c-2c46-4eb8-9769-01890133c064-4326.tif"
164 | },
165 | {
166 | "type": "geotiff",
167 | "id": "geotiff2",
168 | "minzoom": 16,
169 | "maxzoom": 24,
170 | "title": "geotiff2",
171 | "abstract": "Tiles from single GeoTIFF image",
172 | "location": "d:\\SQLDB\\GeoTIFF\\longxi.tif"
173 | }
174 | ]
175 | }
176 |
--------------------------------------------------------------------------------
/Src/TileMapService/wwwroot/ol-layerswitcher.css:
--------------------------------------------------------------------------------
1 | .layer-switcher {
2 | position: absolute;
3 | top: 3.5em;
4 | right: 0.5em;
5 | text-align: left;
6 | }
7 |
8 | .layer-switcher .panel {
9 | margin: 0;
10 | border: 4px solid #eee;
11 | border-radius: 4px;
12 | background-color: white;
13 | display: none;
14 | max-height: inherit;
15 | height: 100%;
16 | box-sizing: border-box;
17 | overflow-y: auto;
18 | }
19 |
20 | .layer-switcher button {
21 | float: right;
22 | z-index: 1;
23 | width: 38px;
24 | height: 38px;
25 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAACE1BMVEX///8A//8AgICA//8AVVVAQID///8rVVVJtttgv98nTmJ2xNgkW1ttyNsmWWZmzNZYxM4gWGgeU2JmzNNr0N1Rwc0eU2VXxdEhV2JqytQeVmMhVmNoydUfVGUgVGQfVGQfVmVqy9hqy9dWw9AfVWRpydVry9YhVmMgVGNUw9BrytchVWRexdGw294gVWQgVmUhVWPd4N6HoaZsy9cfVmQgVGRrytZsy9cgVWQgVWMgVWRsy9YfVWNsy9YgVWVty9YgVWVry9UgVWRsy9Zsy9UfVWRsy9YgVWVty9YgVWRty9Vsy9aM09sgVWRTws/AzM0gVWRtzNYgVWRuy9Zsy9cgVWRGcHxty9bb5ORbxdEgVWRty9bn6OZTws9mydRfxtLX3Nva5eRix9NFcXxOd4JPeINQeIMiVmVUws9Vws9Vw9BXw9BYxNBaxNBbxNBcxdJexdElWWgmWmhjyNRlx9IqXGtoipNpytVqytVryNNrytZsjZUuX210k5t1y9R2zNR3y9V4lp57zth9zdaAnKOGoaeK0NiNpquV09mesrag1tuitbmj1tuj19uktrqr2d2svcCu2d2xwMO63N+7x8nA3uDC3uDFz9DK4eHL4eLN4eIyYnDX5OM5Z3Tb397e4uDf4uHf5uXi5ePi5+Xj5+Xk5+Xm5+Xm6OY6aHXQ19fT4+NfhI1Ww89gx9Nhx9Nsy9ZWw9Dpj2abAAAAWnRSTlMAAQICAwQEBgcIDQ0ODhQZGiAiIyYpKywvNTs+QklPUlNUWWJjaGt0dnd+hIWFh4mNjZCSm6CpsbW2t7nDzNDT1dje5efr7PHy9PT29/j4+Pn5+vr8/f39/f6DPtKwAAABTklEQVR4Xr3QVWPbMBSAUTVFZmZmhhSXMjNvkhwqMzMzMzPDeD+xASvObKePPa+ffHVl8PlsnE0+qPpBuQjVJjno6pZpSKXYl7/bZyFaQxhf98hHDKEppwdWIW1frFnrxSOWHFfWesSEWC6R/P4zOFrix3TzDFLlXRTR8c0fEEJ1/itpo7SVO9Jdr1DVxZ0USyjZsEY5vZfiiAC0UoTGOrm9PZLuRl8X+Dq1HQtoFbJZbv61i+Poblh/97TC7n0neCcK0ETNUrz1/xPHf+DNAW9Ac6t8O8WH3Vp98f5lCaYKAOFZMLyHL4Y0fe319idMNgMMp+zWVSybUed/+/h7I4wRAG1W6XDy4XmjR9HnzvDRZXUAYDFOhC1S/Hh+fIXxen+eO+AKqbs+wAo30zDTDvDxKoJN88sjUzDFAvBzEUGFsnADoIvAJzoh2BZ8sner+Ke/vwECuQAAAABJRU5ErkJggg==')
26 | /*logo.png*/;
27 | background-repeat: no-repeat;
28 | background-position: 2px;
29 | background-color: white;
30 | color: black;
31 | border: none;
32 | }
33 |
34 | .layer-switcher button:focus,
35 | .layer-switcher button:hover {
36 | background-color: white;
37 | }
38 |
39 | .layer-switcher.shown {
40 | overflow-y: hidden;
41 | display: flex;
42 | flex-direction: column;
43 | max-height: calc(100% - 5.5em);
44 | }
45 |
46 | .layer-switcher.shown.ol-control {
47 | background-color: transparent;
48 | }
49 |
50 | .layer-switcher.shown.ol-control:hover {
51 | background-color: transparent;
52 | }
53 | .layer-switcher.shown .panel {
54 | display: block;
55 | }
56 |
57 | .layer-switcher.shown button {
58 | display: none;
59 | }
60 |
61 | .layer-switcher.shown.layer-switcher-activation-mode-click > button {
62 | display: block;
63 | background-image: unset;
64 | right: 2px;
65 | position: absolute;
66 | background-color: #eee;
67 | margin: 1px;
68 | }
69 |
70 | .layer-switcher.shown button:focus,
71 | .layer-switcher.shown button:hover {
72 | background-color: #fafafa;
73 | }
74 |
75 | .layer-switcher ul {
76 | list-style: none;
77 | margin: 1.6em 0.4em;
78 | padding-left: 0;
79 | }
80 | .layer-switcher ul ul {
81 | padding-left: 1.2em;
82 | margin: 0.1em 0 0 0;
83 | }
84 | .layer-switcher li.group + li.group {
85 | margin-top: 0.4em;
86 | }
87 | .layer-switcher li.group + li.layer-switcher-base-group {
88 | }
89 |
90 | .layer-switcher li.group > label {
91 | font-weight: bold;
92 | }
93 |
94 | .layer-switcher.layer-switcher-group-select-style-none li.group > label {
95 | padding-left: 1.2em;
96 | }
97 |
98 | .layer-switcher li {
99 | position: relative;
100 | margin-top: 0.3em;
101 | }
102 |
103 | .layer-switcher li input {
104 | position: absolute;
105 | left: 1.2em;
106 | height: 1em;
107 | width: 1em;
108 | font-size: 1em;
109 | }
110 | .layer-switcher li label {
111 | padding-left: 2.7em;
112 | padding-right: 1.2em;
113 | display: inline-block;
114 | margin-top: 1px;
115 | }
116 |
117 | .layer-switcher label.disabled {
118 | opacity: 0.4;
119 | }
120 |
121 | .layer-switcher input {
122 | margin: 0px;
123 | }
124 |
125 | .layer-switcher.touch ::-webkit-scrollbar {
126 | width: 4px;
127 | }
128 |
129 | .layer-switcher.touch ::-webkit-scrollbar-track {
130 | -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
131 | border-radius: 10px;
132 | }
133 |
134 | .layer-switcher.touch ::-webkit-scrollbar-thumb {
135 | border-radius: 10px;
136 | -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.5);
137 | }
138 |
139 | li.layer-switcher-base-group > label {
140 | padding-left: 1.2em;
141 | }
142 |
143 | .layer-switcher .group button {
144 | position: absolute;
145 | left: 0;
146 | display: inline-block;
147 | vertical-align: top;
148 | float: none;
149 | font-size: 1em;
150 | width: 1em;
151 | height: 1em;
152 | margin: 0;
153 | background-position: center 2px;
154 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAW0lEQVR4nGNgGAWMyBwXFxcGBgaGeii3EU0tXHzPnj1wQRYsihqQ+I0ExDEMQAYNONgoAN0AmMkNaDSyQSheY8JiaCMOGzE04zIAmyFYNTMw4A+DRhzsUUBtAADw4BCeIZkGdwAAAABJRU5ErkJggg==');
155 | -webkit-transition: -webkit-transform 0.2s ease-in-out;
156 | -ms-transition: -ms-transform 0.2s ease-in-out;
157 | transition: transform 0.2s ease-in-out;
158 | }
159 |
160 | .layer-switcher .group.layer-switcher-close button {
161 | transform: rotate(-90deg);
162 | -webkit-transform: rotate(-90deg);
163 | }
164 |
165 | .layer-switcher .group.layer-switcher-fold.layer-switcher-close > ul {
166 | overflow: hidden;
167 | height: 0;
168 | }
169 |
170 | /*layerswitcher on the right*/
171 | .layer-switcher.shown.layer-switcher-activation-mode-click {
172 | padding-left: 34px;
173 | }
174 | .layer-switcher.shown.layer-switcher-activation-mode-click > button {
175 | left: 0;
176 | border-right: 0;
177 | }
178 |
179 | /*layerswitcher on the left*/
180 | /*
181 | .layer-switcher.shown.layer-switcher-activation-mode-click {
182 | padding-right: 34px;
183 | }
184 | .layer-switcher.shown.layer-switcher-activation-mode-click > button {
185 | right: 0;
186 | border-left: 0;
187 | }
188 | */
189 |
--------------------------------------------------------------------------------
/Src/TileMapService/TileSources/PostGISTileSource.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading;
3 | using System.Threading.Tasks;
4 |
5 | using Npgsql;
6 |
7 | namespace TileMapService.TileSources
8 | {
9 | ///
10 | /// Represents tile source with vector tiles (MVT) from PostgreSQL database with PostGIS extension.
11 | ///
12 | class PostGisTileSource : ITileSource
13 | {
14 | private SourceConfiguration configuration;
15 |
16 | private readonly string connectionString;
17 |
18 | public PostGisTileSource(SourceConfiguration configuration)
19 | {
20 | if (String.IsNullOrEmpty(configuration.Id))
21 | {
22 | throw new ArgumentException("Source identifier is null or empty string.");
23 | }
24 |
25 | if (String.IsNullOrEmpty(configuration.Location))
26 | {
27 | throw new ArgumentException("Source location is null or empty string.");
28 | }
29 |
30 | this.connectionString = configuration.Location;
31 | this.configuration = configuration; // Will be changed later in InitAsync
32 | }
33 |
34 | #region ITileSource implementation
35 |
36 | Task ITileSource.InitAsync()
37 | {
38 | var title = String.IsNullOrEmpty(this.configuration.Title) ?
39 | this.configuration.Id :
40 | this.configuration.Title;
41 |
42 | var minZoom = this.configuration.MinZoom ?? 0;
43 | var maxZoom = this.configuration.MaxZoom ?? 20;
44 |
45 | // Re-create configuration
46 | this.configuration = new SourceConfiguration
47 | {
48 | Id = this.configuration.Id,
49 | Type = this.configuration.Type,
50 | Format = ImageFormats.MapboxVectorTile,
51 | Title = title,
52 | Abstract = this.configuration.Abstract,
53 | Attribution = this.configuration.Attribution,
54 | Tms = this.configuration.Tms ?? true,
55 | Srs = Utils.SrsCodes.EPSG3857,
56 | Location = this.configuration.Location,
57 | ContentType = Utils.EntitiesConverter.TileFormatToContentType(ImageFormats.MapboxVectorTile),
58 | MinZoom = minZoom,
59 | MaxZoom = maxZoom,
60 | GeographicalBounds = null,
61 | TileWidth = Utils.WebMercator.DefaultTileWidth,
62 | TileHeight = Utils.WebMercator.DefaultTileHeight,
63 | Cache = null, // TODO: ? possible to implement
64 | PostGis = configuration.PostGis,
65 | };
66 |
67 | return Task.CompletedTask;
68 | }
69 |
70 | async Task ITileSource.GetTileAsync(int x, int y, int z, CancellationToken cancellationToken)
71 | {
72 | if (z < this.configuration.MinZoom ||
73 | z > this.configuration.MaxZoom ||
74 | x < 0 ||
75 | y < 0 ||
76 | x > Utils.WebMercator.TileCount(z) ||
77 | y > Utils.WebMercator.TileCount(z))
78 | {
79 | return null;
80 | }
81 | else
82 | {
83 | var postgis = this.configuration.PostGis ?? throw new InvalidOperationException("PostGIS connection options must be defined.");
84 |
85 | if (String.IsNullOrWhiteSpace(postgis.Table))
86 | {
87 | throw new InvalidOperationException("Table name must be defined.");
88 | }
89 |
90 | if (String.IsNullOrWhiteSpace(postgis.Geometry))
91 | {
92 | throw new InvalidOperationException("Table geometry field must be defined.");
93 | }
94 |
95 | var fields = !String.IsNullOrWhiteSpace(postgis.Fields)
96 | ? postgis.Fields.Split(FieldsSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
97 | : null;
98 |
99 | return await this.ReadPostGisVectorTileAsync(
100 | postgis.Table,
101 | postgis.Geometry,
102 | fields,
103 | x,
104 | Utils.WebMercator.FlipYCoordinate(y, z),
105 | z,
106 | cancellationToken).ConfigureAwait(false);
107 | }
108 | }
109 |
110 | SourceConfiguration ITileSource.Configuration => this.configuration;
111 |
112 | private static readonly string[] FieldsSeparator = new string[] { "," };
113 |
114 | #endregion
115 |
116 | private async Task ReadPostGisVectorTileAsync(
117 | string tableName,
118 | string geometry,
119 | string[]? fields,
120 | int x,
121 | int y,
122 | int z,
123 | CancellationToken cancellationToken = default)
124 | {
125 | // https://blog.crunchydata.com/blog/dynamic-vector-tiles-from-postgis
126 | // https://postgis.net/docs/ST_AsMVT.html
127 | // https://postgis.net/docs/ST_AsMVTGeom.html
128 |
129 | var commandText = $@"
130 | WITH mvtgeom AS
131 | (
132 | SELECT ST_AsMVTGeom({geometry}, ST_TileEnvelope({z},{x},{y})) AS geom
133 | {(fields != null && fields.Length > 0 ? ", " + String.Join(',', fields) : String.Empty)}
134 | FROM ""{tableName}""
135 | WHERE ST_Intersects({geometry}, ST_TileEnvelope({z},{x},{y}))
136 | )
137 | SELECT ST_AsMVT(mvtgeom.*, '{this.configuration.Id}')
138 | FROM mvtgeom";
139 |
140 | // TODO: ? other SRS, using ST_Transform if needed
141 | using var connection = new NpgsqlConnection(this.connectionString);
142 | await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
143 | using var command = new NpgsqlCommand(commandText, connection);
144 | using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
145 |
146 | return await reader.ReadAsync(cancellationToken).ConfigureAwait(false)
147 | ? reader[0] as byte[]
148 | : null;
149 | }
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/Src/TileMapService/TileSources/MBTilesTileSource.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using System.IO.Compression;
4 | using System.Threading;
5 | using System.Threading.Tasks;
6 |
7 | namespace TileMapService.TileSources
8 | {
9 | ///
10 | /// Represents tile source with tiles stored in MBTiles file (SQLite database).
11 | ///
12 | ///
13 | /// Supports only Spherical Mercator (EPSG:3857) tile grid and TMS tiling scheme (Y axis is going up).
14 | /// See MBTiles 1.3 specification: https://github.com/mapbox/mbtiles-spec/blob/master/1.3/spec.md
15 | ///
16 | class MBTilesTileSource : ITileSource
17 | {
18 | private SourceConfiguration configuration;
19 |
20 | private MBTiles.Repository? repository;
21 |
22 | public MBTilesTileSource(SourceConfiguration configuration)
23 | {
24 | if (String.IsNullOrEmpty(configuration.Id))
25 | {
26 | throw new ArgumentException("Source identifier is null or empty string");
27 | }
28 |
29 | if (String.IsNullOrEmpty(configuration.Location))
30 | {
31 | throw new ArgumentException("Source location is null or empty string");
32 | }
33 |
34 | this.configuration = configuration; // Will be changed later in InitAsync
35 | }
36 |
37 | #region ITileSource implementation
38 |
39 | Task ITileSource.InitAsync()
40 | {
41 | // Configuration values priority:
42 | // 1. Default values for MBTiles source type.
43 | // 2. Actual values (MBTiles metadata table values).
44 | // 3. Values from configuration file - overrides given above, if provided.
45 |
46 | if (String.IsNullOrEmpty(this.configuration.Location))
47 | {
48 | throw new InvalidOperationException("configuration.Location is null or empty");
49 | }
50 |
51 | this.repository = new MBTiles.Repository(configuration.Location, false);
52 | var metadata = new MBTiles.Metadata(this.repository.ReadMetadata());
53 |
54 | var title = String.IsNullOrEmpty(this.configuration.Title) ?
55 | (!String.IsNullOrEmpty(metadata.Name) ? metadata.Name : this.configuration.Id) :
56 | this.configuration.Title;
57 |
58 | var format = String.IsNullOrEmpty(this.configuration.Format) ?
59 | (!String.IsNullOrEmpty(metadata.Format) ? metadata.Format : ImageFormats.Png) :
60 | this.configuration.Format;
61 |
62 | // Get tile width and height from first tile, for raster formats
63 | var tileWidth = Utils.WebMercator.DefaultTileWidth;
64 | var tileHeight = Utils.WebMercator.DefaultTileHeight;
65 | var firstTile = this.repository.ReadFirstTile();
66 | if (firstTile != null && (format == ImageFormats.Png || format == ImageFormats.Jpeg))
67 | {
68 | var size = Utils.ImageHelper.GetImageSize(firstTile);
69 | if (size.HasValue)
70 | {
71 | tileWidth = size.Value.Width;
72 | tileHeight = size.Value.Height;
73 | }
74 | }
75 |
76 | var minZoom = this.configuration.MinZoom ?? metadata.MinZoom ?? null;
77 | var maxZoom = this.configuration.MaxZoom ?? metadata.MaxZoom ?? null;
78 | if (minZoom == null || maxZoom == null)
79 | {
80 | var zoomRange = this.repository.GetZoomLevelRange();
81 | if (zoomRange.HasValue)
82 | {
83 | minZoom = zoomRange.Value.Min;
84 | maxZoom = zoomRange.Value.Max;
85 | }
86 | else
87 | {
88 | minZoom = 0;
89 | maxZoom = 20;
90 | }
91 | }
92 |
93 | // Re-create configuration
94 | this.configuration = new SourceConfiguration
95 | {
96 | Id = this.configuration.Id,
97 | Type = this.configuration.Type,
98 | Format = format,
99 | Title = title,
100 | Abstract = this.configuration.Abstract,
101 | Attribution = String.IsNullOrEmpty(this.configuration.Attribution) ? metadata.Attribution : null,
102 | Tms = this.configuration.Tms ?? true, // Default true for the MBTiles, following the Tile Map Service Specification.
103 | Srs = Utils.SrsCodes.EPSG3857, // MBTiles supports only Spherical Mercator tile grid
104 | Location = this.configuration.Location,
105 | ContentType = Utils.EntitiesConverter.TileFormatToContentType(format),
106 | MinZoom = minZoom,
107 | MaxZoom = maxZoom,
108 | GeographicalBounds = metadata.Bounds, // Can be null, if no corresponding record in 'metadata' table
109 | TileWidth = tileWidth,
110 | TileHeight = tileHeight,
111 | Cache = null, // Not used for MBTiles source
112 | };
113 |
114 | return Task.CompletedTask;
115 | }
116 |
117 | Task ITileSource.GetTileAsync(int x, int y, int z, CancellationToken cancellationToken)
118 | {
119 | if (this.repository == null)
120 | {
121 | throw new InvalidOperationException("Repository was not initialized.");
122 | }
123 |
124 | var tileRow = this.configuration.Tms != null && this.configuration.Tms.Value ? y : Utils.WebMercator.FlipYCoordinate(y, z);
125 | var tileData = this.repository.ReadTile(x, tileRow, z);
126 |
127 | // TODO: pass gzipped data as-is with setting HTTP headers?
128 | // pbf as a format refers to gzip-compressed vector tile data in Mapbox Vector Tile format,
129 | // which uses Google Protocol Buffers as encoding format.
130 | if (this.configuration.Format == ImageFormats.Protobuf && tileData != null)
131 | {
132 | using var compressedStream = new MemoryStream(tileData);
133 | using var zipStream = new GZipStream(compressedStream, CompressionMode.Decompress);
134 | using var resultStream = new MemoryStream();
135 | zipStream.CopyTo(resultStream);
136 | tileData = resultStream.ToArray();
137 | }
138 |
139 | return Task.FromResult(tileData);
140 | }
141 |
142 | SourceConfiguration ITileSource.Configuration => this.configuration;
143 |
144 | #endregion
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/Src/TileMapService/TileSources/LocalFilesTileSource.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Globalization;
4 | using System.IO;
5 | using System.Linq;
6 | using System.Runtime.CompilerServices;
7 | using System.Threading;
8 | using System.Threading.Tasks;
9 |
10 | namespace TileMapService.TileSources
11 | {
12 | ///
13 | /// Represents tile source with tiles stored in separate files.
14 | ///
15 | class LocalFilesTileSource : ITileSource
16 | {
17 | private SourceConfiguration configuration;
18 |
19 | public LocalFilesTileSource(SourceConfiguration configuration)
20 | {
21 | if (String.IsNullOrEmpty(configuration.Id))
22 | {
23 | throw new ArgumentException("Source identifier is null or empty string");
24 | }
25 |
26 | if (String.IsNullOrEmpty(configuration.Location))
27 | {
28 | throw new ArgumentException("Source location is null or empty string");
29 | }
30 |
31 | this.configuration = configuration; // Will be changed later in InitAsync
32 | }
33 |
34 | #region ITileSource implementation
35 |
36 | Task ITileSource.InitAsync()
37 | {
38 | if (String.IsNullOrEmpty(this.configuration.Location))
39 | {
40 | throw new InvalidOperationException("configuration.Location is null or empty");
41 | }
42 |
43 | if (String.IsNullOrEmpty(this.configuration.Format)) // TODO: from first file, if any
44 | {
45 | throw new InvalidOperationException("configuration.Format is null or empty");
46 | }
47 |
48 | // Configuration values priority:
49 | // 1. Default values for local files source type.
50 | // 2. Actual values (from first found tile properties).
51 | // 3. Values from configuration file - overrides given above, if provided.
52 |
53 | // Detect zoom levels range - build list of folders
54 | var zoomLevels = new List();
55 | var xIndex = this.configuration.Location.IndexOf("{x}", StringComparison.OrdinalIgnoreCase);
56 | var yIndex = this.configuration.Location.IndexOf("{y}", StringComparison.OrdinalIgnoreCase);
57 | var zIndex = this.configuration.Location.IndexOf("{z}", StringComparison.OrdinalIgnoreCase);
58 | if ((zIndex < yIndex) && (zIndex < xIndex))
59 | {
60 | var baseFolder = new Uri(this.configuration.Location[..zIndex]).LocalPath;
61 | foreach (var directory in Directory.GetDirectories(baseFolder))
62 | {
63 | if (Int32.TryParse(Path.GetFileName(directory), out int zoomLevel)) // Directory name is integer number
64 | {
65 | zoomLevels.Add(zoomLevel);
66 | }
67 | }
68 | }
69 |
70 | var title = String.IsNullOrEmpty(this.configuration.Title) ?
71 | this.configuration.Id :
72 | this.configuration.Title;
73 |
74 | var srs = String.IsNullOrWhiteSpace(this.configuration.Srs) ? Utils.SrsCodes.EPSG3857 : this.configuration.Srs.Trim().ToUpper();
75 |
76 | var minZoom = this.configuration.MinZoom ?? (zoomLevels.Count > 0 ? zoomLevels.Min(z => z) : 0);
77 | var maxZoom = this.configuration.MaxZoom ?? (zoomLevels.Count > 0 ? zoomLevels.Max(z => z) : 20);
78 |
79 | // TODO: TileWidh, TileHeight from file properties (with supported image extension)
80 |
81 | // Re-create configuration
82 | this.configuration = new SourceConfiguration
83 | {
84 | Id = this.configuration.Id,
85 | Type = this.configuration.Type,
86 | Format = this.configuration.Format, // TODO: from file properties (extension)
87 | Title = title,
88 | Abstract = this.configuration.Abstract,
89 | Attribution = this.configuration.Attribution,
90 | Tms = this.configuration.Tms ?? false, // Default is tms=false for file storage
91 | Srs = srs,
92 | Location = this.configuration.Location,
93 | ContentType = Utils.EntitiesConverter.TileFormatToContentType(this.configuration.Format), // TODO: from file properties
94 | MinZoom = minZoom,
95 | MaxZoom = maxZoom,
96 | GeographicalBounds = null, // TODO: compute bounds (need to scan all folders ?)
97 | Cache = null, // Not used for local files source
98 | };
99 |
100 | // TODO: tile width, tile height from first tile
101 |
102 | return Task.CompletedTask;
103 | }
104 |
105 | async Task ITileSource.GetTileAsync(int x, int y, int z, CancellationToken cancellationToken)
106 | {
107 | if (z < this.configuration.MinZoom || z > this.configuration.MaxZoom)
108 | {
109 | return null;
110 | }
111 | else
112 | {
113 | if (String.IsNullOrEmpty(this.configuration.Location))
114 | {
115 | throw new InvalidOperationException("configuration.Location is null or empty");
116 | }
117 |
118 | y = this.configuration.Tms != null && this.configuration.Tms.Value ? y : Utils.WebMercator.FlipYCoordinate(y, z);
119 | var path = GetLocalFilePath(this.configuration.Location, x, y, z);
120 | var fileInfo = new FileInfo(path);
121 | if (fileInfo.Exists)
122 | {
123 | using var fileStream = fileInfo.OpenRead();
124 | var buffer = new byte[fileInfo.Length];
125 | await fileStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false);
126 | return buffer;
127 | }
128 | else
129 | {
130 | return null;
131 | }
132 | }
133 | }
134 |
135 | SourceConfiguration ITileSource.Configuration => this.configuration;
136 |
137 | #endregion
138 |
139 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
140 | private static string GetLocalFilePath(string template, int x, int y, int z)
141 | {
142 | return template
143 | .Replace("{x}", x.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)
144 | .Replace("{y}", y.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase)
145 | .Replace("{z}", z.ToString(CultureInfo.InvariantCulture), StringComparison.OrdinalIgnoreCase);
146 | }
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/Src/TileMapService/Wms/QueryUtility.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Globalization;
4 | using System.Linq;
5 | using System.Runtime.CompilerServices;
6 |
7 | using Microsoft.AspNetCore.Http.Extensions;
8 |
9 | namespace TileMapService.Wms
10 | {
11 | class QueryUtility
12 | {
13 | private const string WmsQueryService = "service";
14 | private const string WmsQueryRequest = "request";
15 | private const string WmsQueryVersion = "version";
16 | internal const string WmsQueryLayers = "layers";
17 | private const string WmsQueryStyles = "styles";
18 | private const string WmsQuerySrs = "srs";
19 | private const string WmsQueryCrs = "crs";
20 | private const string WmsQueryBBox = "bbox";
21 | private const string WmsQueryFormat = "format";
22 | private const string WmsQueryTransparent = "transparent";
23 | private const string WmsQueryBackgroundColor = "bgcolor";
24 | private const string WmsQueryWidth = "width";
25 | private const string WmsQueryHeight = "height";
26 |
27 | private const string EPSG3857 = Utils.SrsCodes.EPSG3857; // TODO: EPSG:4326 support
28 |
29 | public static string GetCapabilitiesUrl(SourceConfiguration configuration)
30 | {
31 | var location = configuration.Location;
32 | if (String.IsNullOrWhiteSpace(location))
33 | {
34 | throw new ArgumentException("Location must be valid string");
35 | }
36 |
37 | var baseUri = Utils.UrlHelper.GetQueryBase(location);
38 | var items = Utils.UrlHelper.GetQueryParameters(location);
39 |
40 | // Version
41 | var wmsVersion = String.Empty; // Default WMS version not set for GetCapabilities
42 | if (configuration.Wms != null && !String.IsNullOrWhiteSpace(configuration.Wms.Version))
43 | {
44 | wmsVersion = configuration.Wms.Version;
45 | }
46 | else if (items.Any(kvp => kvp.Key == WmsQueryVersion))
47 | {
48 | wmsVersion = items.First(kvp => kvp.Key == WmsQueryVersion).Value;
49 | }
50 |
51 | RemoveKnownParameters(items);
52 | items.RemoveAll(kvp => String.Compare(kvp.Key, WmsQueryStyles, StringComparison.OrdinalIgnoreCase) == 0); // TODO: add styles to WMS configuration and RemoveKnownParameters()
53 |
54 | var qb = new QueryBuilder(items)
55 | {
56 | { WmsQueryService, Identifiers.Wms },
57 | { WmsQueryRequest, Identifiers.GetCapabilities }
58 | };
59 |
60 | if (!String.IsNullOrEmpty(wmsVersion))
61 | {
62 | qb.Add(WmsQueryVersion, wmsVersion);
63 | }
64 |
65 | return baseUri + qb.ToQueryString();
66 | }
67 |
68 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
69 | public static string GetTileUrl(
70 | SourceConfiguration configuration,
71 | int x, int y, int z)
72 | {
73 | return GetMapUrl(
74 | configuration,
75 | Utils.WebMercator.TileSize, // TODO: ? high resolution tiles ?
76 | Utils.WebMercator.TileSize,
77 | Utils.WebMercator.GetTileBounds(x, y, z),
78 | true,
79 | 0xFFFFFF);
80 | }
81 |
82 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
83 | public static string GetMapUrl(
84 | SourceConfiguration configuration,
85 | int width,
86 | int height,
87 | Models.Bounds boundingBox,
88 | bool isTransparent,
89 | uint backgroundColor)
90 | {
91 | var location = configuration.Location;
92 | if (String.IsNullOrWhiteSpace(location))
93 | {
94 | throw new ArgumentException("Location must be valid string");
95 | }
96 |
97 | var baseUri = Utils.UrlHelper.GetQueryBase(location);
98 | var items = Utils.UrlHelper.GetQueryParameters(location);
99 |
100 | // Version
101 | var wmsVersion = Identifiers.Version111; // Default WMS version is 1.1.1
102 | if (configuration.Wms != null && !String.IsNullOrWhiteSpace(configuration.Wms.Version))
103 | {
104 | wmsVersion = configuration.Wms.Version;
105 | }
106 | else if (items.Any(kvp => kvp.Key == WmsQueryVersion))
107 | {
108 | wmsVersion = items.First(kvp => kvp.Key == WmsQueryVersion).Value;
109 | }
110 |
111 | // Layers
112 | var layers = String.Empty;
113 | if (configuration.Wms != null && !String.IsNullOrWhiteSpace(configuration.Wms.Layer))
114 | {
115 | layers = configuration.Wms.Layer; // TODO: ? multiple layers
116 | }
117 | else if (items.Any(kvp => kvp.Key == WmsQueryLayers))
118 | {
119 | layers = items.First(kvp => kvp.Key == WmsQueryLayers).Value;
120 | }
121 |
122 | // Format
123 | var format = MediaTypeNames.Image.Png;
124 | if (!String.IsNullOrWhiteSpace(configuration.ContentType))
125 | {
126 | format = configuration.ContentType;
127 | }
128 |
129 | RemoveKnownParameters(items);
130 |
131 | var qb = new QueryBuilder(items)
132 | {
133 | { WmsQueryService, Identifiers.Wms },
134 | { WmsQueryRequest, Identifiers.GetMap },
135 | { WmsQueryVersion, wmsVersion },
136 | { WmsQueryLayers, layers },
137 | { wmsVersion == Identifiers.Version130 ? WmsQueryCrs : WmsQuerySrs, EPSG3857 }, // TODO: EPSG:4326 support
138 | { WmsQueryBBox, boundingBox.ToBBoxString() },
139 | { WmsQueryWidth, width.ToString(CultureInfo.InvariantCulture) },
140 | { WmsQueryHeight, height.ToString(CultureInfo.InvariantCulture) },
141 | { WmsQueryFormat, format },
142 | };
143 |
144 | if (isTransparent)
145 | {
146 | qb.Add(WmsQueryTransparent, "true");
147 | }
148 |
149 | qb.Add(WmsQueryBackgroundColor, "0x" + backgroundColor.ToString("X8"));
150 |
151 | return baseUri + qb.ToQueryString();
152 | }
153 |
154 | private static void RemoveKnownParameters(List> items)
155 | {
156 | // Location url can contain some specific parameters, like GeoServer-specific "map" parameter
157 | foreach (var known in new[] {
158 | WmsQueryService, WmsQueryRequest, WmsQueryVersion,
159 | WmsQueryLayers,
160 | WmsQuerySrs, WmsQueryCrs, WmsQueryBBox,
161 | WmsQueryFormat, WmsQueryTransparent, WmsQueryBackgroundColor,
162 | WmsQueryWidth, WmsQueryHeight })
163 | {
164 | items.RemoveAll(kvp => String.Compare(kvp.Key, known, StringComparison.OrdinalIgnoreCase) == 0);
165 | }
166 | }
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Tile Map Service for .NET 5 / .NET 8
2 | Simple and lightweight implementation of tile server basic features for .NET 5 / .NET 8 platforms. Provides access to tiles stored in several source types and serving them using various protocols.
3 |
4 | ### Demo page
5 | 
6 |
7 | ### Features
8 | * Supported tile sources:
9 |
10 | | Source type | EPSG:3857 | EPSG:4326 | Notes |
11 | | ------------------------- |:----------:|:---------:|--------------------------------------------------------------------------------------------------------|
12 | | Local file system | ✓ | ✓ | Each tile in separate file in Z/X/Y.ext folder structure |
13 | | MBTiles (SQLite) | ✓ | — | [MBTiles 1.3 Specification](https://github.com/mapbox/mbtiles-spec/blob/master/1.3/spec.md) |
14 | | GeoTIFF local file | ✓ | ✓ | [GeoTIFF](https://en.wikipedia.org/wiki/GeoTIFF) basic support with `EPSG:3857` or `EPSG:4326` source image SRS only |
15 | | XYZ tile service | ✓ | ✓ | [XYZ](https://en.wikipedia.org/wiki/Tiled_web_map) with local cache for `EPSG:3857` SRS |
16 | | TMS tile service | ✓ | ✓ | [TMS](https://wiki.osgeo.org/wiki/Tile_Map_Service_Specification) with local cache for `EPSG:3857` SRS |
17 | | WMTS tile service | ✓ | ✓ | [WMTS](https://www.ogc.org/standards/wmts) with local cache for `EPSG:3857` SRS |
18 | | WMS service | ✓ | — | [WMS](https://en.wikipedia.org/wiki/Web_Map_Service), versions 1.1.1 and 1.3.0, cache for `EPSG:3857` SRS |
19 | | PostGIS database | ✓ | — | [Mapbox Vector Tiles](https://github.com/mapbox/vector-tile-spec) from `geometry` column with `EPSG:3857` SRS only |
20 |
21 | * Supported protocols (service endpoints) for serving tiles:
22 |
23 | | Endpoint | EPSG:3857 | EPSG:4326 | Endpoint Url | Formats | Notes |
24 | | --------------------------------------------------------------------------------- |:---------:|:---------:|--------------|-----------|--------------------------------------------------------------------------------------------|
25 | | XYZ ([Tiled web map](https://en.wikipedia.org/wiki/Tiled_web_map)) | ✓ | ✓ | `/xyz` | png, jpeg, webp, mvt | Can be REST style url (/{z}/{x}/{y}.ext) or url with parameters (&x={x}&y={y}&z={z}) |
26 | | TMS ([Tile Map Service](https://en.wikipedia.org/wiki/Tile_Map_Service)) | ✓ | ✓ | `/tms` | png, jpeg, webp, mvt | |
27 | | WMTS ([Web Map Tile Service](https://en.wikipedia.org/wiki/Web_Map_Tile_Service)) | ✓ | — | `/wmts` | png, jpeg, webp, mvt | Support both `RESTful` and `KVP` `GetTile` url syntax |
28 | | WMS ([Web Map Service](https://en.wikipedia.org/wiki/Web_Map_Service)) | ✓ | — | `/wms` | png, jpeg, tiff (geotiff) |WMS versions `1.1.1` and `1.3.0` |
29 |
30 | * Coordinate system / tile grid support: [Web Mercator / Spherical Mercator / EPSG:3857](https://en.wikipedia.org/wiki/Web_Mercator_projection), basic support for geodetic `EPSG:4326`.
31 | * Tile image formats: raster (`PNG`, `JPEG`, `WEBP`) 256×256 pixels tiles, basic support of `TIFF` output and `PBF` / `MVT` (vector tiles).
32 | * Local cache for tiles from external tile services sources (modified `mbtiles` format database file, `EPSG:3857` only), with blank tiles detection support.
33 | * Configuration in JSON file.
34 | * Reading sources configuration using `/api` endpoint (local requests only).
35 |
36 | ### Technologies
37 | There are two separate solutions and corresponding projects, sharing the same source code files:
38 |
39 | | Property | .NET 5 | .NET 8 |
40 | | ------------------ |:---------:|:-----------:|
41 | | SDK | .NET 5.0 | .NET 8.0 |
42 | | MS Visual Studio | 2019 | 2022 |
43 | | Status | Legacy | Active |
44 |
45 | Using
46 | * [Microsoft.Data.Sqlite](https://docs.microsoft.com/ru-ru/dotnet/standard/data/sqlite/) for working with SQLite database.
47 | * [SkiaSharp](https://github.com/mono/SkiaSharp) for raster images processing.
48 | * [BitMiracle.LibTiff.NET](https://github.com/BitMiracle/libtiff.net) for reading source GeoTIFF files and creating output TIFF images.
49 | * [Npgsql](https://github.com/npgsql/npgsql) .NET data provider for PostgreSQL.
50 | * [OpenLayers](https://github.com/OpenLayers) with [OpenLayers LayerSwitcher](https://github.com/walkermatt/ol-layerswitcher) for displaying layers.
51 | * [NUnit](https://nunit.org/) for tests.
52 |
53 | ### Configuration file
54 |
55 | Tile sources are defined in [appsettings.json](https://github.com/apdevelop/tile-map-service-net5/blob/master/Docs/appsettings.md) configuration file.
56 |
57 | ### Running framework-dependent deployment
58 |
59 | Check if .NET 5 or .NET 8 runtime is installed on target system:
60 |
61 | `dotnet --info`
62 |
63 | The `Microsoft.AspNetCore.App 5.0.3` / `8.0.0` (or later versions) should present in list.
64 |
65 | *There is known issue for .NET 5 and libssl 3.x compatibility on Linux systems, use .NET 8 in this case.*
66 |
67 | Run the application using command:
68 |
69 | `dotnet tms.dll`
70 |
71 | After start, it will listen on default TCP port 5000 (using in-process `Kestrel` web server)
72 | and tile service with demo page will be available on `http://localhost:5000/` address; to enable remote calls allow connections to this port in firewall settings.
73 |
74 | ### Further improvements on Linux
75 |
76 | Some improvements can be made for better using this application in real environment:
77 | * Install `nginx` and configure it as reverse proxy server for Kestrel server.
78 | * Configure application to run as a service, using `systemd` service manager.
79 |
80 | ### TODOs
81 | * Support for more formats (image formats, vector tiles) and coordinate systems (tile grids).
82 | * Flexible settings of tile sources.
83 | * Configuration Web API / Web UI with authentication.
84 | * WMS client in Web UI.
85 | * Compare with reference implementations (servers and clients).
86 | * Using metatiles for better tiles quality.
87 | * Include test dataset(s) created from free data.
88 | * Extended diagnostics, error handling and logging.
89 | * Performance tests.
90 | * Live demo.
91 |
92 | ### Some MBTiles sample datasets
93 | * [World Countries MBTiles by ArcGIS / EsriAndroidTeam](https://www.arcgis.com/home/item.html?id=7b650618563741ca9a5186c1aa69126e)
94 | * [Satellite Lowres raster tiles Planet by MapTiler](https://data.maptiler.com/downloads/dataset/satellite-lowres/)
95 | * [Custom vector tiles from Georaphy Class example MVT](https://github.com/klokantech/vector-tiles-sample/releases/tag/v1.0)
96 |
97 | All external tile sources (services) in the provided `appsettings.json` file are only for development / testing, not for production use.
98 |
99 | ### References
100 | * [MBTiles Specification](https://github.com/mapbox/mbtiles-spec/)
101 | * [Tile Map Service Specification](https://wiki.osgeo.org/wiki/Tile_Map_Service_Specification)
102 | * [OpenGIS Web Map Tile Service Implementation Standard](https://www.ogc.org/standard/wmts/)
103 | * [Serving Dynamic Vector Tiles from PostGIS](https://blog.crunchydata.com/blog/dynamic-vector-tiles-from-postgis)
104 | * [GeoTIFF Format Specification](http://geotiff.maptools.org/spec/geotiffhome.html)
105 | * [Using WMS and TMS in Leaflet](https://leafletjs.com/examples/wms/wms.html)
106 | * [QGIS User Guide: Working with OGC / ISO protocols](https://docs.qgis.org/3.28/en/docs/user_manual/working_with_ogc/ogc_client_support.html)
107 | * [Deploy ASP.NET Core on Linux with Nginx](https://code-maze.com/deploy-aspnetcore-linux-nginx/)
--------------------------------------------------------------------------------
/Src/TileMapService/wwwroot/ol.css:
--------------------------------------------------------------------------------
1 | :root,
2 | :host {
3 | --ol-background-color: white;
4 | --ol-accent-background-color: #F5F5F5;
5 | --ol-subtle-background-color: rgba(128, 128, 128, 0.25);
6 | --ol-partial-background-color: rgba(255, 255, 255, 0.75);
7 | --ol-foreground-color: #333333;
8 | --ol-subtle-foreground-color: #666666;
9 | --ol-brand-color: #00AAFF;
10 | }
11 |
12 | .ol-box {
13 | box-sizing: border-box;
14 | border-radius: 2px;
15 | border: 1.5px solid var(--ol-background-color);
16 | background-color: var(--ol-partial-background-color);
17 | }
18 |
19 | .ol-mouse-position {
20 | top: 8px;
21 | right: 8px;
22 | position: absolute;
23 | }
24 |
25 | .ol-scale-line {
26 | background: var(--ol-partial-background-color);
27 | border-radius: 4px;
28 | bottom: 8px;
29 | left: 8px;
30 | padding: 2px;
31 | position: absolute;
32 | }
33 |
34 | .ol-scale-line-inner {
35 | border: 1px solid var(--ol-subtle-foreground-color);
36 | border-top: none;
37 | color: var(--ol-foreground-color);
38 | font-size: 10px;
39 | text-align: center;
40 | margin: 1px;
41 | will-change: contents, width;
42 | transition: all 0.25s;
43 | }
44 |
45 | .ol-scale-bar {
46 | position: absolute;
47 | bottom: 8px;
48 | left: 8px;
49 | }
50 |
51 | .ol-scale-bar-inner {
52 | display: flex;
53 | }
54 |
55 | .ol-scale-step-marker {
56 | width: 1px;
57 | height: 15px;
58 | background-color: var(--ol-foreground-color);
59 | float: right;
60 | z-index: 10;
61 | }
62 |
63 | .ol-scale-step-text {
64 | position: absolute;
65 | bottom: -5px;
66 | font-size: 10px;
67 | z-index: 11;
68 | color: var(--ol-foreground-color);
69 | text-shadow: -1.5px 0 var(--ol-partial-background-color), 0 1.5px var(--ol-partial-background-color), 1.5px 0 var(--ol-partial-background-color), 0 -1.5px var(--ol-partial-background-color);
70 | }
71 |
72 | .ol-scale-text {
73 | position: absolute;
74 | font-size: 12px;
75 | text-align: center;
76 | bottom: 25px;
77 | color: var(--ol-foreground-color);
78 | text-shadow: -1.5px 0 var(--ol-partial-background-color), 0 1.5px var(--ol-partial-background-color), 1.5px 0 var(--ol-partial-background-color), 0 -1.5px var(--ol-partial-background-color);
79 | }
80 |
81 | .ol-scale-singlebar {
82 | position: relative;
83 | height: 10px;
84 | z-index: 9;
85 | box-sizing: border-box;
86 | border: 1px solid var(--ol-foreground-color);
87 | }
88 |
89 | .ol-scale-singlebar-even {
90 | background-color: var(--ol-subtle-foreground-color);
91 | }
92 |
93 | .ol-scale-singlebar-odd {
94 | background-color: var(--ol-background-color);
95 | }
96 |
97 | .ol-unsupported {
98 | display: none;
99 | }
100 |
101 | .ol-viewport,
102 | .ol-unselectable {
103 | -webkit-touch-callout: none;
104 | -webkit-user-select: none;
105 | -moz-user-select: none;
106 | user-select: none;
107 | -webkit-tap-highlight-color: transparent;
108 | }
109 |
110 | .ol-viewport canvas {
111 | all: unset;
112 | overflow: hidden;
113 | }
114 |
115 | .ol-viewport {
116 | touch-action: pan-x pan-y;
117 | }
118 |
119 | .ol-selectable {
120 | -webkit-touch-callout: default;
121 | -webkit-user-select: text;
122 | -moz-user-select: text;
123 | user-select: text;
124 | }
125 |
126 | .ol-grabbing {
127 | cursor: -webkit-grabbing;
128 | cursor: -moz-grabbing;
129 | cursor: grabbing;
130 | }
131 |
132 | .ol-grab {
133 | cursor: move;
134 | cursor: -webkit-grab;
135 | cursor: -moz-grab;
136 | cursor: grab;
137 | }
138 |
139 | .ol-control {
140 | position: absolute;
141 | background-color: var(--ol-subtle-background-color);
142 | border-radius: 4px;
143 | }
144 |
145 | .ol-zoom {
146 | top: .5em;
147 | left: .5em;
148 | }
149 |
150 | .ol-rotate {
151 | top: .5em;
152 | right: .5em;
153 | transition: opacity .25s linear, visibility 0s linear;
154 | }
155 |
156 | .ol-rotate.ol-hidden {
157 | opacity: 0;
158 | visibility: hidden;
159 | transition: opacity .25s linear, visibility 0s linear .25s;
160 | }
161 |
162 | .ol-zoom-extent {
163 | top: 4.643em;
164 | left: .5em;
165 | }
166 |
167 | .ol-full-screen {
168 | right: .5em;
169 | top: .5em;
170 | }
171 |
172 | .ol-control button {
173 | display: block;
174 | margin: 1px;
175 | padding: 0;
176 | color: var(--ol-subtle-foreground-color);
177 | font-weight: bold;
178 | text-decoration: none;
179 | font-size: inherit;
180 | text-align: center;
181 | height: 1.375em;
182 | width: 1.375em;
183 | line-height: .4em;
184 | background-color: var(--ol-background-color);
185 | border: none;
186 | border-radius: 2px;
187 | }
188 |
189 | .ol-control button::-moz-focus-inner {
190 | border: none;
191 | padding: 0;
192 | }
193 |
194 | .ol-zoom-extent button {
195 | line-height: 1.4em;
196 | }
197 |
198 | .ol-compass {
199 | display: block;
200 | font-weight: normal;
201 | will-change: transform;
202 | }
203 |
204 | .ol-touch .ol-control button {
205 | font-size: 1.5em;
206 | }
207 |
208 | .ol-touch .ol-zoom-extent {
209 | top: 5.5em;
210 | }
211 |
212 | .ol-control button:hover,
213 | .ol-control button:focus {
214 | text-decoration: none;
215 | outline: 1px solid var(--ol-subtle-foreground-color);
216 | color: var(--ol-foreground-color);
217 | }
218 |
219 | .ol-zoom .ol-zoom-in {
220 | border-radius: 2px 2px 0 0;
221 | }
222 |
223 | .ol-zoom .ol-zoom-out {
224 | border-radius: 0 0 2px 2px;
225 | }
226 |
227 | .ol-attribution {
228 | text-align: right;
229 | bottom: .5em;
230 | right: .5em;
231 | max-width: calc(100% - 1.3em);
232 | display: flex;
233 | flex-flow: row-reverse;
234 | align-items: center;
235 | }
236 |
237 | .ol-attribution a {
238 | color: var(--ol-subtle-foreground-color);
239 | text-decoration: none;
240 | }
241 |
242 | .ol-attribution ul {
243 | margin: 0;
244 | padding: 1px .5em;
245 | color: var(--ol-foreground-color);
246 | text-shadow: 0 0 2px var(--ol-background-color);
247 | font-size: 12px;
248 | }
249 |
250 | .ol-attribution li {
251 | display: inline;
252 | list-style: none;
253 | }
254 |
255 | .ol-attribution li:not(:last-child):after {
256 | content: " ";
257 | }
258 |
259 | .ol-attribution img {
260 | max-height: 2em;
261 | max-width: inherit;
262 | vertical-align: middle;
263 | }
264 |
265 | .ol-attribution button {
266 | flex-shrink: 0;
267 | }
268 |
269 | .ol-attribution.ol-collapsed ul {
270 | display: none;
271 | }
272 |
273 | .ol-attribution:not(.ol-collapsed) {
274 | background: var(--ol-partial-background-color);
275 | }
276 |
277 | .ol-attribution.ol-uncollapsible {
278 | bottom: 0;
279 | right: 0;
280 | border-radius: 4px 0 0;
281 | }
282 |
283 | .ol-attribution.ol-uncollapsible img {
284 | margin-top: -.2em;
285 | max-height: 1.6em;
286 | }
287 |
288 | .ol-attribution.ol-uncollapsible button {
289 | display: none;
290 | }
291 |
292 | .ol-zoomslider {
293 | top: 4.5em;
294 | left: .5em;
295 | height: 200px;
296 | }
297 |
298 | .ol-zoomslider button {
299 | position: relative;
300 | height: 10px;
301 | }
302 |
303 | .ol-touch .ol-zoomslider {
304 | top: 5.5em;
305 | }
306 |
307 | .ol-overviewmap {
308 | left: 0.5em;
309 | bottom: 0.5em;
310 | }
311 |
312 | .ol-overviewmap.ol-uncollapsible {
313 | bottom: 0;
314 | left: 0;
315 | border-radius: 0 4px 0 0;
316 | }
317 |
318 | .ol-overviewmap .ol-overviewmap-map,
319 | .ol-overviewmap button {
320 | display: block;
321 | }
322 |
323 | .ol-overviewmap .ol-overviewmap-map {
324 | border: 1px solid var(--ol-subtle-foreground-color);
325 | height: 150px;
326 | width: 150px;
327 | }
328 |
329 | .ol-overviewmap:not(.ol-collapsed) button {
330 | bottom: 0;
331 | left: 0;
332 | position: absolute;
333 | }
334 |
335 | .ol-overviewmap.ol-collapsed .ol-overviewmap-map,
336 | .ol-overviewmap.ol-uncollapsible button {
337 | display: none;
338 | }
339 |
340 | .ol-overviewmap:not(.ol-collapsed) {
341 | background: var(--ol-subtle-background-color);
342 | }
343 |
344 | .ol-overviewmap-box {
345 | border: 1.5px dotted var(--ol-subtle-foreground-color);
346 | }
347 |
348 | .ol-overviewmap .ol-overviewmap-box:hover {
349 | cursor: move;
350 | }
351 |
--------------------------------------------------------------------------------
/Src/TileMapService/SourceConfiguration.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Text.Json.Serialization;
3 |
4 | namespace TileMapService
5 | {
6 | ///
7 | /// Represents source configuration and properties.
8 | ///
9 | ///
10 | /// The [JsonPropertyName("...")] attribute is actually ignored on properties when loading configuration
11 | /// https://stackoverflow.com/questions/60470583/handling-key-names-with-periods-in-net-core-appsettings-configuration
12 | /// https://github.com/dotnet/runtime/issues/36010
13 | ///
14 | public class SourceConfiguration
15 | {
16 | ///
17 | /// Type of source, "file", "mbtiles", "postgis", "xyz", "geotiff", "tms", "wmts", "wms".
18 | ///
19 | [JsonPropertyName("type")]
20 | public string Type { get; set; } = String.Empty;
21 |
22 | #region Types
23 | ///
24 | /// Local files in directories.
25 | ///
26 | [JsonIgnore]
27 | public const string TypeLocalFiles = "file";
28 |
29 | ///
30 | /// MBTiles database local file.
31 | ///
32 | [JsonIgnore]
33 | public const string TypeMBTiles = "mbtiles";
34 |
35 | ///
36 | /// PostGIS database.
37 | ///
38 | [JsonIgnore]
39 | public const string TypePostGIS = "postgis";
40 |
41 | ///
42 | /// Tile service with minimalistic REST API (Slippy Map).
43 | ///
44 | [JsonIgnore]
45 | public const string TypeXyz = "xyz";
46 |
47 | ///
48 | /// Tile service with TMS protocol support.
49 | ///
50 | [JsonIgnore]
51 | public const string TypeTms = "tms";
52 |
53 | ///
54 | /// Tile service with WMTS protocol support.
55 | ///
56 | [JsonIgnore]
57 | public const string TypeWmts = "wmts";
58 |
59 | ///
60 | /// Web Map Service (WMS protocol).
61 | ///
62 | [JsonIgnore]
63 | public const string TypeWms = "wms";
64 |
65 | ///
66 | /// GeoTIFF local file.
67 | ///
68 | [JsonIgnore]
69 | public const string TypeGeoTiff = "geotiff";
70 | #endregion
71 |
72 | ///
73 | /// String identifier of tile source (case-sensitive).
74 | ///
75 | [JsonPropertyName("id")]
76 | public string Id { get; set; } = String.Empty;
77 |
78 | ///
79 | /// Name of image format ("png", "jpg", "mvt", "pbf").
80 | ///
81 | [JsonPropertyName("format")]
82 | public string? Format { get; set; } // TODO: implement conversion of source formats to output formats
83 |
84 | ///
85 | /// User-friendly title (displayed name) of source.
86 | ///
87 | [JsonPropertyName("title")]
88 | public string Title { get; set; } = String.Empty;
89 |
90 | ///
91 | /// Detailed text description of source.
92 | ///
93 | [JsonPropertyName("abstract")]
94 | public string Abstract { get; set; } = String.Empty;
95 |
96 | ///
97 | /// An attribution (HTML) string, which explains the sources of data and/or style for the map.
98 | ///
99 | [JsonPropertyName("attribution")]
100 | public string? Attribution { get; set; }
101 |
102 | ///
103 | /// Location of tiles (path template for "file", full path for "mbtiles", url template for "http").
104 | ///
105 | [JsonPropertyName("location")]
106 | public string? Location { get; set; }
107 |
108 | ///
109 | /// TMS type Y coordinate (true: Y going from bottom to top; false: from top to bottom, like in OSM tiles).
110 | ///
111 | [JsonPropertyName("tms")]
112 | public bool? Tms { get; set; }
113 |
114 | ///
115 | /// MIME type identifier of image format.
116 | ///
117 | [JsonIgnore]
118 | public string? ContentType { get; set; }
119 |
120 | [JsonPropertyName("minzoom")]
121 | public int? MinZoom { get; set; }
122 |
123 | [JsonPropertyName("maxzoom")]
124 | public int? MaxZoom { get; set; }
125 |
126 | ///
127 | /// Spatial reference system (SRS), "EPSG:4326" format.
128 | ///
129 | [JsonPropertyName("srs")]
130 | public string? Srs { get; set; }
131 |
132 | [JsonIgnore] // TODO: allow reading from config file
133 | public int TileWidth { get; set; } = Utils.WebMercator.DefaultTileWidth;
134 |
135 | [JsonIgnore] // TODO: allow reading from config file
136 | public int TileHeight { get; set; } = Utils.WebMercator.DefaultTileHeight;
137 |
138 | ///
139 | /// Maximum extent of the tiles coordinates in EPSG:4326 coordinate system.
140 | ///
141 | [JsonIgnore] // TODO: allow reading from config file
142 | public Models.GeographicalBounds? GeographicalBounds { get; set; }
143 |
144 | ///
145 | /// Cache configuration, if used.
146 | ///
147 | [JsonPropertyName("cache")]
148 | public SourceCacheConfiguration? Cache { get; set; }
149 |
150 | ///
151 | /// WMTS source type configuration.
152 | ///
153 | [JsonPropertyName("wmts")]
154 | public WmtsSourceConfiguration? Wmts { get; set; }
155 |
156 | ///
157 | /// WMS source type configuration.
158 | ///
159 | [JsonPropertyName("wms")]
160 | public WmsSourceConfiguration? Wms { get; set; }
161 |
162 | ///
163 | /// PostGIS source type configuration.
164 | ///
165 | [JsonPropertyName("postgis")]
166 | public PostGisSourceTableConfiguration? PostGis { get; set; }
167 | }
168 |
169 | ///
170 | /// Cache configuration.
171 | ///
172 | public class SourceCacheConfiguration
173 | {
174 | ///
175 | /// Type of cache ('mbtiles' is only valid value).
176 | ///
177 | [JsonPropertyName("type")]
178 | public string? Type { get; set; }
179 |
180 | ///
181 | /// Full path to cache database file.
182 | ///
183 | [JsonPropertyName("dbfile")]
184 | public string? DbFile { get; set; }
185 | }
186 |
187 | ///
188 | /// PostGIS source type configuration.
189 | ///
190 | public class PostGisSourceTableConfiguration
191 | {
192 | ///
193 | /// Table name.
194 | ///
195 | [JsonPropertyName("table")]
196 | public string? Table { get; set; }
197 |
198 | ///
199 | /// Name of geometry field.
200 | ///
201 | [JsonPropertyName("geometry")]
202 | public string? Geometry { get; set; }
203 |
204 | ///
205 | /// List of fields with object attributes in form of CSV string.
206 | ///
207 | [JsonPropertyName("fields")]
208 | public string? Fields { get; set; }
209 | }
210 |
211 | ///
212 | /// WMTS source type configuration.
213 | ///
214 | public class WmtsSourceConfiguration
215 | {
216 | ///
217 | /// WMTS Capabilities document URL.
218 | ///
219 | [JsonPropertyName("capabilitiesurl")]
220 | public string? CapabilitiesUrl { get; set; }
221 |
222 | ///
223 | /// Layer identifier.
224 | ///
225 | [JsonPropertyName("layer")]
226 | public string? Layer { get; set; }
227 |
228 | ///
229 | /// Style identifier.
230 | ///
231 | [JsonPropertyName("style")]
232 | public string? Style { get; set; }
233 |
234 | ///
235 | /// TileMatrixSet identifier.
236 | ///
237 | [JsonPropertyName("tilematrixset")]
238 | public string? TileMatrixSet { get; set; }
239 | }
240 |
241 | ///
242 | /// WMS source type configuration.
243 | ///
244 | public class WmsSourceConfiguration
245 | {
246 | ///
247 | /// Layer identifier.
248 | ///
249 | [JsonPropertyName("layer")]
250 | public string? Layer { get; set; } // TODO: ? multiple layers
251 |
252 | public string? Version { get; set; }
253 | }
254 | }
255 |
--------------------------------------------------------------------------------
/Src/TileMapService/Utils/WebMercator.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Runtime.CompilerServices;
3 |
4 | namespace TileMapService.Utils
5 | {
6 | ///
7 | /// Various utility functions for EPSG:3857 / Web Mercator SRS and tile system.
8 | /// (Bing Maps Tile System)
9 | ///
10 | public static class WebMercator
11 | {
12 | public const int TileSize = 256; // TODO: custom tile size / resolution
13 |
14 | public const int DefaultTileSize = 256;
15 |
16 | ///
17 | /// Default tile width, pixels.
18 | ///
19 | public const int DefaultTileWidth = 256;
20 |
21 | ///
22 | /// Default tile height, pixels.
23 | ///
24 | public const int DefaultTileHeight = 256;
25 |
26 | private const double EarthRadius = 6378137;
27 |
28 | private const double MaxLatitude = 85.0511287798;
29 |
30 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
31 | public static bool IsInsideBBox(int x, int y, int z, string? srs)
32 | {
33 | int xmin, xmax;
34 | int ymin = 0;
35 | int ymax = (1 << z) - 1;
36 | switch (srs)
37 | {
38 | case SrsCodes.EPSG3857: { xmin = 0; xmax = (1 << z) - 1; break; }
39 | case SrsCodes.EPSG4326: { xmin = 0; xmax = 2 * (1 << z) - 1; break; }
40 | default: throw new ArgumentOutOfRangeException(nameof(srs));
41 | }
42 |
43 | return x >= xmin && x <= xmax && y >= ymin && y <= ymax;
44 | }
45 |
46 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
47 | public static double Longitude(double x) =>
48 | x / (EarthRadius * Math.PI / 180);
49 |
50 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
51 | public static double Latitude(double y) =>
52 | MathHelper.RadiansToDegrees(2 * Math.Atan(Math.Exp(y / EarthRadius)) - Math.PI / 2);
53 |
54 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
55 | public static double X(double longitude) =>
56 | EarthRadius * MathHelper.DegreesToRadians(longitude);
57 |
58 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
59 | public static double Y(double latitude) =>
60 | EarthRadius * MathHelper.Artanh(Math.Sin(MathHelper.DegreesToRadians(Math.Max(Math.Min(MaxLatitude, latitude), -MaxLatitude))));
61 |
62 | ///
63 | /// Computes tile bounds for given coordinates (x, y, z).
64 | ///
65 | /// Tile X coordinate.
66 | /// Tile Y coordinate.
67 | /// Zoom level.
68 | /// Tile bounds.
69 | ///
70 | /// Similar to PostGIS ST_TileEnvelope function: https://postgis.net/docs/ST_TileEnvelope.html
71 | ///
72 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
73 | public static Models.Bounds GetTileBounds(int tileX, int tileY, int zoomLevel) =>
74 | new Models.Bounds(
75 | TileXtoEpsg3857X(tileX, zoomLevel),
76 | TileYtoEpsg3857Y(tileY + 1, zoomLevel),
77 | TileXtoEpsg3857X(tileX + 1, zoomLevel),
78 | TileYtoEpsg3857Y(tileY, zoomLevel));
79 |
80 | public static Models.GeographicalBounds GetTileGeographicalBounds(int tileX, int tileY, int zoomLevel) =>
81 | new Models.GeographicalBounds(
82 | new Models.GeographicalPoint(PixelXToLongitude(TileSize * tileX, zoomLevel), PixelYToLatitude(TileSize * tileY + TileSize, zoomLevel)),
83 | new Models.GeographicalPoint(PixelXToLongitude(TileSize * tileX + TileSize, zoomLevel), PixelYToLatitude(TileSize * tileY, zoomLevel)));
84 |
85 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
86 | public static double TileXtoEpsg3857X(int tileX, int zoomLevel)
87 | {
88 | var mapSize = (double)MapSize(zoomLevel, DefaultTileSize);
89 | var pixelX = tileX * TileSize;
90 | var x = (MathHelper.Clip(pixelX, 0, mapSize) / mapSize) - 0.5;
91 | var longitude = 360 * x;
92 |
93 | return EarthRadius * MathHelper.DegreesToRadians(longitude);
94 | }
95 |
96 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
97 | public static double TileYtoEpsg3857Y(int tileY, int zoomLevel)
98 | {
99 | var mapSize = (double)MapSize(zoomLevel, DefaultTileSize);
100 | var pixelY = tileY * TileSize;
101 | var y = 0.5 - (MathHelper.Clip(pixelY, 0, mapSize) / mapSize);
102 | var latitude = 90 - 360 * Math.Atan(Math.Exp(-y * 2 * Math.PI)) / Math.PI;
103 |
104 | return EarthRadius * MathHelper.Artanh(Math.Sin(MathHelper.DegreesToRadians(latitude)));
105 | }
106 |
107 | ///
108 | /// Returns number of tiles along axis at given zoom level.
109 | ///
110 | /// Zoom level.
111 | /// Number of tiles along axis
112 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
113 | public static int TileCount(int zoomLevel) =>
114 | 1 << zoomLevel;
115 |
116 | ///
117 | /// Returns entire world map image size in pixels at given zoom level.
118 | ///
119 | /// Zoom level.
120 | /// Tile size (width and height) in pixels.
121 | ///
122 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
123 | public static int MapSize(int zoomLevel, int tileSize) =>
124 | tileSize << zoomLevel;
125 |
126 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
127 | public static double MapSize(double width, double longitudeMin, double longitudeMax)
128 | {
129 | if (width <= 0)
130 | {
131 | throw new ArgumentOutOfRangeException(nameof(width), width, "Width must be greater than zero.");
132 | }
133 |
134 | if (longitudeMin >= longitudeMax)
135 | {
136 | throw new ArgumentException("longitudeMin >= longitudeMax");
137 | }
138 |
139 | var mapSize = width / ((longitudeMax - longitudeMin) / 360);
140 |
141 | return mapSize;
142 | }
143 |
144 | ///
145 | /// Flips tile Y coordinate (according to XYZ-TMS coordinate systems conversion).
146 | ///
147 | /// Tile Y coordinate.
148 | /// Tile zoom level.
149 | /// Flipped tile Y coordinate.
150 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
151 | public static int FlipYCoordinate(int y, int zoomLevel) => (1 << zoomLevel) - y - 1;
152 |
153 | public static double TileCoordinateXAtZoom(double longitude, int zoomLevel) =>
154 | LongitudeToPixelXAtZoom(longitude, zoomLevel) / (double)TileSize;
155 |
156 | public static double TileCoordinateYAtZoom(double latitude, int zoomLevel) =>
157 | LatitudeToPixelYAtZoom(latitude, zoomLevel) / (double)TileSize;
158 |
159 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
160 | public static double LongitudeToPixelXAtZoom(double longitude, int zoomLevel) =>
161 | LongitudeToPixelX(longitude, MapSize(zoomLevel, DefaultTileSize));
162 |
163 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
164 | public static double LatitudeToPixelYAtZoom(double latitude, int zoomLevel) =>
165 | LatitudeToPixelY(latitude, MapSize(zoomLevel, DefaultTileSize));
166 |
167 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
168 | public static double LongitudeToPixelX(double longitude, double mapSize) =>
169 | ((longitude + 180) / 360) * mapSize;
170 |
171 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
172 | public static double LatitudeToPixelY(double latitude, double mapSize)
173 | {
174 | var sinLatitude = Math.Sin(MathHelper.DegreesToRadians(latitude));
175 | return (0.5 - Math.Log((1 + sinLatitude) / (1 - sinLatitude)) / (4 * Math.PI)) * mapSize;
176 | }
177 |
178 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
179 | public static double PixelXToLongitude(double pixelX, int zoomLevel)
180 | {
181 | var mapSize = (double)MapSize(zoomLevel, DefaultTileSize);
182 | var x = (MathHelper.Clip(pixelX, 0, mapSize) / mapSize) - 0.5;
183 |
184 | return 360 * x;
185 | }
186 |
187 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
188 | public static double PixelYToLatitude(double pixelY, int zoomLevel)
189 | {
190 | var mapSize = (double)MapSize(zoomLevel, DefaultTileSize);
191 | var y = 0.5 - (MathHelper.Clip(pixelY, 0, mapSize) / mapSize);
192 |
193 | return 90 - 360 * Math.Atan(Math.Exp(-y * 2 * Math.PI)) / Math.PI;
194 | }
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/Src/TileMapService/Wms/WmsHelper.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Threading;
5 | using System.Threading.Tasks;
6 |
7 | using SkiaSharp;
8 |
9 | using TileMapService.Utils;
10 |
11 | namespace TileMapService.Wms
12 | {
13 | public static class WmsHelper
14 | {
15 | public static Version GetWmsVersion(string version)
16 | {
17 | switch (version)
18 | {
19 | case Identifiers.Version111: return Version.Version111;
20 | case Identifiers.Version130: return Version.Version130;
21 | default: throw new ArgumentOutOfRangeException(nameof(version), $"WMS version '{version}' is not supported.");
22 | }
23 | }
24 |
25 | public static async Task DrawLayerAsync(
26 | ITileSource source,
27 | int width,
28 | int height,
29 | Models.Bounds boundingBox,
30 | SKCanvas outputCanvas,
31 | bool isTransparent,
32 | uint backgroundColor,
33 | CancellationToken cancellationToken)
34 | {
35 | // TODO: check SRS support in source
36 | if ((String.Compare(source.Configuration.Type, SourceConfiguration.TypeWms, StringComparison.OrdinalIgnoreCase) == 0) &&
37 | (source.Configuration.Cache == null))
38 | {
39 | // Cascading GetMap request to WMS source as single GetMap request
40 | var imageData = await ((TileSources.HttpTileSource)source).GetWmsMapAsync(width, height, boundingBox, isTransparent, backgroundColor, cancellationToken).ConfigureAwait(false);
41 | if (imageData != null)
42 | {
43 | using var sourceImage = SKImage.FromEncodedData(imageData);
44 | outputCanvas.DrawImage(sourceImage, SKRect.Create(0, 0, sourceImage.Width, sourceImage.Height));
45 | }
46 | }
47 | else if (String.Compare(source.Configuration.Type, SourceConfiguration.TypeGeoTiff, StringComparison.OrdinalIgnoreCase) == 0)
48 | {
49 | // Get part of GeoTIFF source image in single request
50 | using var image = await ((TileSources.RasterTileSource)source).GetImagePartAsync(width, height, boundingBox, backgroundColor, cancellationToken).ConfigureAwait(false);
51 | if (image != null)
52 | {
53 | outputCanvas.DrawImage(image, SKRect.Create(0, 0, image.Width, image.Height));
54 | }
55 | }
56 | else
57 | {
58 | var tileCoordinates = WmsHelper.BuildTileCoordinatesList(boundingBox, width);
59 | var sourceTiles = await GetSourceTilesAsync(source, tileCoordinates, cancellationToken).ConfigureAwait(false);
60 | if (sourceTiles.Count > 0)
61 | {
62 | WmsHelper.DrawWebMercatorTilesToRasterCanvas(outputCanvas, width, height, boundingBox, sourceTiles, backgroundColor, WebMercator.TileSize, cancellationToken);
63 | }
64 | }
65 | }
66 |
67 | private static async Task> GetSourceTilesAsync(
68 | ITileSource source,
69 | IList tileCoordinates,
70 | CancellationToken cancellationToken)
71 | {
72 | var sourceTiles = new List(tileCoordinates.Count);
73 | foreach (var tc in tileCoordinates)
74 | {
75 | cancellationToken.ThrowIfCancellationRequested();
76 |
77 | // 180 degrees
78 | var tileCount = WebMercator.TileCount(tc.Z);
79 | var x = tc.X % tileCount;
80 |
81 | var tileData = await source.GetTileAsync(x, WebMercator.FlipYCoordinate(tc.Y, tc.Z), tc.Z, cancellationToken).ConfigureAwait(false);
82 | if (tileData != null)
83 | {
84 | sourceTiles.Add(new Models.TileDataset(tc.X, tc.Y, tc.Z, tileData));
85 | }
86 | }
87 |
88 | return sourceTiles;
89 | }
90 |
91 | private static void DrawWebMercatorTilesToRasterCanvas(
92 | SKCanvas outputCanvas,
93 | int width,
94 | int height,
95 | Models.Bounds boundingBox,
96 | IList sourceTiles,
97 | uint backgroundColor,
98 | int tileSize,
99 | CancellationToken cancellationToken)
100 | {
101 | var zoom = sourceTiles[0].Z;
102 | var tileMinX = sourceTiles.Min(t => t.X);
103 | var tileMinY = sourceTiles.Min(t => t.Y);
104 | var tilesCountX = sourceTiles.Max(t => t.X) - tileMinX + 1;
105 | var tilesCountY = sourceTiles.Max(t => t.Y) - tileMinY + 1;
106 | var canvasWidth = tilesCountX * tileSize;
107 | var canvasHeight = tilesCountY * tileSize;
108 |
109 | var imageInfo = new SKImageInfo(
110 | width: canvasWidth,
111 | height: canvasHeight,
112 | colorType: SKColorType.Rgba8888,
113 | alphaType: SKAlphaType.Premul);
114 |
115 | using var surface = SKSurface.Create(imageInfo);
116 | using var canvas = surface.Canvas;
117 | canvas.Clear(new SKColor(backgroundColor));
118 |
119 | // Draw all tiles
120 | foreach (var sourceTile in sourceTiles)
121 | {
122 | cancellationToken.ThrowIfCancellationRequested();
123 | var offsetX = (sourceTile.X - tileMinX) * tileSize;
124 | var offsetY = (sourceTile.Y - tileMinY) * tileSize;
125 | using var sourceImage = SKImage.FromEncodedData(sourceTile.ImageData);
126 | canvas.DrawImage(sourceImage, SKRect.Create(offsetX, offsetY, tileSize, tileSize)); // Source tile scaled to dest rectangle, if needed
127 | }
128 |
129 | // Clip and scale to requested size of output image
130 | var geoBBox = EntitiesConverter.MapRectangleToGeographicalBounds(boundingBox);
131 | var pixelOffsetX = WebMercator.LongitudeToPixelXAtZoom(geoBBox.MinLongitude, zoom) - tileSize * tileMinX;
132 | var pixelOffsetY = WebMercator.LatitudeToPixelYAtZoom(geoBBox.MaxLatitude, zoom) - tileSize * tileMinY;
133 | var pixelWidth = WebMercator.LongitudeToPixelXAtZoom(geoBBox.MaxLongitude, zoom) - WebMercator.LongitudeToPixelXAtZoom(geoBBox.MinLongitude, zoom);
134 | var pixelHeight = WebMercator.LatitudeToPixelYAtZoom(geoBBox.MinLatitude, zoom) - WebMercator.LatitudeToPixelYAtZoom(geoBBox.MaxLatitude, zoom);
135 | var sourceRectangle = SKRect.Create((float)pixelOffsetX, (float)pixelOffsetY, (float)pixelWidth, (float)pixelHeight);
136 | var destRectangle = SKRect.Create(0, 0, width, height);
137 |
138 | using SKImage canvasImage = surface.Snapshot();
139 | outputCanvas.DrawImage(canvasImage, sourceRectangle, destRectangle, new SKPaint { FilterQuality = SKFilterQuality.High, });
140 | }
141 |
142 | public static Models.TileCoordinates[] BuildTileCoordinatesList(Models.Bounds boundingBox, int width)
143 | {
144 | var geoBBox = EntitiesConverter.MapRectangleToGeographicalBounds(boundingBox);
145 | var zoomLevel = FindOptimalTileZoomLevel(width, geoBBox);
146 | var tileCoordBottomLeft = GetTileCoordinatesAtPoint(geoBBox.MinLongitude, geoBBox.MinLatitude, zoomLevel);
147 | var tileCoordTopRight = GetTileCoordinatesAtPoint(geoBBox.MaxLongitude, geoBBox.MaxLatitude, zoomLevel);
148 |
149 | // Cropping bounds, because coordinates of boundingBox can be beyond of CRS standard bounds
150 | var maxTileNumber = WebMercator.TileCount(zoomLevel) - 1;
151 | tileCoordBottomLeft.Y = Math.Min(tileCoordBottomLeft.Y, maxTileNumber);
152 | tileCoordTopRight.Y = Math.Max(tileCoordTopRight.Y, 0);
153 |
154 | // Using array for slightly better performance
155 | var totalNumber = (tileCoordTopRight.X - tileCoordBottomLeft.X + 1) * (tileCoordBottomLeft.Y - tileCoordTopRight.Y + 1);
156 | var result = new Models.TileCoordinates[totalNumber];
157 | var counter = 0;
158 | for (var tileX = tileCoordBottomLeft.X; tileX <= tileCoordTopRight.X; tileX++)
159 | {
160 | for (var tileY = tileCoordTopRight.Y; tileY <= tileCoordBottomLeft.Y; tileY++)
161 | {
162 | result[counter] = new Models.TileCoordinates(tileX, tileY, zoomLevel);
163 | counter++;
164 | }
165 | }
166 |
167 | return result;
168 | }
169 |
170 | private static int FindOptimalTileZoomLevel(int width, Models.GeographicalBounds geoBBox)
171 | {
172 | var mapSize = WebMercator.MapSize(width, geoBBox.MinLongitude, geoBBox.MaxLongitude);
173 | var minZoom = 0;
174 | var minDistance = Double.MaxValue;
175 | for (var zoom = 0; zoom < 24; zoom++) // TODO: range?
176 | {
177 | var mapSizeAtZoom = WebMercator.MapSize(zoom, WebMercator.DefaultTileSize); // TODO: ? use tile size parameter instead of const
178 | var distance = Math.Abs(mapSize - mapSizeAtZoom);
179 | if (distance < minDistance)
180 | {
181 | minDistance = distance;
182 | minZoom = zoom;
183 | }
184 | }
185 |
186 | return minZoom;
187 | }
188 |
189 | private static Models.TileCoordinates GetTileCoordinatesAtPoint(double longitude, double latitude, int zoomLevel) =>
190 | new Models.TileCoordinates(
191 | (int)Math.Floor(WebMercator.TileCoordinateXAtZoom(longitude, zoomLevel)),
192 | (int)Math.Floor(WebMercator.TileCoordinateYAtZoom(latitude, zoomLevel)),
193 | zoomLevel);
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/Src/TileMapService/MBTiles/Repository.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Data.Sqlite;
2 | using System.Collections.Generic;
3 |
4 | namespace TileMapService.MBTiles
5 | {
6 | ///
7 | /// Repository for MBTiles (MBTiles Specification) database access.
8 | ///
9 | ///
10 | /// Supports only Spherical Mercator tile grid and TMS tiling scheme (Y axis is going up).
11 | /// SQLite doesn't support asynchronous I/O. so the async ADO.NET methods will execute synchronously in Microsoft.Data.Sqlite.
12 | ///
13 | public class Repository
14 | {
15 | ///
16 | /// Connection string for SQLite database.
17 | ///
18 | private readonly string connectionString;
19 |
20 | #region MBTiles database objects names
21 |
22 | private const string TableTiles = "tiles";
23 |
24 | private const string ColumnTileColumn = "tile_column";
25 |
26 | private const string ColumnTileRow = "tile_row";
27 |
28 | private const string ColumnZoomLevel = "zoom_level";
29 |
30 | private const string ColumnTileData = "tile_data";
31 |
32 | private const string TableMetadata = "metadata";
33 |
34 | private const string ColumnMetadataName = "name";
35 |
36 | private const string ColumnMetadataValue = "value";
37 |
38 | private const string IndexTile = "tile_index";
39 |
40 | // TODO: Grids / UTFGrid
41 |
42 | private static readonly string ReadTileDataCommandText =
43 | $"SELECT {ColumnTileData} FROM {TableTiles} WHERE (({ColumnZoomLevel} = @zoom_level) AND ({ColumnTileColumn} = @tile_column) AND ({ColumnTileRow} = @tile_row))";
44 |
45 | private static readonly string ReadMetadataCommandText =
46 | $"SELECT {ColumnMetadataName}, {ColumnMetadataValue} FROM {TableMetadata}";
47 |
48 | #endregion
49 |
50 | ///
51 | /// Initializes a new instance of the class.
52 | ///
53 | /// Full path to MBTiles database file.
54 | /// Allows database modification if true.
55 | public Repository(string path, bool isFullAccess = false)
56 | {
57 | this.connectionString = new SqliteConnectionStringBuilder
58 | {
59 | DataSource = path,
60 | Mode = isFullAccess ? SqliteOpenMode.ReadWriteCreate : SqliteOpenMode.ReadOnly,
61 | Cache = SqliteCacheMode.Shared,
62 | }.ToString();
63 | }
64 |
65 | #region Read methods
66 |
67 | ///
68 | /// Reads tile image contents with given coordinates from database.
69 | ///
70 | /// Tile X coordinate (column).
71 | /// Tile Y coordinate (row), Y axis goes up from the bottom (TMS scheme).
72 | /// Tile Z coordinate (zoom level).
73 | /// Async Limitations
74 | /// Tile image contents.
75 | public byte[]? ReadTile(int tileColumn, int tileRow, int zoomLevel)
76 | {
77 | using var connection = new SqliteConnection(this.connectionString);
78 | using var command = new SqliteCommand(ReadTileDataCommandText, connection);
79 | command.Parameters.AddRange(new[]
80 | {
81 | new SqliteParameter("@tile_column", tileColumn),
82 | new SqliteParameter("@tile_row", tileRow),
83 | new SqliteParameter("@zoom_level", zoomLevel),
84 | });
85 |
86 | connection.Open();
87 | using var dr = command.ExecuteReader();
88 | byte[]? result = null;
89 |
90 | if (dr.Read())
91 | {
92 | result = (byte[])dr[0];
93 | }
94 |
95 | dr.Close();
96 |
97 | return result;
98 | }
99 |
100 | public byte[]? ReadFirstTile()
101 | {
102 | using var connection = new SqliteConnection(this.connectionString);
103 | using var command = new SqliteCommand($"SELECT {ColumnTileData} FROM {TableTiles} LIMIT 1", connection);
104 |
105 | connection.Open();
106 | using var dr = command.ExecuteReader();
107 | byte[]? result = null;
108 |
109 | if (dr.Read())
110 | {
111 | result = (byte[])dr[0];
112 | }
113 |
114 | dr.Close();
115 |
116 | return result;
117 | }
118 |
119 | public (int Min, int Max)? GetZoomLevelRange()
120 | {
121 | using var connection = new SqliteConnection(this.connectionString);
122 | using var command = new SqliteCommand($"SELECT MIN({ColumnZoomLevel}), MAX({ColumnZoomLevel}) FROM {TableTiles}", connection);
123 |
124 | connection.Open();
125 | using var dr = command.ExecuteReader();
126 |
127 | (int Min, int Max)? result = null;
128 | if (dr.Read())
129 | {
130 | result = (Min: dr.GetInt32(0), Max: dr.GetInt32(1));
131 | }
132 |
133 | dr.Close();
134 |
135 | return result;
136 | }
137 |
138 | ///
139 | /// Reads all metadata key/value items from database.
140 | ///
141 | /// Metadata records.
142 | public MetadataItem[] ReadMetadata()
143 | {
144 | using var connection = new SqliteConnection(this.connectionString);
145 | using var command = new SqliteCommand(ReadMetadataCommandText, connection);
146 | var result = new List();
147 |
148 | connection.Open();
149 | using (var dr = command.ExecuteReader())
150 | {
151 | while (dr.Read())
152 | {
153 | result.Add(new MetadataItem(dr.GetString(0), dr.IsDBNull(1) ? null : dr.GetString(1)));
154 | }
155 |
156 | dr.Close();
157 | }
158 |
159 | return result.ToArray();
160 | }
161 |
162 | #endregion
163 |
164 | #region Create / Update methods
165 |
166 | public static Repository CreateEmptyDatabase(string path)
167 | {
168 | var repository = new Repository(path, true);
169 |
170 | var createMetadataCommand = $"CREATE TABLE {TableMetadata} ({ColumnMetadataName} text, {ColumnMetadataValue} text)";
171 | repository.ExecuteSqlQuery(createMetadataCommand);
172 |
173 | var createTilesCommand = $"CREATE TABLE {TableTiles} ({ColumnZoomLevel} integer, {ColumnTileColumn} integer, {ColumnTileRow} integer, {ColumnTileData} blob)";
174 | repository.ExecuteSqlQuery(createTilesCommand);
175 |
176 | var createTileIndexCommand = $"CREATE UNIQUE INDEX {IndexTile} ON {TableTiles} ({ColumnZoomLevel}, {ColumnTileColumn}, {ColumnTileRow})";
177 | repository.ExecuteSqlQuery(createTileIndexCommand);
178 |
179 | return repository;
180 | }
181 |
182 | ///
183 | /// Inserts tile image with coordinates into 'tiles' table.
184 | ///
185 | /// Tile X coordinate (column).
186 | /// Tile Y coordinate (row), Y axis goes up from the bottom (TMS scheme).
187 | /// Tile Z coordinate (zoom level).
188 | /// Tile image contents.
189 | public void AddTile(int tileColumn, int tileRow, int zoomLevel, byte[] tileData)
190 | {
191 | var commandText = @$"INSERT INTO {TableTiles}
192 | ({ColumnTileColumn}, {ColumnTileRow}, {ColumnZoomLevel}, {ColumnTileData})
193 | VALUES
194 | (@{ColumnTileColumn}, @{ColumnTileRow}, @{ColumnZoomLevel}, @{ColumnTileData})";
195 |
196 | using var connection = new SqliteConnection(this.connectionString);
197 | using var command = new SqliteCommand(commandText, connection);
198 | command.Parameters.Add(new SqliteParameter($"@{ColumnTileColumn}", tileColumn));
199 | command.Parameters.Add(new SqliteParameter($"@{ColumnTileRow}", tileRow));
200 | command.Parameters.Add(new SqliteParameter($"@{ColumnZoomLevel}", zoomLevel));
201 | command.Parameters.Add(new SqliteParameter($"@{ColumnTileData}", tileData));
202 | connection.Open();
203 | command.ExecuteNonQuery();
204 | }
205 |
206 | ///
207 | /// Inserts given metadata item into 'metadata' table.
208 | ///
209 | /// Key/value metadata item.
210 | public void AddMetadataItem(MetadataItem item)
211 | {
212 | using var connection = new SqliteConnection(this.connectionString);
213 | var commandText = @$"INSERT INTO {TableMetadata}
214 | ({ColumnMetadataName}, {ColumnMetadataValue})
215 | VALUES
216 | (@{ColumnMetadataName}, @{ColumnMetadataValue})";
217 |
218 | using var command = new SqliteCommand(commandText, connection);
219 | command.Parameters.Add(new SqliteParameter($"@{ColumnMetadataName}", item.Name));
220 | command.Parameters.Add(new SqliteParameter($"@{ColumnMetadataValue}", item.Value));
221 | connection.Open();
222 | command.ExecuteNonQuery();
223 | }
224 |
225 | private void ExecuteSqlQuery(string commandText)
226 | {
227 | using var connection = new SqliteConnection(this.connectionString);
228 | using var command = new SqliteCommand(commandText, connection);
229 | connection.Open();
230 | command.ExecuteNonQuery();
231 | }
232 |
233 | #endregion
234 | }
235 | }
236 |
--------------------------------------------------------------------------------