├── 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 | ![Demo page](https://github.com/apdevelop/tile-map-service/blob/master/Docs/demo-page.png) 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 | --------------------------------------------------------------------------------