aboutsummaryrefslogtreecommitdiff
path: root/src/Costasdev.Busurbano.Backend
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-12 10:24:43 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-12 10:24:54 +0100
commit3a1a1e6dc2f6f0abceac5da0cfb530fdb45fc6f5 (patch)
tree0b887eece835ff12ebd2eea831483407223e1a22 /src/Costasdev.Busurbano.Backend
parentd65ce8288bbda3cb6e0b37613c29d7bf52703ba7 (diff)
Initial ultra-ñapa implementation of OTP integration
Diffstat (limited to 'src/Costasdev.Busurbano.Backend')
-rw-r--r--src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs9
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs61
-rw-r--r--src/Costasdev.Busurbano.Backend/Program.cs1
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/OtpService.cs271
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs187
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs75
6 files changed, 604 insertions, 0 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs
index 49c001f..a61fdb6 100644
--- a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs
+++ b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs
@@ -4,4 +4,13 @@ public class AppConfiguration
{
public required string VitrasaScheduleBasePath { get; set; }
public required string RenfeScheduleBasePath { get; set; }
+
+ public string OtpGeocodingBaseUrl { get; set; } = "https://planificador-rutas-api.vigo.org/v1";
+ public string OtpPlannerBaseUrl { get; set; } = "https://planificador-rutas.vigo.org/otp/routers/default";
+
+ // Default Routing Parameters
+ public double WalkSpeed { get; set; } = 1.4;
+ public int MaxWalkDistance { get; set; } = 1000;
+ public int MaxWalkTime { get; set; } = 20;
+ public int NumItineraries { get; set; } = 4;
}
diff --git a/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs b/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs
new file mode 100644
index 0000000..efddf82
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs
@@ -0,0 +1,61 @@
+using Costasdev.Busurbano.Backend.Services;
+using Costasdev.Busurbano.Backend.Types.Planner;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Costasdev.Busurbano.Backend.Controllers;
+
+[ApiController]
+[Route("api/planner")]
+public class RoutePlannerController : ControllerBase
+{
+ private readonly OtpService _otpService;
+
+ public RoutePlannerController(OtpService otpService)
+ {
+ _otpService = otpService;
+ }
+
+ [HttpGet("autocomplete")]
+ public async Task<ActionResult<List<PlannerSearchResult>>> Autocomplete([FromQuery] string query)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ {
+ return BadRequest("Query cannot be empty");
+ }
+
+ var results = await _otpService.GetAutocompleteAsync(query);
+ return Ok(results);
+ }
+
+ [HttpGet("reverse")]
+ public async Task<ActionResult<PlannerSearchResult>> Reverse([FromQuery] double lat, [FromQuery] double lon)
+ {
+ var result = await _otpService.GetReverseGeocodeAsync(lat, lon);
+ if (result == null)
+ {
+ return NotFound();
+ }
+ return Ok(result);
+ }
+
+ [HttpGet("plan")]
+ public async Task<ActionResult<RoutePlan>> Plan(
+ [FromQuery] double fromLat,
+ [FromQuery] double fromLon,
+ [FromQuery] double toLat,
+ [FromQuery] double toLon,
+ [FromQuery] DateTime? time = null,
+ [FromQuery] bool arriveBy = false)
+ {
+ try
+ {
+ var plan = await _otpService.GetRoutePlanAsync(fromLat, fromLon, toLat, toLon, time, arriveBy);
+ return Ok(plan);
+ }
+ catch (Exception)
+ {
+ // Log error
+ return StatusCode(500, "An error occurred while planning the route.");
+ }
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs
index 959e114..74c6337 100644
--- a/src/Costasdev.Busurbano.Backend/Program.cs
+++ b/src/Costasdev.Busurbano.Backend/Program.cs
@@ -11,6 +11,7 @@ builder.Services.AddHttpClient();
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<ShapeTraversalService>();
+builder.Services.AddHttpClient<OtpService>();
builder.Services.AddScoped<VitrasaTransitProvider>();
builder.Services.AddScoped<RenfeTransitProvider>();
diff --git a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs
new file mode 100644
index 0000000..4c22ff5
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs
@@ -0,0 +1,271 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using Costasdev.Busurbano.Backend.Configuration;
+using Costasdev.Busurbano.Backend.Types.Otp;
+using Costasdev.Busurbano.Backend.Types.Planner;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace Costasdev.Busurbano.Backend.Services;
+
+public class OtpService
+{
+ private readonly HttpClient _httpClient;
+ private readonly AppConfiguration _config;
+ private readonly IMemoryCache _cache;
+ private readonly ILogger<OtpService> _logger;
+
+ public OtpService(HttpClient httpClient, IOptions<AppConfiguration> config, IMemoryCache cache, ILogger<OtpService> logger)
+ {
+ _httpClient = httpClient;
+ _config = config.Value;
+ _cache = cache;
+ _logger = logger;
+ }
+
+ public async Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query)
+ {
+ if (string.IsNullOrWhiteSpace(query)) return new List<PlannerSearchResult>();
+
+ var cacheKey = $"otp_autocomplete_{query.ToLowerInvariant()}";
+ if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedResults) && cachedResults != null)
+ {
+ return cachedResults;
+ }
+
+ try
+ {
+ // https://planificador-rutas-api.vigo.org/v1/autocomplete?text=XXXX&layers=venue,street,address&lang=es
+ var url = $"{_config.OtpGeocodingBaseUrl}/autocomplete?text={Uri.EscapeDataString(query)}&layers=venue,street,address&lang=es";
+ var response = await _httpClient.GetFromJsonAsync<OtpGeocodeResponse>(url);
+
+ var results = response?.Features.Select(f => new PlannerSearchResult
+ {
+ Name = f.Properties?.Name,
+ Label = f.Properties?.Label,
+ Layer = f.Properties?.Layer,
+ Lat = f.Geometry?.Coordinates.Count > 1 ? f.Geometry.Coordinates[1] : 0,
+ Lon = f.Geometry?.Coordinates.Count > 0 ? f.Geometry.Coordinates[0] : 0
+ }).ToList() ?? new List<PlannerSearchResult>();
+
+ _cache.Set(cacheKey, results, TimeSpan.FromMinutes(30)); // Cache for 30 mins
+ return results;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching autocomplete results");
+ return new List<PlannerSearchResult>();
+ }
+ }
+
+ public async Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon)
+ {
+ var cacheKey = $"otp_reverse_{lat:F5}_{lon:F5}";
+ if (_cache.TryGetValue(cacheKey, out PlannerSearchResult? cachedResult) && cachedResult != null)
+ {
+ return cachedResult;
+ }
+
+ try
+ {
+ // https://planificador-rutas-api.vigo.org/v1/reverse?point.lat=LAT&point.lon=LON&lang=es
+ var url = $"{_config.OtpGeocodingBaseUrl}/reverse?point.lat={lat.ToString(CultureInfo.InvariantCulture)}&point.lon={lon.ToString(CultureInfo.InvariantCulture)}&lang=es";
+ var response = await _httpClient.GetFromJsonAsync<OtpGeocodeResponse>(url);
+
+ var feature = response?.Features.FirstOrDefault();
+ if (feature == null) return null;
+
+ var result = new PlannerSearchResult
+ {
+ Name = feature.Properties?.Name,
+ Label = feature.Properties?.Label,
+ Layer = feature.Properties?.Layer,
+ Lat = feature.Geometry?.Coordinates.Count > 1 ? feature.Geometry.Coordinates[1] : 0,
+ Lon = feature.Geometry?.Coordinates.Count > 0 ? feature.Geometry.Coordinates[0] : 0
+ };
+
+ _cache.Set(cacheKey, result, TimeSpan.FromMinutes(60)); // Cache for 1 hour
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching reverse geocode results");
+ return null;
+ }
+ }
+
+ public async Task<RoutePlan> GetRoutePlanAsync(double fromLat, double fromLon, double toLat, double toLon, DateTime? time = null, bool arriveBy = false)
+ {
+ try
+ {
+ var date = time ?? DateTime.Now;
+ var dateStr = date.ToString("MM/dd/yyyy", CultureInfo.InvariantCulture);
+ var timeStr = date.ToString("h:mm tt", CultureInfo.InvariantCulture);
+
+ var queryParams = new Dictionary<string, string>
+ {
+ { "fromPlace", $"{fromLat.ToString(CultureInfo.InvariantCulture)},{fromLon.ToString(CultureInfo.InvariantCulture)}" },
+ { "toPlace", $"{toLat.ToString(CultureInfo.InvariantCulture)},{toLon.ToString(CultureInfo.InvariantCulture)}" },
+ { "arriveBy", arriveBy.ToString().ToLower() },
+ { "date", dateStr },
+ { "time", timeStr },
+ { "locale", "es" },
+ { "showIntermediateStops", "true" },
+ { "mode", "TRANSIT,WALK" },
+ { "numItineraries", _config.NumItineraries.ToString() },
+ { "walkSpeed", _config.WalkSpeed.ToString(CultureInfo.InvariantCulture) },
+ { "maxWalkDistance", _config.MaxWalkDistance.ToString() }, // Note: OTP might ignore this if it's too small
+ { "optimize", "QUICK" },
+ { "wheelchair", "false" }
+ };
+
+ var queryString = string.Join("&", queryParams.Select(kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}"));
+ var url = $"{_config.OtpPlannerBaseUrl}/plan?{queryString}";
+
+ var response = await _httpClient.GetFromJsonAsync<OtpResponse>(url);
+
+ if (response?.Plan == null)
+ {
+ return new RoutePlan();
+ }
+
+ return MapToRoutePlan(response.Plan);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching route plan");
+ throw;
+ }
+ }
+
+ private RoutePlan MapToRoutePlan(OtpPlan otpPlan)
+ {
+ return new RoutePlan
+ {
+ Itineraries = otpPlan.Itineraries.Select(MapItinerary).ToList()
+ };
+ }
+
+ private Itinerary MapItinerary(OtpItinerary otpItinerary)
+ {
+ return new Itinerary
+ {
+ DurationSeconds = otpItinerary.Duration,
+ StartTime = DateTimeOffset.FromUnixTimeMilliseconds(otpItinerary.StartTime).LocalDateTime, // Assuming local time or handling timezone
+ EndTime = DateTimeOffset.FromUnixTimeMilliseconds(otpItinerary.EndTime).LocalDateTime,
+ WalkDistanceMeters = otpItinerary.WalkDistance,
+ WalkTimeSeconds = otpItinerary.WalkTime,
+ TransitTimeSeconds = otpItinerary.TransitTime,
+ WaitingTimeSeconds = otpItinerary.WaitingTime,
+ Legs = otpItinerary.Legs.Select(MapLeg).ToList()
+ };
+ }
+
+ private Leg MapLeg(OtpLeg otpLeg)
+ {
+ return new Leg
+ {
+ Mode = otpLeg.Mode,
+ RouteName = otpLeg.Route,
+ RouteShortName = otpLeg.RouteShortName,
+ RouteLongName = otpLeg.RouteLongName,
+ Headsign = otpLeg.Headsign,
+ AgencyName = otpLeg.AgencyName,
+ From = MapPlace(otpLeg.From),
+ To = MapPlace(otpLeg.To),
+ StartTime = DateTimeOffset.FromUnixTimeMilliseconds(otpLeg.StartTime).LocalDateTime,
+ EndTime = DateTimeOffset.FromUnixTimeMilliseconds(otpLeg.EndTime).LocalDateTime,
+ Geometry = DecodePolyline(otpLeg.LegGeometry?.Points),
+ Steps = otpLeg.Steps.Select(MapStep).ToList()
+ };
+ }
+
+ private PlannerPlace? MapPlace(OtpPlace? otpPlace)
+ {
+ if (otpPlace == null) return null;
+ return new PlannerPlace
+ {
+ Name = otpPlace.Name,
+ Lat = otpPlace.Lat,
+ Lon = otpPlace.Lon,
+ StopId = otpPlace.StopId, // Use string directly
+ StopCode = otpPlace.StopCode
+ };
+ }
+
+ private Step MapStep(OtpWalkStep otpStep)
+ {
+ return new Step
+ {
+ DistanceMeters = otpStep.Distance,
+ RelativeDirection = otpStep.RelativeDirection,
+ AbsoluteDirection = otpStep.AbsoluteDirection,
+ StreetName = otpStep.StreetName,
+ Lat = otpStep.Lat,
+ Lon = otpStep.Lon
+ };
+ }
+
+ private PlannerGeometry? DecodePolyline(string? encodedPoints)
+ {
+ if (string.IsNullOrEmpty(encodedPoints)) return null;
+
+ var coordinates = Decode(encodedPoints);
+ return new PlannerGeometry
+ {
+ Coordinates = coordinates.Select(c => new List<double> { c.Lon, c.Lat }).ToList()
+ };
+ }
+
+ // Polyline decoding algorithm
+ private static List<(double Lat, double Lon)> Decode(string encodedPoints)
+ {
+ if (string.IsNullOrEmpty(encodedPoints))
+ return new List<(double, double)>();
+
+ var poly = new List<(double, double)>();
+ char[] polylineChars = encodedPoints.ToCharArray();
+ int index = 0;
+
+ int currentLat = 0;
+ int currentLng = 0;
+ int next5bits;
+ int sum;
+ int shifter;
+
+ while (index < polylineChars.Length)
+ {
+ // calculate next latitude
+ sum = 0;
+ shifter = 0;
+ do
+ {
+ next5bits = (int)polylineChars[index++] - 63;
+ sum |= (next5bits & 31) << shifter;
+ shifter += 5;
+ } while (next5bits >= 32 && index < polylineChars.Length);
+
+ if (index >= polylineChars.Length)
+ break;
+
+ currentLat += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1);
+
+ // calculate next longitude
+ sum = 0;
+ shifter = 0;
+ do
+ {
+ next5bits = (int)polylineChars[index++] - 63;
+ sum |= (next5bits & 31) << shifter;
+ shifter += 5;
+ } while (next5bits >= 32 && index < polylineChars.Length);
+
+ currentLng += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1);
+
+ poly.Add((Convert.ToDouble(currentLat) / 100000.0, Convert.ToDouble(currentLng) / 100000.0));
+ }
+
+ return poly;
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs
new file mode 100644
index 0000000..3d3de17
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs
@@ -0,0 +1,187 @@
+using System.Text.Json.Serialization;
+
+namespace Costasdev.Busurbano.Backend.Types.Otp;
+
+public class OtpResponse
+{
+ [JsonPropertyName("plan")]
+ public OtpPlan? Plan { get; set; }
+
+ [JsonPropertyName("error")]
+ public OtpError? Error { get; set; }
+}
+
+public class OtpError
+{
+ [JsonPropertyName("id")]
+ public int Id { get; set; }
+
+ [JsonPropertyName("msg")]
+ public string? Msg { get; set; }
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+}
+
+public class OtpPlan
+{
+ [JsonPropertyName("date")]
+ public long Date { get; set; }
+
+ [JsonPropertyName("from")]
+ public OtpPlace? From { get; set; }
+
+ [JsonPropertyName("to")]
+ public OtpPlace? To { get; set; }
+
+ [JsonPropertyName("itineraries")]
+ public List<OtpItinerary> Itineraries { get; set; } = new();
+}
+
+public class OtpItinerary
+{
+ [JsonPropertyName("duration")]
+ public long Duration { get; set; }
+
+ [JsonPropertyName("startTime")]
+ public long StartTime { get; set; }
+
+ [JsonPropertyName("endTime")]
+ public long EndTime { get; set; }
+
+ [JsonPropertyName("walkTime")]
+ public long WalkTime { get; set; }
+
+ [JsonPropertyName("transitTime")]
+ public long TransitTime { get; set; }
+
+ [JsonPropertyName("waitingTime")]
+ public long WaitingTime { get; set; }
+
+ [JsonPropertyName("walkDistance")]
+ public double WalkDistance { get; set; }
+
+ [JsonPropertyName("legs")]
+ public List<OtpLeg> Legs { get; set; } = new();
+}
+
+public class OtpLeg
+{
+ [JsonPropertyName("startTime")]
+ public long StartTime { get; set; }
+
+ [JsonPropertyName("endTime")]
+ public long EndTime { get; set; }
+
+ [JsonPropertyName("mode")]
+ public string? Mode { get; set; }
+
+ [JsonPropertyName("route")]
+ public string? Route { get; set; }
+
+ [JsonPropertyName("routeShortName")]
+ public string? RouteShortName { get; set; }
+
+ [JsonPropertyName("routeLongName")]
+ public string? RouteLongName { get; set; }
+
+ [JsonPropertyName("agencyName")]
+ public string? AgencyName { get; set; }
+
+ [JsonPropertyName("from")]
+ public OtpPlace? From { get; set; }
+
+ [JsonPropertyName("to")]
+ public OtpPlace? To { get; set; }
+
+ [JsonPropertyName("legGeometry")]
+ public OtpGeometry? LegGeometry { get; set; }
+
+ [JsonPropertyName("steps")]
+ public List<OtpWalkStep> Steps { get; set; } = new();
+
+ [JsonPropertyName("headsign")]
+ public string? Headsign { get; set; }
+}
+
+public class OtpPlace
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("lat")]
+ public double Lat { get; set; }
+
+ [JsonPropertyName("lon")]
+ public double Lon { get; set; }
+
+ [JsonPropertyName("stopId")]
+ public string? StopId { get; set; }
+
+ [JsonPropertyName("stopCode")]
+ public string? StopCode { get; set; }
+}
+
+public class OtpGeometry
+{
+ [JsonPropertyName("points")]
+ public string? Points { get; set; }
+
+ [JsonPropertyName("length")]
+ public int Length { get; set; }
+}
+
+public class OtpWalkStep
+{
+ [JsonPropertyName("distance")]
+ public double Distance { get; set; }
+
+ [JsonPropertyName("relativeDirection")]
+ public string? RelativeDirection { get; set; }
+
+ [JsonPropertyName("streetName")]
+ public string? StreetName { get; set; }
+
+ [JsonPropertyName("absoluteDirection")]
+ public string? AbsoluteDirection { get; set; }
+
+ [JsonPropertyName("lat")]
+ public double Lat { get; set; }
+
+ [JsonPropertyName("lon")]
+ public double Lon { get; set; }
+}
+
+// Geocoding Models (Pelias-like)
+public class OtpGeocodeResponse
+{
+ [JsonPropertyName("features")]
+ public List<OtpGeocodeFeature> Features { get; set; } = new();
+}
+
+public class OtpGeocodeFeature
+{
+ [JsonPropertyName("geometry")]
+ public OtpGeocodeGeometry? Geometry { get; set; }
+
+ [JsonPropertyName("properties")]
+ public OtpGeocodeProperties? Properties { get; set; }
+}
+
+public class OtpGeocodeGeometry
+{
+ [JsonPropertyName("coordinates")]
+ public List<double> Coordinates { get; set; } = new(); // [lon, lat]
+}
+
+public class OtpGeocodeProperties
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("label")]
+ public string? Label { get; set; }
+
+ [JsonPropertyName("layer")]
+ public string? Layer { get; set; }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs
new file mode 100644
index 0000000..30e5e2d
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs
@@ -0,0 +1,75 @@
+using System.Text.Json.Serialization;
+
+namespace Costasdev.Busurbano.Backend.Types.Planner;
+
+public class RoutePlan
+{
+ public List<Itinerary> Itineraries { get; set; } = new();
+}
+
+public class Itinerary
+{
+ public double DurationSeconds { get; set; }
+ public DateTime StartTime { get; set; }
+ public DateTime EndTime { get; set; }
+ public double WalkDistanceMeters { get; set; }
+ public double WalkTimeSeconds { get; set; }
+ public double TransitTimeSeconds { get; set; }
+ public double WaitingTimeSeconds { get; set; }
+ public List<Leg> Legs { get; set; } = new();
+}
+
+public class Leg
+{
+ public string? Mode { get; set; } // WALK, BUS, etc.
+ public string? RouteName { get; set; }
+ public string? RouteShortName { get; set; }
+ public string? RouteLongName { get; set; }
+ public string? Headsign { get; set; }
+ public string? AgencyName { get; set; }
+ public PlannerPlace? From { get; set; }
+ public PlannerPlace? To { get; set; }
+ public DateTime StartTime { get; set; }
+ public DateTime EndTime { get; set; }
+ public double DistanceMeters { get; set; }
+
+ // GeoJSON LineString geometry
+ public PlannerGeometry? Geometry { get; set; }
+
+ public List<Step> Steps { get; set; } = new();
+}
+
+public class PlannerPlace
+{
+ public string? Name { get; set; }
+ public double Lat { get; set; }
+ public double Lon { get; set; }
+ public string? StopId { get; set; }
+ public string? StopCode { get; set; }
+}
+
+public class PlannerGeometry
+{
+ public string Type { get; set; } = "LineString";
+ public List<List<double>> Coordinates { get; set; } = new(); // [[lon, lat], ...]
+}
+
+public class Step
+{
+ public double DistanceMeters { get; set; }
+ public string? RelativeDirection { get; set; }
+ public string? AbsoluteDirection { get; set; }
+ public string? StreetName { get; set; }
+ public double Lat { get; set; }
+ public double Lon { get; set; }
+}
+
+// For Autocomplete/Reverse
+public class PlannerSearchResult
+{
+ public string? Name { get; set; }
+ public string? Label { get; set; }
+ public double Lat { get; set; }
+ public double Lon { get; set; }
+ public string? Layer { get; set; }
+}