commit 8b106a8319f9091cbb093e884c56ccddfab4a9c6 Author: Pedro Date: Tue Jul 7 14:18:51 2026 +0200 Initial clean RutasDBUS app diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..e40201c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,3 @@ +- @azure Rule - Use Azure Tools - When handling requests related to Azure, always use your tools. +- @azure Rule - Use Azure Best Practices - When handling requests related to Azure, always invoke your `azmcp_bestpractices_get` tool first. +- @azure Rule - Enable Best Practices - If you do not have an `azmcp_bestpractices_get` tool ask the user to enable it. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed6d1d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,402 @@ +# ---> VisualStudio +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +# but not Directory.Build.rsp, as it configures directory-level build defaults +!Directory.Build.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/RutasDBUS.sln b/RutasDBUS.sln new file mode 100644 index 0000000..fa81dc6 --- /dev/null +++ b/RutasDBUS.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RutasDBUS", "RutasDBUS\RutasDBUS.csproj", "{FABFF09E-333D-49F2-9A57-D24694E953C6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FABFF09E-333D-49F2-9A57-D24694E953C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FABFF09E-333D-49F2-9A57-D24694E953C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FABFF09E-333D-49F2-9A57-D24694E953C6}.Debug|x64.ActiveCfg = Debug|Any CPU + {FABFF09E-333D-49F2-9A57-D24694E953C6}.Debug|x64.Build.0 = Debug|Any CPU + {FABFF09E-333D-49F2-9A57-D24694E953C6}.Debug|x86.ActiveCfg = Debug|Any CPU + {FABFF09E-333D-49F2-9A57-D24694E953C6}.Debug|x86.Build.0 = Debug|Any CPU + {FABFF09E-333D-49F2-9A57-D24694E953C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FABFF09E-333D-49F2-9A57-D24694E953C6}.Release|Any CPU.Build.0 = Release|Any CPU + {FABFF09E-333D-49F2-9A57-D24694E953C6}.Release|x64.ActiveCfg = Release|Any CPU + {FABFF09E-333D-49F2-9A57-D24694E953C6}.Release|x64.Build.0 = Release|Any CPU + {FABFF09E-333D-49F2-9A57-D24694E953C6}.Release|x86.ActiveCfg = Release|Any CPU + {FABFF09E-333D-49F2-9A57-D24694E953C6}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/RutasDBUS/Components/App.razor b/RutasDBUS/Components/App.razor new file mode 100644 index 0000000..41d455b --- /dev/null +++ b/RutasDBUS/Components/App.razor @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/RutasDBUS/Components/Layout/MainLayout.razor b/RutasDBUS/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..71c2cd4 --- /dev/null +++ b/RutasDBUS/Components/Layout/MainLayout.razor @@ -0,0 +1,23 @@ +@inherits LayoutComponentBase + +
+@* *@ + +
+@*
+ About +
+ *@ +
+ @Body +
+
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/RutasDBUS/Components/Layout/MainLayout.razor.css b/RutasDBUS/Components/Layout/MainLayout.razor.css new file mode 100644 index 0000000..038baf1 --- /dev/null +++ b/RutasDBUS/Components/Layout/MainLayout.razor.css @@ -0,0 +1,96 @@ +.page { + position: relative; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.sidebar { + background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); +} + +.top-row { + background-color: #f7f7f7; + border-bottom: 1px solid #d6d5d5; + justify-content: flex-end; + height: 3.5rem; + display: flex; + align-items: center; +} + + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + +@media (max-width: 640.98px) { + .top-row { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } +} + +@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/RutasDBUS/Components/Layout/NavMenu.razor b/RutasDBUS/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..c5b0117 --- /dev/null +++ b/RutasDBUS/Components/Layout/NavMenu.razor @@ -0,0 +1,118 @@ + + + + + + + + +@*
+ Flujo de selección +

@TextoPasoActual

+ +
+
+ Origen: + @if (_paradaOrigenSeleccionada is not null) + { + + @_paradaOrigenSeleccionada.Nombre (@_paradaOrigenSeleccionada.Codigo) + + } + else if (_coordenadasOrigen is not null) + { + Seleccionado en el mapa + } + else + { + Pendiente + } +
+ +
+ Destino: + @if (_paradaDestinoSeleccionada is not null) + { + + @_paradaDestinoSeleccionada.Nombre (@_paradaDestinoSeleccionada.Codigo) + + } + else if (_coordenadasDestino is not null) + { + Seleccionado en el mapa + } + else + { + Pendiente + } +
+ + @if (_primerTransbordoseleccionado is not null) + { +
+ 1er Transbordo: + + @_primerTransbordoseleccionado.Nombre (@_primerTransbordoseleccionado.Codigo) + +
+ } + + @* Info de la última parada clicada *@ + @* @if (_paradaClicada is not null) + { +
+
+ Parada clicada: + + @_paradaClicada.Nombre (@_paradaClicada.Codigo) + +
+
+ @if (!string.IsNullOrWhiteSpace(_paradaClicada.Abreviatura)) + { + Abrev.: @_paradaClicada.Abreviatura + } + @if (!string.IsNullOrWhiteSpace(_paradaClicada.NombreCorto)) + { + · Corto: @_paradaClicada.NombreCorto + } + @if (_paradaClicada.Altura > 0) + { + · Altura: @_paradaClicada.Altura.ToString("0") m + } +
+ } +
+ +
+ +
+
*@ \ No newline at end of file diff --git a/RutasDBUS/Components/Layout/NavMenu.razor.css b/RutasDBUS/Components/Layout/NavMenu.razor.css new file mode 100644 index 0000000..4e15395 --- /dev/null +++ b/RutasDBUS/Components/Layout/NavMenu.razor.css @@ -0,0 +1,105 @@ +.navbar-toggler { + appearance: none; + cursor: pointer; + width: 3.5rem; + height: 2.5rem; + color: white; + position: absolute; + top: 0.5rem; + right: 1rem; + border: 1px solid rgba(255, 255, 255, 0.1); + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1); +} + +.navbar-toggler:checked { + background-color: rgba(255, 255, 255, 0.5); +} + +.top-row { + height: 3.5rem; + background-color: rgba(0,0,0,0.4); +} + +.navbar-brand { + font-size: 1.1rem; +} + +.bi { + display: inline-block; + position: relative; + width: 1.25rem; + height: 1.25rem; + margin-right: 0.75rem; + top: -1px; + background-size: cover; +} + +.bi-house-door-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); +} + +.bi-plus-square-fill-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); +} + +.bi-list-nested-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); +} + +.nav-item { + font-size: 0.9rem; + padding-bottom: 0.5rem; +} + + .nav-item:first-of-type { + padding-top: 1rem; + } + + .nav-item:last-of-type { + padding-bottom: 1rem; + } + + .nav-item ::deep .nav-link { + color: #d7d7d7; + background: none; + border: none; + border-radius: 4px; + height: 3rem; + display: flex; + align-items: center; + line-height: 3rem; + width: 100%; + } + +.nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; +} + +.nav-item ::deep .nav-link:hover { + background-color: rgba(255,255,255,0.1); + color: white; +} + +.nav-scrollable { + display: none; +} + +.navbar-toggler:checked ~ .nav-scrollable { + display: block; +} + +@media (min-width: 641px) { + .navbar-toggler { + display: none; + } + + .nav-scrollable { + /* Never collapse the sidebar for wide screens */ + display: block; + + /* Allow sidebar to scroll for tall menus */ + height: calc(100vh - 3.5rem); + overflow-y: auto; + } +} diff --git a/RutasDBUS/Components/Pages/Error.razor b/RutasDBUS/Components/Pages/Error.razor new file mode 100644 index 0000000..576cc2d --- /dev/null +++ b/RutasDBUS/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs new file mode 100644 index 0000000..78d8d60 --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs @@ -0,0 +1,496 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using LeafletForBlazor; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using RutasDBUS.Modelos.Planificacion; +using RutasDBUS.Modelos.RedBus; +using RutasDBUS.Servicios; +using RutasDBUS.Servicios.Caminata; +using RutasDBUS.Servicios.Horarios; +using RutasDBUS.Servicios.Planificacion; +using RutasDBUS.Servicios.RedBus; + +namespace RutasDBUS.Components.Pages; + +public partial class PlanificadorRutas : ComponentBase, IDisposable +{ + private DotNetObjectReference? _dotRef; + + private enum HorarioOrden + { + PorHora = 0, + PorItinerario = 1 + } + + private HorarioOrden _horarioOrden = HorarioOrden.PorHora; + + private string HorarioOrdenIcono => + _horarioOrden == HorarioOrden.PorHora ? "\u23F0" : "\U0001F68C"; + + private string HorarioOrdenTooltip => + _horarioOrden == HorarioOrden.PorHora + ? "Orden actual: por hora. Pulsar para ordenar por itinerario" + : "Orden actual: por itinerario. Pulsar para ordenar por hora"; + + + /// + /// Gestiona after render. + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dotRef = DotNetObjectReference.Create(this); + + try { await JS.InvokeVoidAsync("initBrilloMapa", _dotRef); } catch (Exception ex) { Console.WriteLine("initBrilloMapa: " + ex); } + try { await JS.InvokeVoidAsync("initAltInspector", _dotRef); } catch (Exception ex) { Console.WriteLine("initAltInspector: " + ex); } + try { await JS.InvokeVoidAsync("rt.setDotNetHelper", _dotRef); } catch (Exception ex) { Console.WriteLine("rt.setDotNetHelper: " + ex); } + try { await JS.InvokeVoidAsync("rt.blockLeafletDoubleClick"); } catch (Exception ex) { Console.WriteLine("rt.blockLeafletDoubleClick: " + ex); } + try { await JS.InvokeVoidAsync("rt.initDraggablePopups"); } catch (Exception ex) { Console.WriteLine("rt.initDraggablePopups: " + ex); } + await CargarConfiguracionLocalAsync(); + await CargarHistorialLocalAsync(); + await CargarHistorialCompartidoAsync(); + await CargarModelosHorariosAsync(); + + if (_usarHoraSimulada) + await RefrescarHoraUIAsync(); + else + StartClock(); + + if (_usarHorariosTeoricos) + await EnsureHorariosLoadedAsync(); + + _tareaCargaRed = RedBusServicio.CargarRedAsync(); + StateHasChanged(); + return; + } + + _tareaCargaRed ??= RedBusServicio.CargarRedAsync(); + if (!_tareaCargaRed.IsCompleted) return; + + await _tareaCargaRed; + + if (RedBusServicio.Paradas.Count > 0) + EnsureBusIndexBuilt(); + + if (!_zoomLimitsAplicados && _mapa is not null) + { + _zoomLimitsAplicados = true; + await JS.InvokeVoidAsync("rt.setZoomLimits", 3, 19); + } + + try { await JS.InvokeVoidAsync("rt.addZoomIndicator", new { fakeZoom19 = true }); } catch { } + + if (!_paradasPintadas && !_pintandoParadas && _mapa is not null && RedBusServicio.Paradas.Count > 0) + { + _pintandoParadas = true; + try + { + if (_mostrarParadas) + await DibujarParadasAsync(); + else + await OcultarParadasAsync(); + + _paradasPintadas = true; + } + finally + { + _pintandoParadas = false; + } + + StateHasChanged(); + + if (_itItems.Count == 0 && RedBusServicio.Paradas.Count > 0) + { + BuildItinerariosDropdown(); + SincronizarSeleccionItinerarioConFiltro(); + + StateHasChanged(); + } + } + + // ✅ ESTO VA FUERA + if (_scrollHorariosPendiente) + { + _scrollHorariosPendiente = false; + try { await JS.InvokeVoidAsync("rt.scrollToHorarioProximo"); } catch { } + } + } + private bool _scrollHorariosPendiente = false; + // ============================= + // PREVIEWS ITINERARIOS (por sentido real) + // ============================= + + // ids reales pintados (subIds): it_xxx_IV_I, it_xxx_IV_V, it_xxx_I_3, etc. + private readonly HashSet _itPreviewActivos = new(StringComparer.OrdinalIgnoreCase); + + // Mapa: clave lógica "26.12-I" -> ids reales pintados (pueden ser 1 o varios) + private readonly Dictionary> _itPreviewIdsPorClave = + new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gestiona clave. + /// + private static string ItClave(InfoVariante v) + { + var linea = v.Linea?.Codigo?.ToString() ?? "?"; + var itin = v.CodigoItinerario.ToString(CultureInfo.InvariantCulture); + var sentido = NormalizarSentidoCodigo(v.Sentido?.Codigo); // I / V + return $"{linea}.{itin}-{sentido}"; + } + + /// + /// Gestiona vista previa. + /// + private void RegistrarPreview(string clave, string subId) + { + _itPreviewActivos.Add(subId); + + if (!_itPreviewIdsPorClave.TryGetValue(clave, out var set)) + { + set = new HashSet(StringComparer.OrdinalIgnoreCase); + _itPreviewIdsPorClave[clave] = set; + } + set.Add(subId); + } + /// + /// Obtiene seleccionada it. + /// + private ElementoItinerario? GetSelectedIt() + => _itItems.FirstOrDefault(x => x.Id.Equals(_itSeleccionadoId, StringComparison.OrdinalIgnoreCase)); + + + /// + /// Pinta it seleccionado. + /// + private async Task PintarItSeleccionado() + { + var it = GetSelectedIt(); + if (it is null) return; + + await PintarItItemAsync(it); + } + /// + /// Quita it seleccionado. + /// + private async Task QuitarItSeleccionado() + { + var it = GetSelectedIt(); + if (it is null) return; + + // claves a borrar: + // - si seleccionas IV => borra I y V + // - si seleccionas I/V => borra solo ese sentido + var claves = new List(); + + if (it.Variantes.Count == 2) + { + claves.Add(ItClave(it.Variantes[0])); // I + claves.Add(ItClave(it.Variantes[1])); // V + } + else if (it.Variantes.Count == 1) + { + claves.Add(ItClave(it.Variantes[0])); // solo I o solo V + } + else + { + return; + } + + foreach (var clave in claves) + { + if (!_itPreviewIdsPorClave.TryGetValue(clave, out var ids) || ids.Count == 0) + continue; + + // borrar TODOS los subIds reales asociados a ese sentido (aunque se pintaran desde IV) + foreach (var id in ids.ToList()) + { + await JS.InvokeVoidAsync("rt.drawPreviewLine", id, new List(), new { }); + await JS.InvokeVoidAsync("rt.toggleItStops", id, new List(), new { }); + QuitarParadasItinerarioVisibles(id); + + _itPreviewActivos.Remove(id); + ids.Remove(id); + + // si el popup abierto corresponde a lo borrado, cerrarlo + if (!string.IsNullOrWhiteSpace(_itinerarioClicadoSubId) && + string.Equals(_itinerarioClicadoSubId, id, StringComparison.OrdinalIgnoreCase)) + { + _itinerarioClicado = null; + _itinerarioClicadoSubId = null; + } + } + + if (ids.Count == 0) + _itPreviewIdsPorClave.Remove(clave); + } + } + + /// + /// Gestiona todas las vistas previas. + /// + private async Task CerrarTodasLasPreviewsAsync() + { + try { await JS.InvokeVoidAsync("rt.clearPreview"); } catch { } + + _itPreviewActivos.Clear(); + _itPreviewIdsPorClave.Clear(); + LimpiarParadasItinerarioVisibles(); + + _previewLineasActivas.Clear(); // <- esto ya lo usas para los "full_..." + } + + + + + private bool _inspectorActivo = false; + + private bool _popupInspectorVisible = false; + private double _inspectorLat, _inspectorLon; + private double? _inspectorElev; + private double? _inspectorElevIdee; + + + /// + /// Gestiona vista previa itinerario clic. + /// + [JSInvokable] + public async Task OnPreviewItinerarioClick(string previewId) + { + if (string.IsNullOrWhiteSpace(previewId)) + return; + + try + { + // Cerrar popup de parada si está abierto + + + // Guardamos el subId real pulsado (ej: it_xxx_I o full_...) + _itinerarioClicadoSubId = previewId; + + // 1) Si viene de previews del desplegable/filtro (it_...) + // Puede venir: + // - id directo: "it_26_12_I_3" + // - subId combinado: "it_26_12_IV_I" / "it_26_12_IV_V" + ElementoItinerario? encontrado = null; + + // coincidencia exacta + encontrado = _itItems.FirstOrDefault(x => + x.Id.Equals(previewId, StringComparison.OrdinalIgnoreCase)); + + // si no, probar quitando sufijo _I / _V de subId combinado + if (encontrado is null) + { + if (previewId.EndsWith("_I", StringComparison.OrdinalIgnoreCase) || + previewId.EndsWith("_V", StringComparison.OrdinalIgnoreCase)) + { + var baseId = previewId.Substring(0, previewId.Length - 2); + encontrado = _itItems.FirstOrDefault(x => + x.Id.Equals(baseId, StringComparison.OrdinalIgnoreCase)); + } + } + + // 2) Si no se encuentra, puede ser preview azul de tramo de ruta: "full_..." + // En ese caso intentamos construir info simple desde el id. + if (encontrado is null && previewId.StartsWith("full_", StringComparison.OrdinalIgnoreCase)) + { + // formato: full_{linea}_{itin}_{sentido}_{variante} + var parts = previewId.Split('_', StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length >= 5) + { + var linea = parts[1]; + var itinTxt = parts[2]; + var sentidoRaw = parts[3]; // "Ida"/"Vuelta" o código + var varTxt = parts[4]; + + int.TryParse(itinTxt, out var itinNum); + + var sentido = NormalizarSentidoCodigo(sentidoRaw); + + var info = _infoVariantes.FirstOrDefault(v => + string.Equals(v.Linea.Codigo?.ToString(), linea, StringComparison.OrdinalIgnoreCase) && + v.CodigoItinerario == itinNum && + string.Equals(NormalizarSentidoCodigo(v.Sentido?.Codigo), sentido, StringComparison.OrdinalIgnoreCase) && + string.Equals(v.Variante.Codigo.ToString(), varTxt, StringComparison.OrdinalIgnoreCase)); + + if (info is not null) + { + encontrado = new ElementoItinerario + { + Id = previewId, + Text = $"{linea}.{itinNum}-{sentido} — {info.Linea.Nombre}", + Variantes = new List { info } + }; + } + } + } + + _itinerarioClicado = encontrado; + + await InvokeAsync(StateHasChanged); + } + catch + { + // silencioso + } + } + + /// + /// Abre la información de una parada destacada pulsada directamente sobre la solución o el itinerario. + /// + [JSInvokable] + public async Task OnHighlightedStopClick(string codigoParada) + { + if (string.IsNullOrWhiteSpace(codigoParada)) + return; + + if (!_paradaByCode.TryGetValue(codigoParada, out var parada)) + return; + + await MostrarPopupParadaAsync(parada); + } + + /// + /// Gestiona inspector activo. + /// + [JSInvokable] + public void SetInspectorActivo(bool activo) + { + _inspectorActivo = activo; + StateHasChanged(); + } + + + + /// + /// Gestiona raton move mapa. + /// + private void OnMouseMoveMapa(RealTimeMap.ClicksMapArgs args) + { + _coordsMouse = (args.location.latitude, args.location.longitude); + StateHasChanged(); + } + + /// + /// Gestiona brillo. + /// + [JSInvokable] + public async Task AlternarBrillo() + { + _nivelBrilloMapa++; + if (_nivelBrilloMapa > 4) _nivelBrilloMapa = 0; + await PersistirConfiguracionAsync(); + StateHasChanged(); + } + + /// + /// Dibuja paradas. + /// + private async Task DibujarParadasAsync() + { + if (_mapa is null) return; + + await _mapa.Geometric.DisplayPointsFromArray.deleteAllPoints(); + + + var simboloParada = new RealTimeMap.PointSymbol + { + color = "black", + fillColor = "yellow", + radius = 5, + opacity = 0.9, + fillOpacity = 0.8, + weight = 1 + }; + + + + + foreach (var parada in RedBusServicio.Paradas) + { + await _mapa.Geometric.DisplayPointsFromArray.addPoint( + new[] { parada.Latitud, parada.Longitud }, + simboloParada); + } + + await SincronizarMarcadoresOrigenDestinoJsAsync(); + } + // ========================================================= + // Walking caché (OSRM/Valhalla) + // Nota: el "dur" devuelto NO se usa para tiempos finales. + // El tiempo a pie SIEMPRE sale de EstimarDuracionPieSegundos(dist) + // ========================================================= + + private async Task<(double[,]? line, double dist, double dur)> WalkOsrmCached(double lat1, double lon1, double lat2, double lon2) + { + var key = WalkKey("osrm", lat1, lon1, lat2, lon2); + if (_walkCache.TryGetValue(key, out var v)) + return v; + + try + { + var (line, dist, dur) = await EsrmCliente.ObtenerRutaAPieAsync(lat1, lon1, lat2, lon2); + + // ✅ Solo cachear si es un resultado "bueno" + if (line is not null && dist > 0) + { + v = (line, dist, dur); + _walkCache[key] = v; + return v; + } + + // ❌ si viene mal, NO lo cacheamos + return (null, 0, 0); + } + catch + { + // ❌ no cachear fallos + return (null, 0, 0); + } + } + + private async Task<(double[,]? line, double dist, double dur)> WalkValhallaCached(double lat1, double lon1, double lat2, double lon2) + { + var key = WalkKey("valhalla", lat1, lon1, lat2, lon2); + if (_walkCache.TryGetValue(key, out var v)) + return v; + + try + { + var (line, dist, dur) = await ClienteValhalla.ObtenerRutaAsync(lat1, lon1, lat2, lon2, costing: "pedestrian"); + + // ✅ Solo cachear si es un resultado "bueno" + if (line is not null && dist > 0) + { + v = (line, dist, dur); + _walkCache[key] = v; + return v; + } + + return (null, 0, 0); + } + catch + { + return (null, 0, 0); + } + } + + + /// + /// Gestiona linea recta origen destino. + /// + private double DistanciaLineaRectaOrigenDestino() + { + if (_coordenadasOrigen is null || _coordenadasDestino is null) return 0; + + return CalcularDistanciaMetros( + _coordenadasOrigen[0], _coordenadasOrigen[1], + _coordenadasDestino[0], _coordenadasDestino[1]); + } + +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs new file mode 100644 index 0000000..a62589f --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs @@ -0,0 +1,1633 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using LeafletForBlazor; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using RutasDBUS.Modelos.Planificacion; +using RutasDBUS.Modelos.RedBus; +using RutasDBUS.Servicios; +using RutasDBUS.Servicios.Caminata; +using RutasDBUS.Servicios.Horarios; +using RutasDBUS.Servicios.Planificacion; +using RutasDBUS.Servicios.RedBus; + +namespace RutasDBUS.Components.Pages; + +public partial class PlanificadorRutas : ComponentBase +{ + // ========================================================= + // UI / Estado + // ========================================================= + private int _segundosPorParadaBus = 30; + private int _segundosPorParadaBusManual = 30; + + private bool _paradasPintadas = false; + private bool _pintandoParadas = false; + private Task? _tareaCargaRed; + + private readonly RealTimeMap.PointSymbol _simboloOrigen = new() { color = "green", fillColor = "green", radius = 8 }; + private readonly RealTimeMap.PointSymbol _simboloDestino = new() { color = "red", fillColor = "red", radius = 8 }; + + private int _versionCalculo = 0; + private int _versionPintadoRuta = 0; + private CancellationTokenSource? _ctsCalculo; + private (double Latitud, double Longitud)? _coordsMouse; + private const double UmbralCosturaVisualMetros = 12.0; + private const double UmbralUnionPeatonalAutomaticaMetros = 250.0; + private const string DashArrayBusVuelta = "10 10"; + private const string DashArrayPiePunteado = "1 7"; + + private bool _mostrarConfigAvanzada = false; + /// + /// Alterna la configuracion panel. + /// + private void ToggleConfigPanel() => _mostrarConfigAvanzada = !_mostrarConfigAvanzada; + + private int _nivelBrilloMapa = 0; + + // CONFIG + private double _velocidadPieKmH = 3.6; + private double _factorPenalizacionDistanciaPie = 1.6; + private double _parametroPenalizacionDistanciaPieMetros = 100.0; + private const double CoeficienteEsfuerzoAlturaPorDefecto = 5.2; + private double _coeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto; + private double _pesoTransbordoRankingMinutos = 4.0; + private string _factorPenalizacionDistanciaPieTextoEdicion = "1.60"; + private string _parametroPenalizacionDistanciaPieTextoEdicion = "100"; + private string _coeficienteEsfuerzoAlturaTextoEdicion = "5.20"; + private string _pesoTransbordoRankingTextoEdicion = "4,0"; + private double _velocidadBusKmH = 16.0; + private int _minutosTransbordo = 5; + private int _margenTransbordoSegundos = 120; + + /// + /// Expone el factor de penalizacion peatonal con dos decimales para la UI. + /// + private string FactorPenalizacionDistanciaPieTexto + { + get => _factorPenalizacionDistanciaPieTextoEdicion; + set + { + var texto = (value ?? "").Replace(',', '.'); + _factorPenalizacionDistanciaPieTextoEdicion = texto; + + if (!double.TryParse(texto, NumberStyles.Float, CultureInfo.InvariantCulture, out var valor)) + return; + + if (double.IsFinite(valor)) + _factorPenalizacionDistanciaPie = valor; + } + } + + /// + /// Expone el parametro base de metros de la penalizacion peatonal para la UI. + /// + private string ParametroPenalizacionDistanciaPieTexto + { + get => _parametroPenalizacionDistanciaPieTextoEdicion; + set + { + var texto = (value ?? "").Replace(',', '.'); + _parametroPenalizacionDistanciaPieTextoEdicion = texto; + + if (!double.TryParse(texto, NumberStyles.Float, CultureInfo.InvariantCulture, out var valor)) + return; + + if (double.IsFinite(valor)) + _parametroPenalizacionDistanciaPieMetros = valor; + } + } + + /// + /// Expone el coeficiente usado para elevar el esfuerzo por pendiente. + /// + private string CoeficienteEsfuerzoAlturaTexto + { + get => _coeficienteEsfuerzoAlturaTextoEdicion; + set + { + var texto = (value ?? "").Replace(',', '.'); + _coeficienteEsfuerzoAlturaTextoEdicion = texto; + + if (!double.TryParse(texto, NumberStyles.Float, CultureInfo.InvariantCulture, out var valor)) + return; + + if (double.IsFinite(valor)) + _coeficienteEsfuerzoAltura = valor; + } + } + + /// + /// Sincroniza los textos editables de coeficiente y parámetro con sus valores numéricos. + /// + /// + /// Expone el peso de transbordo usado solo para ordenar alternativas. + /// + private string PesoTransbordoRankingTexto + { + get => _pesoTransbordoRankingTextoEdicion; + set + { + var texto = value ?? ""; + _pesoTransbordoRankingTextoEdicion = texto; + + if (!TryParseDuracionMinutos(texto, out var valor)) + return; + + if (double.IsFinite(valor)) + _pesoTransbordoRankingMinutos = Math.Max(0, valor); + } + } + + private static bool TryParseDuracionMinutos(string texto, out double minutos) + { + minutos = 0; + texto = (texto ?? "").Trim().Replace(',', '.'); + if (string.IsNullOrWhiteSpace(texto)) + return false; + + var partes = texto.Split(':', StringSplitOptions.TrimEntries); + if (partes.Length == 1) + return double.TryParse(partes[0], NumberStyles.Float, CultureInfo.InvariantCulture, out minutos); + + if (partes.Length is < 2 or > 3) + return false; + + if (!double.TryParse(partes[^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var segundos)) + return false; + if (!int.TryParse(partes[^2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var minutosParte)) + return false; + + var horas = 0; + if (partes.Length == 3 && + !int.TryParse(partes[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out horas)) + { + return false; + } + + minutos = Math.Max(0, (horas * 60.0) + minutosParte + (segundos / 60.0)); + return true; + } + + private static string FormatearDuracionMinutos(double minutos) + { + if (!double.IsFinite(minutos) || minutos <= 0) + return "0,0"; + + return minutos.ToString("0.0", CultureInfo.InvariantCulture).Replace('.', ','); + } + + private string FormatearEsfuerzoElevacion(double? distanciaMetros, double? pendientePorcentaje) + { + var esfuerzo = CalcularEsfuerzoElevacion(distanciaMetros, pendientePorcentaje); + return esfuerzo is null + ? "-" + : esfuerzo.Value.ToString("0.00", CultureInfo.InvariantCulture); + } + + private double? CalcularEsfuerzoElevacion(double? distanciaMetros, double? pendientePorcentaje) + { + if (distanciaMetros is null || + pendientePorcentaje is null || + !double.IsFinite(distanciaMetros.Value) || + !double.IsFinite(pendientePorcentaje.Value) || + distanciaMetros.Value <= 0) + { + return null; + } + + var coeficiente = double.IsFinite(_coeficienteEsfuerzoAltura) + ? _coeficienteEsfuerzoAltura + : CoeficienteEsfuerzoAlturaPorDefecto; + + var pendiente = pendientePorcentaje.Value / 100.0; + if (pendiente <= 0) + return distanciaMetros.Value; + + var factorConPotencia = Math.Pow(1.0 + pendiente, coeficiente); + var esfuerzo = distanciaMetros.Value * factorConPotencia; + return double.IsFinite(esfuerzo) + ? esfuerzo + : null; + } + + private void SincronizarTextosPenalizacionPie() + { + _factorPenalizacionDistanciaPieTextoEdicion = _factorPenalizacionDistanciaPie.ToString("0.00", CultureInfo.InvariantCulture); + _parametroPenalizacionDistanciaPieTextoEdicion = _parametroPenalizacionDistanciaPieMetros.ToString("0", CultureInfo.InvariantCulture); + _coeficienteEsfuerzoAlturaTextoEdicion = _coeficienteEsfuerzoAltura.ToString("0.00", CultureInfo.InvariantCulture); + _pesoTransbordoRankingTextoEdicion = FormatearDuracionMinutos(_pesoTransbordoRankingMinutos); + } + + /// + /// Normaliza el texto del coeficiente al salir del campo. + /// + private async Task OnBlurFactorPenalizacionDistanciaPie() + { + SincronizarTextosPenalizacionPie(); + await PersistirConfiguracionAsync(); + } + + /// + /// Normaliza el texto del parámetro base al salir del campo. + /// + private async Task OnBlurParametroPenalizacionDistanciaPie() + { + SincronizarTextosPenalizacionPie(); + await PersistirConfiguracionAsync(); + } + + /// + /// Normaliza el coeficiente de esfuerzo de altura al salir del campo. + /// + private async Task OnBlurCoeficienteEsfuerzoAltura() + { + SincronizarTextosPenalizacionPie(); + await PersistirConfiguracionAsync(); + } + + /// + /// Normaliza el peso de transbordos al salir del campo. + /// + private async Task OnBlurPesoTransbordoRanking() + { + SincronizarTextosPenalizacionPie(); + await PersistirConfiguracionAsync(); + } + + private int _radioCandidatasMetros = 1200; + private int _maxCandidatasPorLado = 12; + private bool _usarOsrmParaRanking = false; + + private bool _activarRangoItinerarios = true; + private int _itinerarioMin = 1; + private int _itinerarioMax = 99; + + // Estado + private string _textoBusquedaOrigen = string.Empty; + private string _textoBusquedaDestino = string.Empty; + + private RealTimeMap? _mapa; + + private readonly RealTimeMap.LoadParameters _parametrosMapa = new() + { + location = new RealTimeMap.Location { latitude = 43.307251, longitude = -1.979497 }, + zoomLevel = 14, + basemap = new RealTimeMap.Basemap + { + basemapLayers = new() + { + new RealTimeMap.BasemapLayer + { + url = "https://osm.tecnosis.net/osm_tiles/{z}/{x}/{y}.png", + attribution = "© OpenStreetMap", + title = "", + detectRetina = true + } + } + } + }; + + private double[]? _coordenadasOrigen; + private double[]? _coordenadasDestino; + + private string _mensajeRuta = string.Empty; + private bool _hayRutaValida = false; + + private Parada? _paradaOrigenSeleccionada; + private Parada? _paradaDestinoSeleccionada; + private Parada? _primerTransbordoseleccionado; + private int _numeroTransbordos = 0; + + private double _distanciaAPieInicio = 0; + private double _distanciaAPieFin = 0; + private double _distanciaEnBus = 0; + + private Parada? _paradaClicada; + + private readonly List _tramosRuta = new(); + private double _duracionTotalSegundos = 0; + private double? _altitudOrigenResumenRuta = null; + private double? _altitudDestinoResumenRuta = null; + + private readonly List _opcionesRuta = new(); + private int _indiceAlternativaSeleccionada = 0; + private DateTime? _ultimaHoraReferenciaRuta = null; + private TimeSpan? _tiempoUltimoCalculoRuta = null; + private readonly Dictionary> _paradasItinerarioVisiblesPorId = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _paradasRutaVisiblesPorId = new(StringComparer.OrdinalIgnoreCase); + + private const string CriterioRutaTiempo = "Tiempo"; + private const string CriterioRutaEquilibrado = "Equilibrado"; + private const string CriterioRutaAndar = "Andar"; + private const string PreferenciaRutaTiempo = "M\u00e1s r\u00e1pida"; + private const string PreferenciaRutaAndar = "Menos Andar"; + private string _criterioPrioritarioRuta = CriterioRutaEquilibrado; + private string? _preferenciaEtiquetaRuta = null; + + private bool CriterioPrioritarioTiempoActivo => + string.Equals(_criterioPrioritarioRuta, CriterioRutaTiempo, StringComparison.OrdinalIgnoreCase); + + private bool CriterioPrioritarioEquilibradoActivo => + string.Equals(_criterioPrioritarioRuta, CriterioRutaEquilibrado, StringComparison.OrdinalIgnoreCase); + + private bool CriterioPrioritarioAndarActivo => + string.Equals(_criterioPrioritarioRuta, CriterioRutaAndar, StringComparison.OrdinalIgnoreCase); + + private void SincronizarPreferenciaDesdeCriterioRuta() + { + _preferenciaEtiquetaRuta = CriterioPrioritarioTiempoActivo + ? PreferenciaRutaTiempo + : CriterioPrioritarioAndarActivo + ? PreferenciaRutaAndar + : null; + } + + private static string NormalizarCriterioRuta(string? criterio, string? preferenciaLegada = null) + { + if (string.Equals(criterio, CriterioRutaTiempo, StringComparison.OrdinalIgnoreCase)) + return CriterioRutaTiempo; + if (string.Equals(criterio, CriterioRutaAndar, StringComparison.OrdinalIgnoreCase)) + return CriterioRutaAndar; + if (string.Equals(criterio, CriterioRutaEquilibrado, StringComparison.OrdinalIgnoreCase)) + return CriterioRutaEquilibrado; + + if (string.Equals(preferenciaLegada, PreferenciaRutaTiempo, StringComparison.OrdinalIgnoreCase)) + return CriterioRutaTiempo; + if (string.Equals(preferenciaLegada, PreferenciaRutaAndar, StringComparison.OrdinalIgnoreCase)) + return CriterioRutaAndar; + + return CriterioRutaEquilibrado; + } + + // ========================================================= + // Helpers (texto/formato) + // ========================================================= + + /// + /// Gestiona css desde variante. + /// + private string ColorCssDesdeVariante(InfoVariante v) + { + // Color real de la línea desde el JSON + var css = RedBusServicio.ConvertirColorArgbACss(v.Linea.ColorArgb); + + // Fallback por seguridad si viniera vacío / inválido + if (string.IsNullOrWhiteSpace(css)) + css = "#2563eb"; + + return css; + } + + private sealed class ColaDoble + { + private readonly LinkedList _list = new(); + public int Count => _list.Count; + /// + /// Gestiona front. + /// + public void PushFront(T item) => _list.AddFirst(item); + /// + /// Gestiona back. + /// + public void PushBack(T item) => _list.AddLast(item); + /// + /// Gestiona front. + /// + public T PopFront() + { + var v = _list.First!.Value; + _list.RemoveFirst(); + return v; + } + } + + + /// + /// Construye paradas for variante. + /// + private static List BuildStopsDesdeParadas(IEnumerable paradas) + { + var stops = new List(); + foreach (var parada in paradas) + stops.Add(new { lat = parada.Latitud, lon = parada.Longitud, code = parada.Codigo }); + + return stops; + } + + /// + /// Construye las paradas visibles recorridas por una variante completa. + /// + private List BuildParadasForVariante(InfoVariante v) + { + var stops = new List(v?.CodigosParadas?.Count ?? 0); + if (v?.CodigosParadas is null) return stops; + + var codigosVistos = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var cod in v.CodigosParadas) + { + if (!codigosVistos.Add(cod)) + continue; + + if (_paradaByCode.TryGetValue(cod, out var parada)) + stops.Add(parada); + } + + return stops; + } + + /// + /// Construye el payload JS de paradas de una variante. + /// + private List BuildStopsForVariante(InfoVariante v) + => BuildStopsDesdeParadas(BuildParadasForVariante(v)); + + /// + /// Construye las paradas visibles recorridas por un tramo concreto de bus. + /// + private List BuildParadasForTramoBus(TramoBus tramo) + { + var stops = new List(); + + if (tramo?.Variante?.CodigosParadas is null || tramo.Variante.CodigosParadas.Count == 0) + return stops; + + var inicio = Math.Clamp(Math.Min(tramo.IndiceInicio, tramo.IndiceFin), 0, tramo.Variante.CodigosParadas.Count - 1); + var fin = Math.Clamp(Math.Max(tramo.IndiceInicio, tramo.IndiceFin), 0, tramo.Variante.CodigosParadas.Count - 1); + var codigosVistos = new HashSet(StringComparer.OrdinalIgnoreCase); + + for (var indice = inicio; indice <= fin; indice++) + { + var codigo = tramo.Variante.CodigosParadas[indice]; + if (!codigosVistos.Add(codigo)) + continue; + + if (_paradaByCode.TryGetValue(codigo, out var parada)) + stops.Add(parada); + } + + return stops; + } + + /// + /// Construye el payload JS de paradas de un tramo de bus. + /// + private List BuildStopsForTramoBus(TramoBus tramo) + => BuildStopsDesdeParadas(BuildParadasForTramoBus(tramo)); + + /// + /// Registra paradas visibles de un itinerario activo para permitir popup aunque la capa general esté oculta. + /// + private void RegistrarParadasItinerarioVisibles(string id, IEnumerable paradas) + { + _paradasItinerarioVisiblesPorId[id] = paradas + .GroupBy(p => p.Codigo, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToList(); + } + + /// + /// Elimina el registro de paradas visibles de un itinerario activo. + /// + private void QuitarParadasItinerarioVisibles(string id) + { + _paradasItinerarioVisiblesPorId.Remove(id); + } + + /// + /// Limpia todas las paradas visibles de itinerarios activos. + /// + private void LimpiarParadasItinerarioVisibles() + { + _paradasItinerarioVisiblesPorId.Clear(); + } + + /// + /// Registra paradas visibles de la solución seleccionada. + /// + private void RegistrarParadasRutaVisibles(string id, IEnumerable paradas) + { + _paradasRutaVisiblesPorId[id] = paradas + .GroupBy(p => p.Codigo, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .ToList(); + } + + /// + /// Limpia todas las paradas visibles de la solución seleccionada. + /// + private void LimpiarParadasRutaVisibles() + { + _paradasRutaVisiblesPorId.Clear(); + } + + /// + /// Devuelve las paradas destacadas actualmente visibles cuando la capa general está oculta. + /// + private List ObtenerParadasDestacadasVisibles() + { + var resultado = new List(); + var codigos = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var parada in _paradasRutaVisiblesPorId.Values.SelectMany(x => x)) + { + if (codigos.Add(parada.Codigo)) + resultado.Add(parada); + } + + foreach (var parada in _paradasItinerarioVisiblesPorId.Values.SelectMany(x => x)) + { + if (codigos.Add(parada.Codigo)) + resultado.Add(parada); + } + + return resultado; + } + + /// + /// Detecta si el click cae sobre una parada destacada de itinerario o solución cuando la capa general está oculta. + /// + private async Task DetectarParadaDestacadaHitAsync(double clickLat, double clickLon) + { + var parada = ObtenerParadasDestacadasVisibles() + .Select(p => new + { + Parada = p, + Distancia = CalcularDistanciaMetros(clickLat, clickLon, p.Latitud, p.Longitud) + }) + .Where(x => x.Distancia <= 40) + .OrderBy(x => x.Distancia) + .Select(x => x.Parada) + .FirstOrDefault(); + + if (parada is null) + return null; + + var hit = await JS.InvokeAsync( + "rt.isClickInsideCirclePx", + clickLat, clickLon, + parada.Latitud, parada.Longitud, + 10); + + return hit ? parada : null; + } + + + + /// + /// Gestiona por pixel. + /// + private static double MetrosPorPixel(double lat, int zoom) + { + // WebMercator: m/px = 156543.03392 * cos(lat) / 2^zoom + return 156543.03392 * Math.Cos(lat * Math.PI / 180.0) / Math.Pow(2, zoom); + } + + /// + /// Gestiona clic parada metros. + /// + private async Task RadioClickParadaMetrosAsync(double lat) + { + int zoom = 14; + try { zoom = await JS.InvokeAsync("rt.getZoom"); } catch { } + + const double TOL_PX = 18; // 🎯 16-22 suele perfecto + return TOL_PX * MetrosPorPixel(lat, zoom); + } + + + + /// + /// Gestiona linea completa variante. + /// + private double[,]? ConstruirLineaCompletaVariante(InfoVariante v) + { + if (v is null || v.CodigosParadas is null || v.CodigosParadas.Count < 2) return null; + + var pts = new List(v.CodigosParadas.Count); + + foreach (var cod in v.CodigosParadas) + { + if (_paradaByCode.TryGetValue(cod, out var p)) + pts.Add(new[] { p.Latitud, p.Longitud }); + } + + if (pts.Count < 2) return null; + + var arr = new double[pts.Count, 2]; + for (int i = 0; i < pts.Count; i++) + { + arr[i, 0] = pts[i][0]; + arr[i, 1] = pts[i][1]; + } + return arr; + } + + + // ✅ Rango de líneas + private bool _activarRangoLineas = false; + private int _lineaMin = 1; + private int _lineaMax = 200; + private readonly HashSet _lineasPermitidas = new(); + + /// + /// Gestiona dentro rango. + /// + private bool LineaDentroRango(InfoVariante v) + { + if (!_activarRangoLineas) return true; + + int codLinea = CodigoLineaAsInt(v); // ya lo tienes hecho + if (codLinea == int.MaxValue) return false; // si no se puede parsear, fuera + + return _lineasPermitidas.Contains(codLinea); + } + + + + /// + /// Alterna linea completa. + /// + private async Task ToggleLineaCompletaAsync(DetalleTramoRuta tramo) + { + if (tramo?.VarianteBus is null) return; + + var id = $"full_{tramo.VarianteBus.Linea.Codigo}_{tramo.VarianteBus.CodigoItinerario}_{tramo.VarianteBus.Sentido?.Codigo}_{tramo.VarianteBus.Variante.Codigo}"; + + // Si está activa => QUITAR + if (_previewLineasActivas.Contains(id)) + { + await JS.InvokeVoidAsync("rt.drawPreviewLine", id, new List(), new { }); + await JS.InvokeVoidAsync("rt.toggleItStops", id, new List(), new { }); + _previewLineasActivas.Remove(id); + return; + + } + + // Si no está activa => PINTAR + var full = ConstruirGeometriaCompletaVariante(tramo.VarianteBus); + if (full is null) return; + + var color = ColorCssDesdeVariante(tramo.VarianteBus); + var dash = string.Equals( + NormalizarSentidoCodigo(tramo.VarianteBus.Sentido?.Codigo), + "V", + StringComparison.OrdinalIgnoreCase) + ? DashArrayBusVuelta + : null; + + await JS.InvokeVoidAsync("rt.drawPreviewLine", id, LineaToLatLng(full), new + { + color, + weight = 7, + opacity = 0.95, + dashArray = dash, + arrowEnd = true, + arrowBackPx = 26 + }); + + await JS.InvokeVoidAsync("rt.toggleItStops", id, BuildStopsForVariante(tramo.VarianteBus), new + { + radius = 7, + color = "#0f172a", + fillColor = color, + weight = 2, + opacity = 1.0, + fillOpacity = 1.0 + }); + + _previewLineasActivas.Add(id); + + } + + + + /// + /// Cuenta paradas recorridas. + /// + private int ContarParadasRecorridas(TramoBus tramo) + { + // Nº de paradas “pasadas” excluyendo la de inicio. + // Ej: inicio=2 fin=5 => 3 paradas (3,4,5) + return Math.Max(0, Math.Abs(tramo.IndiceFin - tramo.IndiceInicio)); + } + + + /// + /// Gestiona sin decimal. + /// + private static string CodigoSinDecimal(string? codigo) + { + if (string.IsNullOrWhiteSpace(codigo)) return ""; + var i = codigo.IndexOf('.'); + return (i > 0) ? codigo.Substring(0, i) : codigo; + } + + /// + /// Gestiona parada formateado. + /// + private static string CodigoParadaFormateado(string? codigo) + { + var c = CodigoSinDecimal(codigo); + if (string.IsNullOrWhiteSpace(c)) return ""; + return $"P-{c}"; + } + + /// + /// Normaliza codigo parada. + /// + private static string NormalizarCodigoParada(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return ""; + s = s.Trim(); + s = s.Replace("P-", "", StringComparison.OrdinalIgnoreCase); + return CodigoSinDecimal(s); + } + + /// + /// Gestiona palabras clave. + /// + private static string CapitalizarPalabrasClave(string s) + { + if (string.IsNullOrWhiteSpace(s)) return s; + + return s + .Replace("parada", "Parada", StringComparison.OrdinalIgnoreCase) + .Replace("paradas", "Paradas", StringComparison.OrdinalIgnoreCase) + .Replace("línea", "Línea", StringComparison.OrdinalIgnoreCase) + .Replace("linea", "Línea", StringComparison.OrdinalIgnoreCase) + .Replace("líneas", "Líneas", StringComparison.OrdinalIgnoreCase) + .Replace("lineas", "Líneas", StringComparison.OrdinalIgnoreCase); + } + + /// + /// + /// + private static string Q(double v) => Math.Round(v, 5).ToString("0.00000", CultureInfo.InvariantCulture); + + /// + /// Gestiona clave. + /// + private static string WalkKey(string provider, double lat1, double lon1, double lat2, double lon2) + => $"{provider}|{Q(lat1)},{Q(lon1)}|{Q(lat2)},{Q(lon2)}"; + + /// + /// Gestiona to lat lng. + /// + private static List LineaToLatLng(double[,] linea) + { + var res = new List(linea.GetLength(0)); + for (int i = 0; i < linea.GetLength(0); i++) + res.Add(new[] { linea[i, 0], linea[i, 1] }); + return res; + } + + /// + /// Devuelve el primer punto de una linea. + /// + private static double[]? ObtenerPrimerPunto(double[,]? linea) + { + if (linea is null || linea.GetLength(0) == 0) + return null; + + return new[] { linea[0, 0], linea[0, 1] }; + } + + /// + /// Devuelve el ultimo punto de una linea. + /// + private static double[]? ObtenerUltimoPunto(double[,]? linea) + { + if (linea is null || linea.GetLength(0) == 0) + return null; + + var ultimo = linea.GetLength(0) - 1; + return new[] { linea[ultimo, 0], linea[ultimo, 1] }; + } + + /// + /// Sobrescribe el primer punto de una linea. + /// + private static void EstablecerPrimerPunto(double[,]? linea, double[]? punto) + { + if (linea is null || punto is null || linea.GetLength(0) == 0) + return; + + linea[0, 0] = punto[0]; + linea[0, 1] = punto[1]; + } + + /// + /// Sobrescribe el ultimo punto de una linea. + /// + private static void EstablecerUltimoPunto(double[,]? linea, double[]? punto) + { + if (linea is null || punto is null || linea.GetLength(0) == 0) + return; + + var ultimo = linea.GetLength(0) - 1; + linea[ultimo, 0] = punto[0]; + linea[ultimo, 1] = punto[1]; + } + + /// + /// Obtiene la distancia entre dos puntos geograficos. + /// + private static double CalcularDistanciaEntrePuntos(double[]? origen, double[]? destino) + { + if (origen is null || destino is null || origen.Length < 2 || destino.Length < 2) + return 0; + + return CalcularDistanciaMetros(origen[0], origen[1], destino[0], destino[1]); + } + + /// + /// Invalida cualquier pintado de ruta anterior y devuelve la nueva versión activa. + /// + private int IniciarNuevaVersionPintadoRuta() + => Interlocked.Increment(ref _versionPintadoRuta); + + /// + /// Lanza cancelación si el pintado ya no es el activo. + /// + private void ThrowIfPintadoObsoleto(int versionPintado) + { + if (versionPintado != Volatile.Read(ref _versionPintadoRuta)) + throw new OperationCanceledException(); + } + + /// + /// Resuelve la parada fisica mas coherente para un codigo y una referencia espacial. + /// + private Parada ResolverParadaFisica( + string? codigoParada, + double[]? puntoReferencia = null, + Parada? paradaPreferida = null) + { + if (!string.IsNullOrWhiteSpace(codigoParada)) + { + var candidatas = RedBusServicio.Paradas + .Where(parada => parada.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (candidatas.Count == 1) + return candidatas[0]; + + if (candidatas.Count > 1) + { + if (paradaPreferida is not null && + paradaPreferida.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase)) + { + var coincidenciaExacta = candidatas.FirstOrDefault(parada => + Math.Abs(parada.Latitud - paradaPreferida.Latitud) < 1e-9 && + Math.Abs(parada.Longitud - paradaPreferida.Longitud) < 1e-9); + + if (coincidenciaExacta is not null) + return coincidenciaExacta; + } + + if (puntoReferencia is not null) + { + return candidatas + .OrderBy(parada => CalcularDistanciaMetros( + parada.Latitud, + parada.Longitud, + puntoReferencia[0], + puntoReferencia[1])) + .First(); + } + + if (paradaPreferida is not null && + paradaPreferida.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase)) + { + return paradaPreferida; + } + + return candidatas[0]; + } + } + + return paradaPreferida ?? new Parada(); + } + + /// + /// Crea una linea minima entre dos puntos. + /// + private static double[,] ConstruirLineaDirecta(double[] origen, double[] destino) + { + return new[,] + { + { origen[0], origen[1] }, + { destino[0], destino[1] } + }; + } + + /// + /// Limpia rutas js. + /// + private async Task ClearRoutesJsAsync() + { + LimpiarParadasRutaVisibles(); + try { await JS.InvokeVoidAsync("rt.clearRoutes"); } catch { } + } + + /// + /// Limpia las paradas destacadas de la solución seleccionada. + /// + private async Task ClearRouteStopsJsAsync() + { + try { await JS.InvokeVoidAsync("rt.clearRouteStops"); } catch { } + } + + /// + /// Sincroniza los marcadores arrastrables de origen y destino con el estado actual. + /// + private async Task SincronizarMarcadoresOrigenDestinoJsAsync() + { + if (_coordenadasOrigen is not null) + { + try + { + await JS.InvokeVoidAsync("rt.setEndpointMarker", "origin", _coordenadasOrigen[0], _coordenadasOrigen[1], new + { + color = "#22c55e", + title = "Origen", + size = 18 + }); + } + catch { } + } + else + { + try { await JS.InvokeVoidAsync("rt.clearEndpointMarkers", "origin"); } catch { } + } + + if (_coordenadasDestino is not null) + { + try + { + await JS.InvokeVoidAsync("rt.setEndpointMarker", "destination", _coordenadasDestino[0], _coordenadasDestino[1], new + { + color = "#ef4444", + title = "Destino", + size = 18 + }); + } + catch { } + } + else + { + try { await JS.InvokeVoidAsync("rt.clearEndpointMarkers", "destination"); } catch { } + } + } + + /// + /// Limpia los marcadores arrastrables de origen y destino. + /// + private async Task ClearEndpointMarkersJsAsync() + { + try { await JS.InvokeVoidAsync("rt.clearEndpointMarkers"); } catch { } + } + + /// + /// Dibuja ruta js. + /// + private async Task DrawRouteJsAsync( + string id, + double[,] linea, + string color, + int weight, + double opacity, + string? dashArray = null, + bool arrowEnd = false, + string? lineCap = null) + { + try + { + await JS.InvokeVoidAsync( + "rt.drawRoute", + id, + LineaToLatLng(linea), + new { color, weight, opacity, dashArray, arrowEnd, lineCap } + ); + } + catch { } + } + + /// + /// Ajusta rutas js. + /// + private async Task FitRoutesJsAsync() + { + try { await JS.InvokeVoidAsync("rt.fitRoutes"); } catch { } + } + + /// + /// Dibuja las paradas destacadas de un tramo de la solución seleccionada. + /// + private async Task DrawRouteStopsJsAsync( + string id, + IReadOnlyList stops, + string fillColor, + double radius = 8, + string color = "#0f172a", + int weight = 2, + double opacity = 1.0, + double fillOpacity = 1.0) + { + try + { + await JS.InvokeVoidAsync( + "rt.drawRouteStops", + id, + stops, + new { radius, color, fillColor, weight, opacity, fillOpacity }); + } + catch { } + } + + /// + /// Formatea distancia. + /// + private string FormatearDistancia(double d) + { + if (d <= 0) return "-"; + if (d < 950) return $"{d:0.00} m"; + return $"{(d / 1000.0):0.00} km"; + } + + /// + /// Formatea distancias para las tarjetas de alternativas con km a 3 decimales y metros truncados. + /// + private string FormatearDistanciaTarjeta(double d) + { + if (d <= 0) return "-"; + if (d < 1000) return $"{Math.Truncate(d):0} m"; + return $"{(d / 1000.0):0.000} km"; + } + + /// + /// Formatea distancias para la ventana de detalle de ruta truncando los metros visibles. + /// + private string FormatearDistanciaDetalleRuta(double d) + { + if (d <= 0) return "-"; + if (d < 1000) return $"{Math.Truncate(d):0} m"; + return $"{(d / 1000.0):0.00} km"; + } + + /// + /// Construye la línea técnica XYZ para el origen. + /// + private string FormatearResumenXYZOrigen() + { + if (_coordenadasOrigen is null) + return string.Empty; + + var lon = _coordenadasOrigen[1].ToString("0.000000", CultureInfo.InvariantCulture); + var lat = _coordenadasOrigen[0].ToString("0.000000", CultureInfo.InvariantCulture); + var alt = _altitudOrigenResumenRuta is null + ? "-" + : $"{_altitudOrigenResumenRuta.Value.ToString("0.0", CultureInfo.InvariantCulture)} m"; + + return $"O: Y.Lat = {lat} X.Lon = {lon} Z = {alt}"; + } + + /// + /// Construye la línea técnica XYZ para el destino. + /// + private string FormatearResumenXYZDestino() + { + if (_coordenadasDestino is null) + return string.Empty; + + var lon = _coordenadasDestino[1].ToString("0.000000", CultureInfo.InvariantCulture); + var lat = _coordenadasDestino[0].ToString("0.000000", CultureInfo.InvariantCulture); + var alt = _altitudDestinoResumenRuta is null + ? "-" + : $"{_altitudDestinoResumenRuta.Value.ToString("0.0", CultureInfo.InvariantCulture)} m"; + + return $"D: Y.Lat = {lat} X.Lon = {lon} Z = {alt}"; + } + + /// + /// Formatea duracion. + /// + private string FormatearDuracion(double segundos) + { + if (segundos <= 0) return "-"; + + int total = (int)Math.Round(segundos); + var ts = TimeSpan.FromSeconds(total); + + if (ts.TotalHours >= 1) + return $"{(int)ts.TotalHours}:{ts.Minutes:D2}:{ts.Seconds:D2}"; + + return $"{ts.Minutes:D2}:{ts.Seconds:D2}"; + } + + private string FormatearTiempoCalculoRuta() + { + if (_tiempoUltimoCalculoRuta is not { } tiempo) + return string.Empty; + + return $"{Math.Max(0, tiempo.TotalSeconds).ToString("0.00", CultureInfo.InvariantCulture)} s"; + } + + /// + /// Construye los parametros actuales que afectan a la penalizacion peatonal. + /// + private ParametrosPlanificadorRuta CrearParametrosPenalizacionActuales() + { + return new ParametrosPlanificadorRuta + { + VelocidadPieKmH = _velocidadPieKmH, + FactorPenalizacionDistanciaPie = _factorPenalizacionDistanciaPie, + ParametroPenalizacionDistanciaPieMetros = _parametroPenalizacionDistanciaPieMetros + }; + } + + /// + /// Calcula la distancia peatonal corregida total de una alternativa. + /// + private double CalcularDistanciaCorregidaAlternativa(AlternativaRuta? alternativa) + { + if (alternativa is null) + return 0; + + var parametros = CrearParametrosPenalizacionActuales(); + + if (alternativa.EsSoloAPie) + return UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaSoloAPie, parametros); + + return UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieInicioAprox, parametros) + + UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieTransbordosAprox, parametros) + + UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieFinAprox, parametros); + } + + /// + /// Estima si una alternativa equilibrada se parece mas al criterio Tiempo o al criterio Andar. + /// + private string FormatearBalanceEquilibradoAlternativa(AlternativaRuta? alternativa) + { + if (alternativa is null || alternativa.EsSoloAPie) + return string.Empty; + + var alternativasBus = _opcionesRuta + .Where(opcion => !opcion.EsSoloAPie) + .ToList(); + + if (alternativasBus.Count < 2) + return string.Empty; + + var duraciones = alternativasBus + .Select(DuracionOrdenacion) + .Where(double.IsFinite) + .ToList(); + + var costesAndar = alternativasBus + .Select(CalcularCosteAndarEquilibradoSegundos) + .Where(double.IsFinite) + .ToList(); + + if (duraciones.Count == 0 || costesAndar.Count == 0) + return string.Empty; + + var ventajaTiempo = CalcularVentajaNormalizada( + DuracionOrdenacion(alternativa), + duraciones.Min(), + duraciones.Max()); + + var ventajaAndar = CalcularVentajaNormalizada( + CalcularCosteAndarEquilibradoSegundos(alternativa), + costesAndar.Min(), + costesAndar.Max()); + + var total = ventajaTiempo + ventajaAndar; + if (!double.IsFinite(total) || total <= 0.000001) + return string.Empty; + + var porcentajeTiempo = (int)Math.Round((ventajaTiempo / total) * 100.0); + porcentajeTiempo = Math.Clamp(porcentajeTiempo, 0, 100); + var porcentajeAndar = 100 - porcentajeTiempo; + + return $"Tiempo {porcentajeTiempo}% / Andar {porcentajeAndar}%"; + } + + private double CalcularCosteAndarEquilibradoSegundos(AlternativaRuta? alternativa) + { + if (alternativa is null) + return double.PositiveInfinity; + + var distanciaCorregida = CalcularDistanciaCorregidaAlternativa(alternativa); + if (!double.IsFinite(distanciaCorregida) || distanciaCorregida < 0) + return double.PositiveInfinity; + + return EstimarDuracionPieSegundos(distanciaCorregida); + } + + private static double CalcularVentajaNormalizada(double valor, double mejor, double peor) + { + if (!double.IsFinite(valor) || + !double.IsFinite(mejor) || + !double.IsFinite(peor) || + peor <= mejor) + { + return 0; + } + + return Math.Clamp((peor - valor) / (peor - mejor), 0.0, 1.0); + } + + private static double CalcularSacrificioNormalizado(double valor, double mejor, double peor) + { + if (!double.IsFinite(valor) || + !double.IsFinite(mejor) || + !double.IsFinite(peor) || + peor <= mejor) + { + return 0; + } + + return Math.Clamp((valor - mejor) / (peor - mejor), 0.0, 1.0); + } + + /// + /// Devuelve el coste penalizado interno de una alternativa en segundos. + /// + private double ObtenerCostePenalizadoAlternativa(AlternativaRuta? alternativa) + { + if (alternativa is null) + return 0; + + if (alternativa.PuntuacionCoste > 0) + return alternativa.PuntuacionCoste; + + var parametros = CrearParametrosPenalizacionActuales(); + var duracionReal = alternativa.DuracionTotalAproxSegundos; + + if (alternativa.EsSoloAPie) + { + return UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos( + duracionReal, + parametros, + alternativa.DistanciaSoloAPie); + } + + return UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos( + duracionReal, + parametros, + alternativa.DistanciaPieInicioAprox, + alternativa.DistanciaPieTransbordosAprox, + alternativa.DistanciaPieFinAprox); + } + + /// + /// Devuelve un icono visual para el tipo de tramo. + /// + private static string IconoTramoRuta(string? tipo) + { + if (string.IsNullOrWhiteSpace(tipo)) + return "\u2022"; + + if (tipo.StartsWith("Bus", StringComparison.OrdinalIgnoreCase)) + return "\U0001F68C"; + + if (tipo.StartsWith("Espera en bus", StringComparison.OrdinalIgnoreCase) || + tipo.StartsWith("Continuidad en bus", StringComparison.OrdinalIgnoreCase)) + return "\u21BB"; + + if (tipo.StartsWith("Espera", StringComparison.OrdinalIgnoreCase)) + return "\u231B"; + + if (tipo.StartsWith("A pie", StringComparison.OrdinalIgnoreCase)) + return "\U0001F6B6"; + + return "\U0001F4CD"; + } + + /// + /// Devuelve la etiqueta visible del tramo dentro del detalle, agrupando los elementos que pertenecen al mismo transbordo. + /// + private static string ObtenerTipoVisibleDetalleTramo(IReadOnlyList tramos, int indice) + { + if (indice < 0 || indice >= tramos.Count) + return string.Empty; + + var tramo = tramos[indice]; + if (EsTramoContinuidadEnBus(tramo)) + return "Continuidad en bus"; + if (EsTramoEsperaEnBus(tramo)) + return "Espera en bus"; + + var transbordosPrevios = ContarTransbordosRealesDetalle(tramos, indice); + + if (EsTramoBus(tramo)) + return transbordosPrevios > 0 ? $"Bus (Transb.{transbordosPrevios})" : "Bus"; + + var numeroTransbordo = EsTramoEntreBusesConTransbordoReal(tramos, indice) + ? transbordosPrevios + 1 + : transbordosPrevios; + if (numeroTransbordo <= 0 || !HayBusPosterior(tramos, indice)) + return tramo.Tipo; + + if (EsTramoEspera(tramo)) + return $"Espera (Transb.{numeroTransbordo})"; + + if (EsTramoPie(tramo)) + return $"A pie (Transb.{numeroTransbordo})"; + + return tramo.Tipo; + } + + private static int ContarTransbordosRealesDetalle(IReadOnlyList tramos, int hastaIndiceExclusivo) + { + var total = 0; + InfoVariante? busAnterior = null; + + for (var i = 0; i < hastaIndiceExclusivo && i < tramos.Count; i++) + { + if (!EsTramoBus(tramos[i]) || tramos[i].VarianteBus is null) + continue; + + if (busAnterior is not null && + UtilidadesTransitoDbus.EsTransbordoReal(busAnterior, tramos[i].VarianteBus)) + { + total++; + } + + busAnterior = tramos[i].VarianteBus; + } + + return total; + } + + private static bool EsTramoEntreBusesConTransbordoReal(IReadOnlyList tramos, int indice) + { + var anterior = BuscarVarianteBusAnterior(tramos, indice); + var siguiente = BuscarVarianteBusPosterior(tramos, indice); + return UtilidadesTransitoDbus.EsTransbordoReal(anterior, siguiente); + } + + private static InfoVariante? BuscarVarianteBusAnterior(IReadOnlyList tramos, int indice) + { + for (var i = indice - 1; i >= 0; i--) + { + if (EsTramoBus(tramos[i])) + return tramos[i].VarianteBus; + } + + return null; + } + + private static InfoVariante? BuscarVarianteBusPosterior(IReadOnlyList tramos, int indice) + { + for (var i = indice + 1; i < tramos.Count; i++) + { + if (EsTramoBus(tramos[i])) + return tramos[i].VarianteBus; + } + + return null; + } + + /// + /// Indica si el tramo representa un trayecto en bus. + /// + private static bool EsTramoBus(DetalleTramoRuta tramo) + => tramo.Tipo.Equals("Bus", StringComparison.OrdinalIgnoreCase); + + /// + /// Indica si el tramo representa una espera. + /// + private static bool EsTramoEspera(DetalleTramoRuta tramo) + => tramo.Tipo.StartsWith("Espera", StringComparison.OrdinalIgnoreCase); + + /// + /// Indica si el tramo representa una espera dentro del bus por continuidad circular. + /// + private static bool EsTramoEsperaEnBus(DetalleTramoRuta tramo) + => tramo.Tipo.StartsWith("Espera en bus", StringComparison.OrdinalIgnoreCase); + + private static bool EsTramoContinuidadEnBus(DetalleTramoRuta tramo) + => tramo.Tipo.StartsWith("Continuidad en bus", StringComparison.OrdinalIgnoreCase); + + /// + /// Indica si el tramo representa una caminata. + /// + private static bool EsTramoPie(DetalleTramoRuta tramo) + => tramo.Tipo.StartsWith("A pie", StringComparison.OrdinalIgnoreCase); + + /// + /// Comprueba si después del tramo actual todavía queda otro bus por coger. + /// + private static bool HayBusPosterior(IReadOnlyList tramos, int indiceActual) + { + for (var i = indiceActual + 1; i < tramos.Count; i++) + { + if (EsTramoBus(tramos[i])) + return true; + } + + return false; + } + + private string FormatearDuracionBloqueBusDetalle(IReadOnlyList tramos) + { + var duracion = CalcularDuracionBloqueBusSegundos(tramos); + return duracion is > 0 ? FormatearDuracionHorasMinutosReloj(duracion.Value) : "-"; + } + + private string FormatearDuracionBloqueBusResumen(IReadOnlyList tramos) + { + var duracion = CalcularDuracionBloqueBusSegundos(tramos); + return duracion is > 0 ? FormatearDuracionHorasMinutosTexto(duracion.Value) : "-"; + } + + private static string FormatearDuracionHorasMinutosReloj(double segundos) + { + if (!double.IsFinite(segundos) || segundos <= 0) + return "-"; + + var totalMinutos = Math.Max(1, (int)Math.Round(segundos / 60.0)); + var horas = totalMinutos / 60; + var minutos = totalMinutos % 60; + + if (horas <= 0) + return $"00:{minutos:D2}"; + + return minutos > 0 + ? $"{horas}:{minutos:D2}" + : $"{horas}:00"; + } + + private static string FormatearDuracionHorasMinutosTexto(double segundos) + { + if (!double.IsFinite(segundos) || segundos <= 0) + return "-"; + + var totalMinutos = Math.Max(1, (int)Math.Round(segundos / 60.0)); + var horas = totalMinutos / 60; + var minutos = totalMinutos % 60; + + if (horas <= 0) + return $"{minutos} min"; + + return minutos > 0 + ? $"{horas} h : {minutos:D2} m" + : $"{horas} h"; + } + + private static double? CalcularDuracionBloqueBusSegundos(IReadOnlyList tramos) + { + var primerBus = -1; + var ultimoBus = -1; + + for (var i = 0; i < tramos.Count; i++) + { + if (!EsTramoBus(tramos[i])) + continue; + + if (primerBus < 0) + primerBus = i; + + ultimoBus = i; + } + + if (primerBus < 0 || ultimoBus < primerBus) + return null; + + double total = 0; + for (var i = primerBus; i <= ultimoBus; i++) + { + if (tramos[i].DuracionSegundos is not { } segundos || + !double.IsFinite(segundos) || + segundos <= 0) + { + continue; + } + + total += segundos; + } + + return total; + } + + /// + /// Formatea la etiqueta visible de una alternativa. + /// + private string FormatearEtiquetaAlternativaTarjeta(AlternativaRuta alternativa, int indice) + { + return !string.IsNullOrWhiteSpace(alternativa.Etiqueta) + ? alternativa.Etiqueta + : $"Opcion {indice + 1}"; + } + + /// + /// Obtiene las siglas compactas de categorías adicionales para una alternativa final. + /// + private List ObtenerSiglasExtrasAlternativa(AlternativaRuta alternativa) + { + if (alternativa.EsSoloAPie) + return new(); + + var alternativasBus = _opcionesRuta + .Where(x => !x.EsSoloAPie && x.AlternativaBus is not null) + .ToList(); + + if (alternativasBus.Count == 0) + return new(); + + var extras = new List(2); + var etiqueta = alternativa.Etiqueta ?? string.Empty; + + var minCoste = alternativasBus.Min(x => x.PuntuacionCoste); + if (!EtiquetaPrincipalEs(etiqueta, "Más rápida") && alternativa.PuntuacionCoste <= minCoste + 0.001) + extras.Add("MR"); + + var minTransbordos = alternativasBus.Min(x => x.NumeroTransbordosReales); + if (!EtiquetaPrincipalEs(etiqueta, "Menos transbordos") && alternativa.NumeroTransbordosReales == minTransbordos) + extras.Add("MT"); + + var minDistanciaPie = alternativasBus.Min(x => x.DistanciaPieTotalAprox); + if (!EtiquetaPrincipalEs(etiqueta, "Menos andar") && alternativa.DistanciaPieTotalAprox <= minDistanciaPie + 0.001) + extras.Add("MA"); + + return extras; + } + + /// + /// Comprueba si la etiqueta principal ya corresponde a una categoría destacada concreta. + /// + private static bool EtiquetaPrincipalEs(string? etiquetaActual, string etiquetaEsperada) + => string.Equals(etiquetaActual?.Trim(), etiquetaEsperada, StringComparison.OrdinalIgnoreCase); + + + /// + /// Estima duracion pie segundos. + /// + private double EstimarDuracionPieSegundos(double distanciaMetros) + { + double v = _velocidadPieKmH * 1000.0 / 3600.0; + return distanciaMetros / Math.Max(0.1, v); + } + + /// + /// Estima duracion bus segundos. + /// + private double EstimarDuracionBusSegundos(double distanciaMetros) + { + double v = _velocidadBusKmH * 1000.0 / 3600.0; + return distanciaMetros / Math.Max(0.1, v); + } + + /// + /// Restablece estado ruta. + /// + private void ResetEstadoRuta(bool limpiarOpciones = false, bool limpiarMensaje = true) + { + if (limpiarMensaje) + _mensajeRuta = string.Empty; + _hayRutaValida = false; + _tramosRuta.Clear(); + _duracionTotalSegundos = 0; + _altitudOrigenResumenRuta = null; + _altitudDestinoResumenRuta = null; + + _paradaOrigenSeleccionada = null; + _paradaDestinoSeleccionada = null; + _primerTransbordoseleccionado = null; + _numeroTransbordos = 0; + _distanciaAPieInicio = _distanciaAPieFin = _distanciaEnBus = 0; + _paradaClicada = null; + + if (limpiarOpciones) + { + _opcionesRuta.Clear(); + _indiceAlternativaSeleccionada = 0; + _tiempoUltimoCalculoRuta = null; + } + } + + // ========================================================= + // MODELOS internos + // ========================================================= + private ElementoItinerario? _itinerarioClicado; + private string? _itinerarioClicadoSubId; // id exacto pulsado (por si es subId) + + private sealed class CandidataParada + { + public Parada Parada { get; set; } = default!; + public double DistEucl { get; set; } + public double DistWalk { get; set; } + public double TiempoWalk { get; set; } + } + private bool _zoomLimitsAplicados = false; + + // ========================================================= + // Ciclo de vida + // ========================================================= +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Geocodificacion.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Geocodificacion.cs new file mode 100644 index 0000000..f10a4ea --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Geocodificacion.cs @@ -0,0 +1,299 @@ +using System.Globalization; +using RutasDBUS.Modelos.Geocodificacion; +using RutasDBUS.Modelos.RedBus; + +namespace RutasDBUS.Components.Pages; + +public partial class PlanificadorRutas +{ + private enum CampoBusquedaMapa + { + Origen, + Destino + } + + private sealed class SugerenciaBusquedaMapa + { + public string Id { get; init; } = ""; + public string TextoEntrada { get; init; } = ""; + public string Titulo { get; init; } = ""; + public string Subtitulo { get; init; } = ""; + public string Tipo { get; init; } = ""; + public string Fuente { get; init; } = ""; + public double Latitud { get; init; } + public double Longitud { get; init; } + } + + private readonly List _sugerenciasBusquedaOrigen = new(); + private readonly List _sugerenciasBusquedaDestino = new(); + private CampoBusquedaMapa? _campoBusquedaActivo; + private SugerenciaBusquedaMapa? _ubicacionOrigenSeleccionada; + private SugerenciaBusquedaMapa? _ubicacionDestinoSeleccionada; + private int _versionSugerenciasBusqueda; + private CancellationTokenSource? _ctsSugerenciasBusqueda; + + private IReadOnlyList SugerenciasOrigenVisibles => + _campoBusquedaActivo == CampoBusquedaMapa.Origen + ? _sugerenciasBusquedaOrigen + : Array.Empty(); + + private IReadOnlyList SugerenciasDestinoVisibles => + _campoBusquedaActivo == CampoBusquedaMapa.Destino + ? _sugerenciasBusquedaDestino + : Array.Empty(); + + private Task ActualizarSugerenciasOrigenAsync() + => ActualizarSugerenciasBusquedaAsync(CampoBusquedaMapa.Origen); + + private Task ActualizarSugerenciasDestinoAsync() + => ActualizarSugerenciasBusquedaAsync(CampoBusquedaMapa.Destino); + + private Task MostrarSugerenciasOrigenAsync() + => ActualizarSugerenciasBusquedaAsync(CampoBusquedaMapa.Origen); + + private Task MostrarSugerenciasDestinoAsync() + => ActualizarSugerenciasBusquedaAsync(CampoBusquedaMapa.Destino); + + private async Task ActualizarSugerenciasBusquedaAsync(CampoBusquedaMapa campo) + { + _campoBusquedaActivo = campo; + InvalidarUbicacionSeleccionadaSiEditada(campo); + + var texto = ObtenerTextoBusqueda(campo); + var version = ++_versionSugerenciasBusqueda; + + _ctsSugerenciasBusqueda?.Cancel(); + _ctsSugerenciasBusqueda = new CancellationTokenSource(); + var ct = _ctsSugerenciasBusqueda.Token; + + if (string.IsNullOrWhiteSpace(texto) || texto.Trim().Length < 2) + { + AsignarSugerenciasBusqueda(campo, Array.Empty()); + StateHasChanged(); + return; + } + + try + { + await Task.Delay(120, ct); + var sugerencias = await CrearSugerenciasBusquedaAsync(texto, 10, ct); + + if (ct.IsCancellationRequested || version != _versionSugerenciasBusqueda) + return; + + AsignarSugerenciasBusqueda(campo, sugerencias); + StateHasChanged(); + } + catch (OperationCanceledException) + { + } + catch + { + AsignarSugerenciasBusqueda(campo, Array.Empty()); + StateHasChanged(); + } + } + + private async Task SeleccionarSugerenciaBusquedaAsync( + CampoBusquedaMapa campo, + SugerenciaBusquedaMapa sugerencia) + { + if (campo == CampoBusquedaMapa.Origen) + { + _textoBusquedaOrigen = sugerencia.TextoEntrada; + _coordenadasOrigen = new[] { sugerencia.Latitud, sugerencia.Longitud }; + _ubicacionOrigenSeleccionada = sugerencia; + _sugerenciasBusquedaOrigen.Clear(); + } + else + { + _textoBusquedaDestino = sugerencia.TextoEntrada; + _coordenadasDestino = new[] { sugerencia.Latitud, sugerencia.Longitud }; + _ubicacionDestinoSeleccionada = sugerencia; + _sugerenciasBusquedaDestino.Clear(); + } + + _campoBusquedaActivo = null; + _mensajeRuta = string.Empty; + _hayRutaValida = false; + + await SincronizarMarcadoresOrigenDestinoJsAsync(); + StateHasChanged(); + } + + private async Task ResolverUbicacionBusquedaAsync( + CampoBusquedaMapa campo, + CancellationToken cancellationToken = default) + { + var texto = ObtenerTextoBusqueda(campo); + if (string.IsNullOrWhiteSpace(texto)) + return null; + + var seleccion = ObtenerUbicacionSeleccionada(campo); + if (seleccion is not null && + string.Equals(texto.Trim(), seleccion.TextoEntrada, StringComparison.OrdinalIgnoreCase)) + { + return seleccion; + } + + var parada = ResolverParadaDesdeTexto(texto); + if (parada is not null) + return CrearSugerenciaParada(parada); + + var sugerencias = await CrearSugerenciasBusquedaAsync(texto, 1, cancellationToken); + return sugerencias.FirstOrDefault(); + } + + private async Task> CrearSugerenciasBusquedaAsync( + string texto, + int maxResultados, + CancellationToken cancellationToken) + { + var paradas = BuscarSugerenciasParadas(texto, 5); + var lugares = await GeocodificadorLocal.BuscarAsync(texto, Math.Max(6, maxResultados), cancellationToken); + + return paradas + .Concat(lugares.Select(CrearSugerenciaLugar)) + .GroupBy(sugerencia => NormalizarClaveSugerencia(sugerencia), StringComparer.Ordinal) + .Select(grupo => grupo.First()) + .Take(Math.Clamp(maxResultados, 1, 20)) + .ToList(); + } + + private IReadOnlyList BuscarSugerenciasParadas(string texto, int maxResultados) + { + var consulta = NormalizeForSearch(texto); + var tokens = consulta + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (tokens.Length == 0) + return Array.Empty(); + + return RedBusServicio.Paradas + .Select(parada => new + { + Parada = parada, + Puntuacion = PuntuarParada(parada, consulta, tokens) + }) + .Where(x => x.Puntuacion < int.MaxValue) + .OrderBy(x => x.Puntuacion) + .ThenBy(x => x.Parada.Nombre, StringComparer.CurrentCultureIgnoreCase) + .Take(Math.Clamp(maxResultados, 1, 12)) + .Select(x => CrearSugerenciaParada(x.Parada)) + .ToList(); + } + + private static int PuntuarParada(Parada parada, string consulta, IReadOnlyList tokens) + { + var codigo = NormalizeForSearch(CodigoParadaFormateado(parada.Codigo)); + var codigoRaw = NormalizeForSearch(parada.Codigo); + var nombre = NormalizeForSearch(parada.Nombre); + var nombreCorto = NormalizeForSearch(parada.NombreCorto); + var abreviatura = NormalizeForSearch(parada.Abreviatura); + var texto = $"{codigo} {codigoRaw} {nombre} {nombreCorto} {abreviatura}"; + + foreach (var token in tokens) + { + if (!texto.Contains(token, StringComparison.Ordinal)) + return int.MaxValue; + } + + if (codigo.Equals(consulta, StringComparison.Ordinal) || + codigoRaw.Equals(consulta, StringComparison.Ordinal)) + { + return 0; + } + + if (nombre.Equals(consulta, StringComparison.Ordinal)) + return 8; + + if (nombre.StartsWith(consulta, StringComparison.Ordinal) || + nombreCorto.StartsWith(consulta, StringComparison.Ordinal) || + abreviatura.StartsWith(consulta, StringComparison.Ordinal)) + { + return 15; + } + + return 45 + Math.Min(40, nombre.Length / 3); + } + + private static SugerenciaBusquedaMapa CrearSugerenciaParada(Parada parada) + { + var codigo = CodigoParadaFormateado(parada.Codigo); + var texto = $"{parada.Nombre} ({codigo})"; + + return new SugerenciaBusquedaMapa + { + Id = "parada:" + parada.Codigo, + TextoEntrada = texto, + Titulo = texto, + Subtitulo = "Parada DBUS", + Tipo = "Parada", + Fuente = "DBUS", + Latitud = parada.Latitud, + Longitud = parada.Longitud + }; + } + + private static SugerenciaBusquedaMapa CrearSugerenciaLugar(LugarGeocodificado lugar) + { + return new SugerenciaBusquedaMapa + { + Id = lugar.Id, + TextoEntrada = lugar.Nombre, + Titulo = lugar.Nombre, + Subtitulo = string.IsNullOrWhiteSpace(lugar.Subtitulo) + ? $"{lugar.Tipo} · {lugar.Fuente}" + : $"{lugar.Subtitulo} · {lugar.Fuente}", + Tipo = lugar.Tipo, + Fuente = lugar.Fuente, + Latitud = lugar.Latitud, + Longitud = lugar.Longitud + }; + } + + private string ObtenerTextoBusqueda(CampoBusquedaMapa campo) + => campo == CampoBusquedaMapa.Origen + ? _textoBusquedaOrigen + : _textoBusquedaDestino; + + private SugerenciaBusquedaMapa? ObtenerUbicacionSeleccionada(CampoBusquedaMapa campo) + => campo == CampoBusquedaMapa.Origen + ? _ubicacionOrigenSeleccionada + : _ubicacionDestinoSeleccionada; + + private void InvalidarUbicacionSeleccionadaSiEditada(CampoBusquedaMapa campo) + { + var texto = ObtenerTextoBusqueda(campo).Trim(); + var seleccion = ObtenerUbicacionSeleccionada(campo); + if (seleccion is null || + string.Equals(texto, seleccion.TextoEntrada, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (campo == CampoBusquedaMapa.Origen) + _ubicacionOrigenSeleccionada = null; + else + _ubicacionDestinoSeleccionada = null; + } + + private void AsignarSugerenciasBusqueda( + CampoBusquedaMapa campo, + IReadOnlyList sugerencias) + { + var lista = campo == CampoBusquedaMapa.Origen + ? _sugerenciasBusquedaOrigen + : _sugerenciasBusquedaDestino; + + lista.Clear(); + lista.AddRange(sugerencias); + } + + private static string NormalizarClaveSugerencia(SugerenciaBusquedaMapa sugerencia) + { + var lat = sugerencia.Latitud.ToString("0.000000", CultureInfo.InvariantCulture); + var lon = sugerencia.Longitud.ToString("0.000000", CultureInfo.InvariantCulture); + return $"{NormalizeForSearch(sugerencia.Titulo)}|{lat}|{lon}"; + } +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs new file mode 100644 index 0000000..31da2db --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs @@ -0,0 +1,593 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.JSInterop; +using RutasDBUS.Modelos.Historial; + +namespace RutasDBUS.Components.Pages; + +public partial class PlanificadorRutas +{ + private const string ClaveHistorialLocal = "rutasdbus.planificador.routeHistory.v1"; + private const int MaxItemsHistorialLocal = 100; + private const int IntervaloRefrescoHistorialCompartidoSegundos = 3; + + private readonly List _historialLocal = new(); + private readonly List _historialCompartido = new(); + private PeriodicTimer? _historialCompartidoTimer; + private CancellationTokenSource? _historialCompartidoCts; + private string _historialLocalSeleccionadoId = string.Empty; + private string _historialCompartidoSeleccionadoId = string.Empty; + private string _mensajeHistorial = string.Empty; + private bool _mensajeHistorialEsError = false; + private bool _mostrarHistorialRutas = false; + private bool _refrescandoHistorialCompartido = false; + private bool _omitirRegistroHistorialEnSiguienteCalculo = false; + private string? _historialCompartidoPendienteEliminarId; + private ConfiguracionLocalPlanificador? _configuracionTemporalPreviaHistorial; + + private async Task ToggleHistorialPanel() + { + if (_mostrarHistorialRutas) + { + _mostrarHistorialRutas = false; + StopRefrescoHistorialCompartido(); + await RestaurarConfiguracionTemporalHistorialAsync(); + return; + } + + _mostrarHistorialRutas = true; + await CargarHistorialCompartidoAsync(); + StartRefrescoHistorialCompartido(); + } + + private async Task CargarHistorialLocalAsync() + { + try + { + var json = await JS.InvokeAsync("rt.loadLocalValue", ClaveHistorialLocal); + if (string.IsNullOrWhiteSpace(json)) + return; + + var items = JsonSerializer.Deserialize>(json); + if (items is null) + return; + + _historialLocal.Clear(); + _historialLocal.AddRange(items + .Where(x => !string.IsNullOrWhiteSpace(x.Id)) + .OrderByDescending(x => x.FechaActualizacionUtc) + .Take(MaxItemsHistorialLocal)); + + _historialLocalSeleccionadoId = _historialLocal.FirstOrDefault()?.Id ?? string.Empty; + } + catch (JSException) + { + } + catch (JsonException) + { + } + } + + private async Task PersistirHistorialLocalAsync() + { + try + { + var json = JsonSerializer.Serialize(_historialLocal); + await JS.InvokeVoidAsync("rt.saveLocalValue", ClaveHistorialLocal, json); + } + catch (JSException) + { + } + } + + private async Task CargarHistorialCompartidoAsync(bool mostrarError = true) + { + if (_refrescandoHistorialCompartido) + return; + + _refrescandoHistorialCompartido = true; + try + { + var items = await HistorialRutasCompartidoService.ObtenerAsync(); + _historialCompartido.Clear(); + _historialCompartido.AddRange(items); + + if (_historialCompartido.Count == 0) + { + _historialCompartidoSeleccionadoId = string.Empty; + return; + } + + if (!_historialCompartido.Any(x => x.Id == _historialCompartidoSeleccionadoId)) + _historialCompartidoSeleccionadoId = _historialCompartido[0].Id; + } + catch (Exception) + { + if (mostrarError) + MostrarMensajeHistorial("No he podido cargar el historial compartido.", esError: true); + } + finally + { + _refrescandoHistorialCompartido = false; + } + } + + private void StartRefrescoHistorialCompartido() + { + if (_historialCompartidoTimer is not null) + return; + + var cts = new CancellationTokenSource(); + var timer = new PeriodicTimer(TimeSpan.FromSeconds(IntervaloRefrescoHistorialCompartidoSegundos)); + _historialCompartidoCts = cts; + _historialCompartidoTimer = timer; + + _ = Task.Run(async () => + { + try + { + while (await timer.WaitForNextTickAsync(cts.Token)) + { + if (!_mostrarHistorialRutas) + continue; + + await InvokeAsync(async () => + { + await CargarHistorialCompartidoAsync(mostrarError: false); + StateHasChanged(); + }); + } + } + catch + { + // Timer cancelled when the popup or component closes. + } + }); + } + + private void StopRefrescoHistorialCompartido() + { + _historialCompartidoCts?.Cancel(); + _historialCompartidoTimer?.Dispose(); + _historialCompartidoCts?.Dispose(); + + _historialCompartidoTimer = null; + _historialCompartidoCts = null; + } + + private async Task RegistrarEscenarioActualEnHistorialLocalAsync() + { + var actual = ConstruirRegistroHistorialActual(); + if (actual is null) + return; + + var clave = CalcularClaveEscenario(actual); + var existente = _historialLocal.FirstOrDefault(x => + string.Equals(CalcularClaveEscenario(x), clave, StringComparison.OrdinalIgnoreCase)); + + if (existente is null) + { + existente = actual; + _historialLocal.Insert(0, existente); + } + else + { + existente.OrigenTexto = actual.OrigenTexto; + existente.DestinoTexto = actual.DestinoTexto; + existente.OrigenLatitud = actual.OrigenLatitud; + existente.OrigenLongitud = actual.OrigenLongitud; + existente.DestinoLatitud = actual.DestinoLatitud; + existente.DestinoLongitud = actual.DestinoLongitud; + existente.UsarHorariosTeoricos = actual.UsarHorariosTeoricos; + existente.UsarHoraSimulada = actual.UsarHoraSimulada; + existente.FechaSimulada = actual.FechaSimulada; + existente.HoraSimulada = actual.HoraSimulada; + existente.CriterioPrioritarioRuta = actual.CriterioPrioritarioRuta; + existente.PreferenciaEtiquetaRuta = actual.PreferenciaEtiquetaRuta; + existente.ActivarFiltroLineas = actual.ActivarFiltroLineas; + existente.LineasPermitidas = actual.LineasPermitidas.ToList(); + existente.FechaActualizacionUtc = DateTime.UtcNow; + + _historialLocal.Remove(existente); + _historialLocal.Insert(0, existente); + } + + while (_historialLocal.Count > MaxItemsHistorialLocal) + _historialLocal.RemoveAt(_historialLocal.Count - 1); + + _historialLocalSeleccionadoId = existente.Id; + await PersistirHistorialLocalAsync(); + } + + private async Task CargarHistorialLocalSeleccionadoAsync() + { + var item = _historialLocal.FirstOrDefault(x => x.Id == _historialLocalSeleccionadoId); + if (item is null) + { + MostrarMensajeHistorial("No encuentro esa combinacion en tu historial personal.", esError: true); + return; + } + + await AplicarRegistroHistorialAsync(item); + MostrarMensajeHistorial("Combinacion personal cargada."); + } + + private async Task CargarHistorialLocalAsync(string id) + { + _historialLocalSeleccionadoId = id; + await CargarHistorialLocalSeleccionadoAsync(); + } + + private async Task CargarHistorialCompartidoSeleccionadoAsync() + { + var item = _historialCompartido.FirstOrDefault(x => x.Id == _historialCompartidoSeleccionadoId); + if (item is null) + { + MostrarMensajeHistorial("No encuentro esa combinacion en el historial compartido.", esError: true); + return; + } + + await AplicarRegistroHistorialAsync(item); + MostrarMensajeHistorial("Combinación compartida cargada."); + } + + private async Task CargarHistorialCompartidoAsync(string id) + { + _historialCompartidoSeleccionadoId = id; + await CargarHistorialCompartidoSeleccionadoAsync(); + } + + private async Task CompartirEscenarioActualAsync() + { + var actual = ConstruirRegistroHistorialActual() + ?? _historialLocal.FirstOrDefault(x => x.Id == _historialLocalSeleccionadoId); + + if (actual is null) + { + MostrarMensajeHistorial("Primero necesitamos una combinacion valida para poder compartirla.", esError: true); + return; + } + + try + { + var guardado = await HistorialRutasCompartidoService.GuardarAsync(actual); + await CargarHistorialCompartidoAsync(); + _historialCompartidoSeleccionadoId = guardado.Id; + MostrarMensajeHistorial("Combinacion enviada al historial compartido."); + } + catch (Exception) + { + MostrarMensajeHistorial("No he podido guardar la combinacion en el historial compartido.", esError: true); + } + } + + private async Task CompartirHistorialLocalAsync(string id) + { + _historialLocalSeleccionadoId = id; + var item = _historialLocal.FirstOrDefault(x => x.Id == id); + if (item is null) + { + MostrarMensajeHistorial("No encuentro esa combinacion en tu historial personal.", esError: true); + return; + } + + try + { + var guardado = await HistorialRutasCompartidoService.GuardarAsync(item); + await CargarHistorialCompartidoAsync(); + _historialCompartidoSeleccionadoId = guardado.Id; + MostrarMensajeHistorial("Combinacion enviada al historial compartido."); + } + catch (Exception) + { + MostrarMensajeHistorial("No he podido guardar la combinacion en el historial compartido.", esError: true); + } + } + + private async Task EliminarHistorialLocalAsync(string id) + { + var item = _historialLocal.FirstOrDefault(x => x.Id == id); + if (item is null) + return; + + _historialLocal.Remove(item); + + if (_historialLocalSeleccionadoId == id) + _historialLocalSeleccionadoId = _historialLocal.FirstOrDefault()?.Id ?? string.Empty; + + await PersistirHistorialLocalAsync(); + MostrarMensajeHistorial("Combinacion eliminada del historial personal."); + } + + private void PedirConfirmacionEliminarHistorialCompartido(string id) + { + if (string.IsNullOrWhiteSpace(id)) + return; + + _historialCompartidoPendienteEliminarId = id; + } + + private void CancelarConfirmacionEliminarHistorialCompartido() + { + _historialCompartidoPendienteEliminarId = null; + } + + private async Task ConfirmarEliminarHistorialCompartidoAsync() + { + var id = _historialCompartidoPendienteEliminarId; + _historialCompartidoPendienteEliminarId = null; + + if (string.IsNullOrWhiteSpace(id)) + return; + + await EliminarHistorialCompartidoAsync(id); + } + + private async Task EliminarHistorialCompartidoAsync(string id) + { + try + { + var ok = await HistorialRutasCompartidoService.EliminarAsync(id); + if (!ok) + { + MostrarMensajeHistorial("No he podido eliminar esa combinacion compartida.", esError: true); + return; + } + + await CargarHistorialCompartidoAsync(); + + if (_historialCompartidoSeleccionadoId == id) + _historialCompartidoSeleccionadoId = _historialCompartido.FirstOrDefault()?.Id ?? string.Empty; + + MostrarMensajeHistorial("Combinacion eliminada del historial compartido."); + } + catch (Exception) + { + MostrarMensajeHistorial("No he podido eliminar esa combinacion compartida.", esError: true); + } + } + + private async Task AplicarRegistroHistorialAsync(RegistroHistorialRuta item) + { + CapturarConfiguracionTemporalAntesDeHistorial(); + + _omitirRegistroHistorialEnSiguienteCalculo = true; + _textoBusquedaOrigen = item.OrigenTexto; + _textoBusquedaDestino = item.DestinoTexto; + _coordenadasOrigen = new[] { item.OrigenLatitud, item.OrigenLongitud }; + _coordenadasDestino = new[] { item.DestinoLatitud, item.DestinoLongitud }; + + _usarHorariosTeoricos = item.UsarHorariosTeoricos; + _usarHoraSimulada = item.UsarHoraSimulada; + + if (DateOnly.TryParse(item.FechaSimulada, out var fecha)) + _fechaSimulada = fecha; + + if (TimeOnly.TryParse(item.HoraSimulada, out var hora)) + _horaSimulada = hora; + + _criterioPrioritarioRuta = NormalizarCriterioRuta(item.CriterioPrioritarioRuta, item.PreferenciaEtiquetaRuta); + SincronizarPreferenciaDesdeCriterioRuta(); + + _activarRangoLineas = item.ActivarFiltroLineas; + _lineasPermitidas.Clear(); + foreach (var linea in NormalizarLineasHistorial(item.LineasPermitidas)) + _lineasPermitidas.Add(linea); + _busFilteredCache.Clear(); + + SincronizarSegundosPorParadaConHorarios(); + + StopClock(); + if (_usarHoraSimulada) + await RefrescarHoraUIAsync(); + else + StartClock(); + + await PersistirConfiguracionAsync(); + await CerrarTodasLasPreviewsAsync(); + await CerrarPopupsAsync(); + + if (_mapa is not null) + { + try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } + await ClearRoutesJsAsync(); + } + + await SincronizarMarcadoresOrigenDestinoJsAsync(); + await CalcularRutaCompletaAsync(); + } + + private void CapturarConfiguracionTemporalAntesDeHistorial() + { + if (_configuracionTemporalPreviaHistorial is not null) + return; + + _configuracionTemporalPreviaHistorial = CrearConfiguracionLocal(); + } + + private async Task RestaurarConfiguracionTemporalHistorialAsync() + { + var configuracionPrevia = _configuracionTemporalPreviaHistorial; + if (configuracionPrevia is null) + return; + + _configuracionTemporalPreviaHistorial = null; + AplicarConfiguracionLocal(configuracionPrevia); + + StopClock(); + if (_usarHoraSimulada) + await RefrescarHoraUIAsync(); + else + StartClock(); + + await PersistirConfiguracionAsync(); + } + + private RegistroHistorialRuta? ConstruirRegistroHistorialActual() + { + if (_coordenadasOrigen is null || _coordenadasDestino is null) + return null; + + var ahora = _ultimaHoraReferenciaRuta ?? AhoraLocal(); + + return new RegistroHistorialRuta + { + Id = Guid.NewGuid().ToString("N"), + OrigenTexto = ResolverTextoPuntoHistorial(_coordenadasOrigen), + DestinoTexto = ResolverTextoPuntoHistorial(_coordenadasDestino), + OrigenLatitud = _coordenadasOrigen[0], + OrigenLongitud = _coordenadasOrigen[1], + DestinoLatitud = _coordenadasDestino[0], + DestinoLongitud = _coordenadasDestino[1], + UsarHorariosTeoricos = _usarHorariosTeoricos, + UsarHoraSimulada = _usarHoraSimulada, + FechaSimulada = DateOnly.FromDateTime(ahora).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + HoraSimulada = TimeOnly.FromDateTime(ahora).ToString("HH:mm:ss", CultureInfo.InvariantCulture), + CriterioPrioritarioRuta = _criterioPrioritarioRuta, + PreferenciaEtiquetaRuta = _preferenciaEtiquetaRuta, + ActivarFiltroLineas = _activarRangoLineas, + LineasPermitidas = ObtenerLineasPermitidasSeleccionadas().ToList(), + FechaCreacionUtc = DateTime.UtcNow, + FechaActualizacionUtc = DateTime.UtcNow + }; + } + + private static string? FormatearParadaHistorial(RutasDBUS.Modelos.RedBus.Parada? parada) + { + if (parada is null || string.IsNullOrWhiteSpace(parada.Codigo)) + return null; + + var nombre = string.IsNullOrWhiteSpace(parada.Nombre) + ? parada.Codigo + : parada.Nombre.Trim(); + + return $"{nombre} ({CodigoParadaFormateado(parada.Codigo)})"; + } + + private string ResolverTextoPuntoHistorial(double[] coords) + { + var lat = coords[0]; + var lon = coords[1]; + + var parada = RedBusServicio.BuscarParadaMasCercana(lat, lon); + + var textoParada = FormatearParadaHistorial(parada); + if (!string.IsNullOrWhiteSpace(textoParada)) + return textoParada; + + return $"{lat.ToString("0.000000", CultureInfo.InvariantCulture)}, {lon.ToString("0.000000", CultureInfo.InvariantCulture)}"; + } + + private string TextoOpcionHistorial(RegistroHistorialRuta item) + { + var sello = item.FechaActualizacionUtc == default + ? string.Empty + : item.FechaActualizacionUtc.ToLocalTime().ToString("dd/MM HH:mm", CultureInfo.CurrentCulture); + var horaReferencia = FormatearHoraReferenciaHistorial(item); + var modo = FormatearModoHistorial(item); + + var textoBase = string.IsNullOrWhiteSpace(sello) + ? $"{item.OrigenTexto} -> {item.DestinoTexto}" + : $"{sello} - {item.OrigenTexto} -> {item.DestinoTexto}"; + + var partes = new List { textoBase }; + if (!string.IsNullOrWhiteSpace(horaReferencia)) + partes.Add($"HR: {horaReferencia}"); + if (!string.IsNullOrWhiteSpace(modo)) + partes.Add($"Modo: {modo}"); + return string.Join(" \u00b7 ", partes); + + } + + private static string FormatearModoHistorial(RegistroHistorialRuta item) + { + var criterio = item.CriterioPrioritarioRuta?.Trim(); + if (string.IsNullOrWhiteSpace(criterio)) + { + var preferencia = item.PreferenciaEtiquetaRuta?.Trim() ?? string.Empty; + return string.Equals(preferencia, PreferenciaRutaAndar, StringComparison.OrdinalIgnoreCase) + ? "Andar" + : preferencia; + } + + if (string.Equals(criterio, CriterioRutaAndar, StringComparison.OrdinalIgnoreCase)) + return "Andar"; + if (string.Equals(criterio, CriterioRutaTiempo, StringComparison.OrdinalIgnoreCase)) + return "Tiempo"; + if (string.Equals(criterio, CriterioRutaEquilibrado, StringComparison.OrdinalIgnoreCase)) + return "Equilibrado"; + + return criterio; + } + + private static IReadOnlyList NormalizarLineasHistorial(IEnumerable? lineas) + => (lineas ?? Array.Empty()) + .Where(linea => linea > 0) + .Distinct() + .OrderBy(linea => linea) + .ToList(); + + private static string FormatearHoraReferenciaHistorial(RegistroHistorialRuta item) + { + if (string.IsNullOrWhiteSpace(item.HoraSimulada)) + return string.Empty; + + var formatosHora = new[] { "HH:mm:ss", "H:mm:ss", "HH:mm", "H:mm" }; + if (!TimeOnly.TryParseExact( + item.HoraSimulada.Trim(), + formatosHora, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var hora)) + { + return string.Empty; + } + + var horaTexto = hora.ToString("HH:mm", CultureInfo.InvariantCulture); + if (string.IsNullOrWhiteSpace(item.FechaSimulada)) + return horaTexto; + + var formatosFecha = new[] { "yyyy-MM-dd", "dd/MM/yyyy", "d/M/yyyy", "dd/MM/yy", "d/M/yy" }; + return DateOnly.TryParseExact( + item.FechaSimulada.Trim(), + formatosFecha, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var fecha) + ? $"{fecha.ToString("dd/MM", CultureInfo.InvariantCulture)} {horaTexto}" + : $"{item.FechaSimulada.Trim()} {horaTexto}"; + } + + private void MostrarMensajeHistorial(string mensaje, bool esError = false) + { + _mensajeHistorial = mensaje; + _mensajeHistorialEsError = esError; + } + + private static string CalcularClaveEscenario(RegistroHistorialRuta item) + { + var origenLat = Math.Round(item.OrigenLatitud, 6).ToString("0.000000", CultureInfo.InvariantCulture); + var origenLon = Math.Round(item.OrigenLongitud, 6).ToString("0.000000", CultureInfo.InvariantCulture); + var destinoLat = Math.Round(item.DestinoLatitud, 6).ToString("0.000000", CultureInfo.InvariantCulture); + var destinoLon = Math.Round(item.DestinoLongitud, 6).ToString("0.000000", CultureInfo.InvariantCulture); + var fecha = item.UsarHoraSimulada ? item.FechaSimulada?.Trim() ?? string.Empty : string.Empty; + var hora = item.UsarHoraSimulada ? item.HoraSimulada?.Trim() ?? string.Empty : string.Empty; + var criterio = NormalizarCriterioRuta(item.CriterioPrioritarioRuta, item.PreferenciaEtiquetaRuta); + var lineas = item.ActivarFiltroLineas + ? string.Join(",", NormalizarLineasHistorial(item.LineasPermitidas)) + : string.Empty; + + return string.Join("|", + origenLat, + origenLon, + destinoLat, + destinoLon, + item.UsarHorariosTeoricos ? "1" : "0", + item.UsarHoraSimulada ? "1" : "0", + fecha, + hora, + criterio, + item.ActivarFiltroLineas ? "1" : "0", + lineas); + } +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs new file mode 100644 index 0000000..ade58c0 --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs @@ -0,0 +1,1565 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using LeafletForBlazor; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using RutasDBUS.Modelos.Planificacion; +using RutasDBUS.Modelos.RedBus; +using RutasDBUS.Servicios; +using RutasDBUS.Servicios.Caminata; +using RutasDBUS.Servicios.Horarios; +using RutasDBUS.Servicios.Planificacion; +using RutasDBUS.Servicios.RedBus; + +namespace RutasDBUS.Components.Pages; + +public partial class PlanificadorRutas : ComponentBase +{ + private enum ProveedorCaminata + { + Osrm = 1, // v1 + Valhalla = 2 // v2 + } + + private ProveedorCaminata _walkProvider = ProveedorCaminata.Valhalla; // por defecto v2 + + private bool _mostrarBuscadorMapa = false; + + /// + /// Alterna buscador mapa. + /// + private void ToggleBuscadorMapa() + { + _mostrarBuscadorMapa = !_mostrarBuscadorMapa; + } + + /// + /// Quita todos itinerarios. + /// + private async Task QuitarTodosItinerarios() + { + await CerrarTodasLasPreviewsAsync(); + + // cerrar popup de itinerario al ocultar todo + _itinerarioClicado = null; + _itinerarioClicadoSubId = null; + + StateHasChanged(); + } + + /// + /// Actualiza colores itinerarios activos. + /// + private async Task RefrescarColoresItinerariosActivosAsync() + { + // Claves activas (26.12-I, 26.12-V, ...) + var clavesActivas = _itPreviewIdsPorClave + .Where(kvp => kvp.Value is not null && kvp.Value.Count > 0) + .Select(kvp => kvp.Key) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Quitamos todos los previews de itinerarios + await QuitarTodosItinerarios(); + + // Repintamos exactamente los sentidos que estaban activos + foreach (var it in _itItems) + { + if (it?.Variantes is null || it.Variantes.Count == 0) continue; + + foreach (var v in it.Variantes) + { + var clave = ItClave(v); + if (!clavesActivas.Contains(clave)) continue; + + // Pintar SOLO este sentido (como item temporal de 1 variante) + var temp = new ElementoItinerario + { + Id = it.Id, + Text = it.Text, + Variantes = new List { v } + }; + + await PintarItItemAsync(temp); + } + } + + StateHasChanged(); + } + /// + /// Gestiona cambio modo color itinerarios. + /// + private async Task OnCambioModoColorItinerarios(ChangeEventArgs e) + { + _usarColorRealLineaEnItinerarios = e?.Value is bool b && b; + await RefrescarColoresItinerariosActivosAsync(); + await PersistirConfiguracionAsync(); + } + + // true = usar color real del JSON (comportamiento actual) + // false = usar color secuencial por itinerario + private bool _usarColorRealLineaEnItinerarios = false; + + /// + /// Gestiona color itinerario vista previa. + /// + private string ColorItinerarioPreview(InfoVariante v, string? itItemId = null) + { + // ✅ Modo actual: color real desde JSON + if (_usarColorRealLineaEnItinerarios) + return ColorCssDesdeVariante(v); + + // ✅ Modo alternativo: color "secuencial/estable" desde paleta + // Clave estable por itinerario (línea + itinerario + sentido) + // así cada itinerario mantiene su color aunque repintes + var key = itItemId; + if (string.IsNullOrWhiteSpace(key)) + { + var linea = v.Linea?.Codigo?.ToString() ?? "?"; + var itin = v.CodigoItinerario.ToString(CultureInfo.InvariantCulture); + var sentido = NormalizarSentidoCodigo(v.Sentido?.Codigo); + key = $"{linea}.{itin}-{sentido}"; + } + + return ColorForKey(key); + } + + private int _selectItKey = 0; + + /// + /// Obtiene variante real clicada. + /// + private InfoVariante? GetVarianteRealClicada() + { + if (_itinerarioClicado is null || _itinerarioClicado.Variantes is null || _itinerarioClicado.Variantes.Count == 0) + return null; + + // Si solo hay una variante, es esa + if (_itinerarioClicado.Variantes.Count == 1) + return _itinerarioClicado.Variantes[0]; + + // Si hay varias (caso IV), mirar el subId real pulsado (..._I / ..._V) + var subId = _itinerarioClicadoSubId ?? ""; + + string? sentidoBuscado = null; + if (subId.EndsWith("_I", StringComparison.OrdinalIgnoreCase)) + sentidoBuscado = "I"; + else if (subId.EndsWith("_V", StringComparison.OrdinalIgnoreCase)) + sentidoBuscado = "V"; + + if (sentidoBuscado is not null) + { + var v = _itinerarioClicado.Variantes.FirstOrDefault(x => + string.Equals(NormalizarSentidoCodigo(x.Sentido?.Codigo), sentidoBuscado, StringComparison.OrdinalIgnoreCase)); + + if (v is not null) + return v; + } + + // Fallback defensivo (no debería hacer falta normalmente) + return _itinerarioClicado.Variantes.FirstOrDefault(); + } + + /// + /// Calcula distancia variante metros. + /// + private double CalcularDistanciaVarianteMetros(InfoVariante v) + { + if (v is null) return 0; + + var linea = ConstruirGeometriaCompletaVariante(v); + if (linea is null) return 0; + + double total = 0; + int n = linea.GetLength(0); + + for (int i = 1; i < n; i++) + { + total += CalcularDistanciaMetros( + linea[i - 1, 0], linea[i - 1, 1], + linea[i, 0], linea[i, 1] + ); + } + + return total; + } + + /// + /// Formatea distancia variante. + /// + private string FormatearDistanciaVariante(InfoVariante? v) + { + if (v is null) return "-"; + + var d = CalcularDistanciaVarianteMetros(v); + if (d <= 0) return "-"; + + return FormatearDistancia(d); // reutiliza tu helper actual + } + + + /// + /// Gestiona sincronizar seleccion itinerario con filtro. + /// + private void SincronizarSeleccionItinerarioConFiltro() + { + var filtrados = _itItemsFiltrados.ToList(); + + if (filtrados.Count == 0) + { + _itSeleccionadoId = null; + return; + } + + // Si el seleccionado actual sigue visible, lo mantenemos + if (!string.IsNullOrWhiteSpace(_itSeleccionadoId) && + filtrados.Any(x => x.Id.Equals(_itSeleccionadoId, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + // Si ya no está en el filtro, seleccionar el primero visible + _itSeleccionadoId = filtrados[0].Id; + } + + + + /// + /// Pinta it filtro. + /// + private async Task PintarItFiltro() + { + foreach (var it in _itItemsFiltrados) + { + if (it is null) continue; + await PintarItItemAsync(it); // ✅ pintará solo I/V que falten + } + } + + /// + /// Pinta it elemento. + /// + private async Task PintarItItemAsync(ElementoItinerario it) + { + if (it is null) return; + + for (int i = 0; i < it.Variantes.Count; i++) + { + var v = it.Variantes[i]; + if (v is null) continue; + + var clave = ItClave(v); + + // ✅ Si ESTE sentido ya está pintado, saltar solo este, no todo el item + if (_itPreviewIdsPorClave.TryGetValue(clave, out var set) && set.Count > 0) + continue; + + var full = ConstruirGeometriaCompletaVariante(v); + if (full is null) continue; + + var sentido = NormalizarSentidoCodigo(v.Sentido?.Codigo); // "I" o "V" + var subId = (it.Variantes.Count == 1) ? it.Id : $"{it.Id}_{sentido}"; + + var color = ColorItinerarioPreview(v, it.Id); + var dash = (sentido == "V") ? "10 10" : null; + + await JS.InvokeVoidAsync("rt.drawPreviewLine", subId, LineaToLatLng(full), new + { + color, + weight = 4, + opacity = 0.90, + dashArray = dash, + arrowEnd = true, + arrowBackPx = 26 + }); + + await JS.InvokeVoidAsync("rt.toggleItStops", subId, BuildStopsForVariante(v), new + { + radius = 7, + color = "#0f172a", + fillColor = color, + weight = 2, + opacity = 1.0, + fillOpacity = 1.0 + }); + + RegistrarPreview(clave, subId); + } + } + + /// + /// Indica si match wildcard. + /// + private static bool MatchWildcard(string input, string pattern) + { + if (string.IsNullOrWhiteSpace(pattern)) return true; + if (string.IsNullOrEmpty(input)) return false; + + // escapamos regex y convertimos comodines + var rx = "^" + Regex.Escape(pattern.Trim()) + .Replace("\\*", ".*") + .Replace("\\?", ".") + "$"; + + return Regex.IsMatch(input, rx, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + private Parada? _paradaPrimerClickPendiente; + + // Detecta si el click cae realmente sobre el círculo de una parada (en píxeles) + /// + /// Gestiona detectar parada hit. + /// + private async Task DetectarParadaHitAsync(double clickLat, double clickLon) + { + // ✅ Si las paradas están ocultas, no hay hit ni popup + if (!_mostrarParadas) + return null; + var maxDist = await RadioClickParadaMetrosAsync(clickLat); + + // Candidatea por distancia (rápido) + var parada = RedBusServicio.BuscarParadaCercanaEnRango(clickLat, clickLon, maxDistMetros: 40); + if (parada is null) return null; + + // Validación final: dentro del círculo en pantalla + var hit = await JS.InvokeAsync( + "rt.isClickInsideCirclePx", + clickLat, clickLon, + parada.Latitud, parada.Longitud, + 7 // radio (5) + margen (2) + ); + + return hit ? parada : null; + } + + // Tu popup actual, pero en método reutilizable (click simple) + /// + /// Muestra popup parada. + /// + private async Task MostrarPopupParadaAsync(Parada paradaDetectada) + { + var myToken = Interlocked.Increment(ref _paradaPopupToken); + + _paradaClicada = null; + _paradaElevG = null; + + // limpiar horarios del popup + _nextDeps.Clear(); + + StateHasChanged(); + + double? elevG = null; + + try + { + var kIdee = ClaveElevacionIdee(paradaDetectada.Latitud, paradaDetectada.Longitud); + + if (_elevIdeeCache.TryGetValue(kIdee, out var g)) + { + elevG = g; + } + else + { + await ObtenerElevacionesIdeePorLoteAsync( + new List<(double lat, double lon)> { (paradaDetectada.Latitud, paradaDetectada.Longitud) }, + CancellationToken.None); + + elevG = _elevIdeeCache.TryGetValue( + ClaveElevacionIdee(paradaDetectada.Latitud, paradaDetectada.Longitud), + out g) ? g : (double?)null; + } + } + catch + { + elevG = null; + } + + if (myToken != _paradaPopupToken) return; + + _paradaElevG = elevG; + _paradaClicada = paradaDetectada; + + // ========================== + // HORARIOS (ZIP) -> usando tu AhoraLocal() (real o simulada) + // ========================== + if (_usarHorariosTeoricos) + { + await EnsureHorariosLoadedAsync(); + + if (_horariosOk) + { + try + { + var now = AhoraLocal(); + var fecha = DateOnly.FromDateTime(now); + var hora = TimeOnly.FromDateTime(now); + + // tu parada usa Codigo (ej "34" o "P-34"). El servicio normaliza. + var deps = Horarios.GetNextDeparturesByCodigo( + paradaDetectada.Codigo, + fecha, + hora, + take: null, + includePast: true); + + _nextDeps = ConstruirListaHorariosConActual(deps, hora); + _scrollHorariosPendiente = true; + } + catch (Exception ex) + { + _horariosOk = false; + _horariosError = "Error leyendo horarios: " + ex.Message; + } + } + } + + StateHasChanged(); + } + + + + /// + /// Gestiona toggle horarios teoricos. + /// + private async Task OnToggleHorariosTeoricos(ChangeEventArgs e) + { + var usabaHorarios = _usarHorariosTeoricos; + _usarHorariosTeoricos = e?.Value is bool b && b; + + if (_usarHorariosTeoricos && !usabaHorarios) + _segundosPorParadaBusManual = Math.Max(0, _segundosPorParadaBus); + + SincronizarSegundosPorParadaConHorarios(); + + if (_usarHorariosTeoricos) + { + await EnsureHorariosLoadedAsync(); + + // si hay popup de parada abierto, refrescar inmediatamente + if (_paradaClicada is not null && _horariosOk) + { + try + { + var now = AhoraLocal(); + var fecha = DateOnly.FromDateTime(now); + var hora = TimeOnly.FromDateTime(now); + + var deps = Horarios.GetNextDeparturesByCodigo( + _paradaClicada.Codigo, + fecha, + hora, + take: null, + includePast: true); + + _nextDeps = ConstruirListaHorariosConActual(deps, hora); + _scrollHorariosPendiente = true; + } + catch (Exception ex) + { + _horariosOk = false; + _horariosError = "Error leyendo horarios: " + ex.Message; + } + } + + await PersistirConfiguracionAsync(); + } + else + { + _nextDeps.Clear(); + } + + StateHasChanged(); + } + + /// + /// Asegura que horarios la carga este disponible. + /// + private async Task EnsureHorariosLoadedAsync() + { + if (_horariosOk) return; + + _horariosError = null; + + try + { + // Carga el ZIP de importación resuelto por la capa compartida de importación. + await Horarios.AsegurarCargaAsync(Env.ContentRootPath); + _horariosOk = true; + } + catch (Exception ex) + { + _horariosOk = false; + _horariosError = ex.Message; + } + } + + + + private bool _mostrarParadas = true; + + /// + /// Alterna paradas. + /// + private async Task ToggleParadasAsync() + { + _mostrarParadas = !_mostrarParadas; + + if (_mapa is null) { StateHasChanged(); return; } + + if (_mostrarParadas) + { + // vuelve a pintar paradas (y también origen/destino si están) + await DibujarParadasAsync(); + } + else + { + // oculta paradas (pero deja rutas, previews, etc.) + await OcultarParadasAsync(); + } + + await PersistirConfiguracionAsync(); + + StateHasChanged(); + } + + /// + /// Oculta paradas. + /// + private async Task OcultarParadasAsync() + { + if (_mapa is null) return; + + // Esto borra TODOS los puntos pintados con DisplayPointsFromArray, + // así que luego reponemos origen/destino si existen. + await _mapa.Geometric.DisplayPointsFromArray.deleteAllPoints(); + } + + + + private int OpacidadMapaPorcentaje => _nivelBrilloMapa switch + { + 0 => 0, + 1 => 25, + 2 => 50, + 3 => 75, + 4 => 90, + _ => 0 + }; + + // ✅ Límite de transbordos + // ✅ Transbordos: máximo y rango (mín..máx) + private bool _activarRangoTransbordos = true; + private int _minTransbordosPermitidos = 0; + private int _maxTransbordosPermitidos = 3; + + + /// + /// Indica si cumple filtro transbordos. + /// + private bool CumpleFiltroTransbordos(int transbordos) + { + if (!_activarRangoTransbordos) + return true; + + int minT = Math.Max(0, _minTransbordosPermitidos); + int maxT = Math.Max(0, _maxTransbordosPermitidos); + + // por si el usuario mete al revés + if (minT > maxT) (minT, maxT) = (maxT, minT); + + return transbordos >= minT && transbordos <= maxT; + } + + /// + /// Indica si esta popup puntos abierto. + /// + private bool EstaPopupPuntosAbierto(DetalleTramoRuta tramo) + { + // Abierto si el popup está mostrando ESTE tramo y no está minimizado + return _tramoElevDetalle == tramo && !_elevPopupMinimizado; + } + + /// + /// Alterna puntos. + /// + private async Task TogglePuntosAsync(DetalleTramoRuta tramo) + { + if (tramo is null) return; + + // Si ya está abierto para este tramo => cerrar (igual que "- Iti") + if (_tramoElevDetalle == tramo && !_elevPopupMinimizado) + { + await CerrarPopupElevAsync(); + return; + } + + // Si estaba abierto para otro tramo, limpiamos puntos antes de cambiar + if (_tramoElevDetalle is not null && _tramoElevDetalle != tramo) + { + try { await JS.InvokeVoidAsync("rt.clearElevPoints", "elev"); } catch { } + } + + // Abrir para este tramo + _elevPopupMinimizado = false; + _tramoElevDetalle = tramo; + + await EnsureElevacionTramoAsync(tramo); + await PintarPuntosElevacionAsync(tramo); + + StateHasChanged(); + } + + private static readonly string[] _palette = + { + "#2563eb", "#16a34a", "#dc2626", "#7c3aed", "#ea580c", + "#0891b2", "#db2777", "#65a30d", "#0f766e", "#b45309", + "#4f46e5", "#be123c", "#15803d", "#c026d3", "#0284c7", + "#ca8a04" + }; + /// + /// Gestiona stable hash. + /// + private static int StableHash(string s) + { + unchecked + { + int h = 23; + foreach (var ch in s) + h = (h * 31) + ch; + return h; + } + } + + /// + /// Gestiona color for clave. + /// + private static string ColorForKey(string key) + { + var idx = Math.Abs(StableHash(key)) % _palette.Length; + return _palette[idx]; + } + /// + /// Gestiona color clave for variante. + /// + private static string ColorKeyForVariante(InfoVariante v) + { + var linea = v.Linea.Codigo?.ToString() ?? "?"; + var itin = v.CodigoItinerario.ToString(CultureInfo.InvariantCulture); + return $"{linea}.{itin}"; + } + + + private string _itFiltro = ""; + private string ItFiltro + { + get => _itFiltro; + set + { + value ??= ""; + + if (string.Equals(_itFiltro, value, StringComparison.Ordinal)) + return; + + var filtroAnterior = _itFiltro; + _itFiltro = value; + + // Si se limpia el filtro (X nativa o borrar), selecciona el primero visible + if (!string.IsNullOrWhiteSpace(filtroAnterior) && string.IsNullOrWhiteSpace(_itFiltro)) + { + _itSeleccionadoId = _itItemsFiltrados.FirstOrDefault()?.Id; + } + else + { + SincronizarSeleccionItinerarioConFiltro(); + } + + // Fuerza reconstrucción visual del select + _selectItKey++; + } + } + + /// + /// Gestiona it filtro busqueda. + /// + private void OnItFiltroSearch(ChangeEventArgs e) + { + ItFiltro = e?.Value?.ToString() ?? string.Empty; + StateHasChanged(); + } + private IEnumerable _itItemsFiltrados => + string.IsNullOrWhiteSpace(_itFiltro) + ? _itItems + : _itItems.Where(x => MatchFiltro(x, _itFiltro)); + + + + + /// + /// Indica si match filtro. + /// + private static bool MatchFiltro(ElementoItinerario? item, string? filtro) + { + var text = item?.TextoBusqueda; + if (string.IsNullOrWhiteSpace(filtro)) return true; + if (string.IsNullOrWhiteSpace(text)) return false; + + var haystack = NormalizeForSearch(text); + + // varios patrones separados por ; o , + var parts = filtro.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var raw in parts) + { + var f = raw.Trim(); + if (f.Length == 0) continue; + + // 1) Regex explícita: re:... + if (f.StartsWith("re:", StringComparison.OrdinalIgnoreCase)) + { + var rx = f.Substring(3).Trim(); + if (rx.Length == 0) continue; + + try + { + if (Regex.IsMatch(haystack, rx, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + return true; + } + catch + { + // regex inválida -> ignorar patrón + } + + continue; + } + + // Definición de "token" + const string TOKEN_CHARS = @"[\p{L}\p{N}.]"; + string leftBoundary = $@"(? regex + var inner = Regex.Escape(rawInner) + .Replace(@"\*", ".*") + .Replace(@"\?", "."); + + var rx = + (leadStar ? "" : leftBoundary) + + inner + + (tailStar ? "" : $@"(?!{TOKEN_CHARS})"); + + try + { + if (Regex.IsMatch(haystack, rx, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + return true; + } + catch + { + // patrón inválido -> ignorar + } + + continue; + } + + // 3) SIN wildcard: por defecto "empieza por" (equivale a añadir * al final) + var needle = NormalizeForSearch(f); + + // --- caso A: solo dígitos -> token numérico que EMPIEZA por needle --- + // Se interpreta como búsqueda de línea lógica: + // - líneas numéricas normales por prefijo + // - taxibús TB6/TB7 por su número real 6/7 + // - líneas B por su identificador 100 exacto (B1 -> 101) + if (Regex.IsMatch(needle, @"^\d+$")) + { + if (CoincideFiltroNumericoItinerario(item, needle)) + return true; + + continue; + } + + // --- caso B: ".5" fracción -> mantener lógica --- + if (Regex.IsMatch(needle, @"^\.\d+$")) + { + var rxFrac = $@"(?<=\p{{N}}){Regex.Escape(needle)}(?!\p{{N}})"; + if (Regex.IsMatch(haystack, rxFrac, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + return true; + + continue; + } + + // --- caso C: token general que EMPIEZA por needle --- + var rxTokenPrefix = $@"{leftBoundary}{Regex.Escape(needle)}{TOKEN_CHARS}*"; + if (Regex.IsMatch(haystack, rxTokenPrefix, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + return true; + } + + return false; + } + + /// + /// Evalúa búsquedas numéricas sobre la línea lógica del itinerario. + /// + private static bool CoincideFiltroNumericoItinerario(ElementoItinerario? item, string needle) + { + if (item?.Variantes is null || item.Variantes.Count == 0) + return false; + + foreach (var variante in item.Variantes) + { + var codigoLinea = variante?.Linea?.Codigo?.ToString()?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(codigoLinea)) + continue; + + var codigoNormalizado = UtilidadesTransitoDbus.CodigoSinDecimal(codigoLinea); + + if (Regex.IsMatch(codigoNormalizado, @"^\d+$") && + codigoNormalizado.StartsWith(needle, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var matchTb = Regex.Match(codigoNormalizado, @"^TB(\d+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (matchTb.Success && + matchTb.Groups[1].Value.StartsWith(needle, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var esBuho = Regex.IsMatch(codigoNormalizado, @"^B\d+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (esBuho && + int.TryParse(variante?.Linea?.IdRuta, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idRutaBuho) && + idRutaBuho.ToString(CultureInfo.InvariantCulture) == needle) + { + return true; + } + } + + return false; + } + // Normaliza: minúsculas + sin tildes (para búsquedas “humanas”) + /// + /// Gestiona normalize for busqueda. + /// + private static string NormalizeForSearch(string s) + { + s = s.Trim().ToLowerInvariant(); + const char marcadorEnye = '\uE000'; + s = s.Replace('\u00f1', marcadorEnye); + + // quitar diacríticos + var formD = s.Normalize(NormalizationForm.FormD); + var sb = new System.Text.StringBuilder(formD.Length); + foreach (var ch in formD) + { + var uc = CharUnicodeInfo.GetUnicodeCategory(ch); + if (uc != UnicodeCategory.NonSpacingMark) + sb.Append(ch); + } + return sb + .ToString() + .Normalize(NormalizationForm.FormC) + .Replace(marcadorEnye, '\u00f1'); + } + + + /// + /// Indica si match wildcard contains. + /// + private static bool MatchWildcardContains(string input, string pattern) + { + // en modo “contains”, envolvemos con *...* + if (!pattern.StartsWith("*")) pattern = "*" + pattern; + if (!pattern.EndsWith("*")) pattern = pattern + "*"; + + var rx = Regex.Escape(pattern) + .Replace("\\*", ".*") + .Replace("\\?", "."); + + return Regex.IsMatch(input, rx, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + + + + + private sealed class ElementoItinerario + { + public string Id { get; set; } = ""; + public string Text { get; set; } = ""; + public List SearchAliases { get; set; } = new(); + public string TextoBusqueda => + SearchAliases.Count == 0 + ? Text + : $"{Text} {string.Join(" ", SearchAliases)}"; + + // ✅ Puede contener 1 (solo Ida o solo Vuelta) o 2 (Ida+Vuelta) + public List Variantes { get; set; } = new(); + } + + + /// + /// Gestiona codigo linea as int. + /// + private static int CodigoLineaAsInt(InfoVariante v) + { + // v.Linea.Codigo puede ser int/decimal/string según tu modelo. + // Lo pasamos a string y extraemos la parte numérica inicial. + var numero = CodigoLineaAsInt(v?.Linea); + return numero; + } + + private static int CodigoLineaAsInt(LineaBus? linea) + { + if (linea is null) + return int.MaxValue; + + if (int.TryParse(linea.IdRuta, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idRuta)) + return idRuta; + + var numero = UtilidadesTransitoDbus.ExtraerNumeroLinea(linea.Codigo); + return numero ?? int.MaxValue; + } + + /// + /// Obtiene el identificador numerico real de la ruta para ordenar el filtro. + /// + private static int IdRutaAsInt(InfoVariante? variante) + { + if (variante?.Linea is null) + return int.MaxValue; + + return int.TryParse(variante.Linea.IdRuta, out var idRuta) + ? idRuta + : CodigoLineaAsInt(variante); + } + + + + /// + /// Construye un texto de trayecto usando la primera y la ultima parada de la variante. + /// + private string ConstruirTextoRecorridoVariante(InfoVariante? variante) + { + if (variante?.CodigosParadas is null || variante.CodigosParadas.Count == 0) + return ""; + + string NombreParada(string codigo) + { + if (_paradaByCode.TryGetValue(codigo, out var parada) && !string.IsNullOrWhiteSpace(parada.Nombre)) + return parada.Nombre.Trim(); + + return CodigoParadaFormateado(codigo); + } + + var origen = NombreParada(variante.CodigosParadas[0]); + var destino = NombreParada(variante.CodigosParadas[^1]); + + if (string.IsNullOrWhiteSpace(origen)) + return destino; + + if (string.IsNullOrWhiteSpace(destino)) + return origen; + + return string.Equals(origen, destino, StringComparison.OrdinalIgnoreCase) + ? origen + : $"{origen} -> {destino}"; + } + + /// + /// Obtiene nombres alternativos de expediciones para que el buscador encuentre el itinerario por destino. + /// + private List ObtenerAliasesBusquedaItinerario( + InfoVariante? variante, + Dictionary> cache) + { + if (variante is null) + return new List(); + + var sentido = NormalizarSentidoCodigo(variante.Sentido?.Codigo); + var key = string.Join("|", + variante.Linea.IdRuta ?? "", + variante.Linea.Codigo ?? "", + variante.CodigoItinerario.ToString(CultureInfo.InvariantCulture), + sentido); + + if (cache.TryGetValue(key, out var cached)) + return cached; + + try + { + cached = Horarios + .GetAliasBusquedaItinerario( + variante.Linea.IdRuta, + variante.Linea.Codigo, + variante.CodigoItinerario, + sentido) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch + { + cached = new List(); + } + + cache[key] = cached; + return cached; + } + + /// + /// Construye itinerarios dropdown. + /// + private void BuildItinerariosDropdown() + { + EnsureBusIndexBuilt(); + + var grupos = _infoVariantes + .GroupBy(v => new + { + Linea = v.Linea.Codigo?.ToString() ?? "?", + Itin = v.CodigoItinerario + }) + .OrderBy(g => + { + var any = g.FirstOrDefault(); + return any is null ? int.MaxValue : IdRutaAsInt(any); + }) + .ThenBy(g => g.Key.Linea, StringComparer.OrdinalIgnoreCase) + .ThenBy(g => g.Key.Itin) + .ToList(); + + var list = new List(grupos.Count * 2); + var aliasesCache = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var g in grupos) + { + var linea = g.Key.Linea; + var itin = g.Key.Itin; + + // 1) Elegimos UNA variante por sentido (determinista) + InfoVariante? ida = g + .Where(v => NormalizarSentidoCodigo(v.Sentido?.Codigo) == "I") + .OrderBy(v => v.Variante.Codigo) + .FirstOrDefault(); + + InfoVariante? vuelta = g + .Where(v => NormalizarSentidoCodigo(v.Sentido?.Codigo) == "V") + .OrderBy(v => v.Variante.Codigo) + .FirstOrDefault(); + + var nombreLinea = (ida?.Linea.Nombre ?? vuelta?.Linea.Nombre ?? ""); + + // 2) ✅ Si existe Ida: añadir item I + if (ida is not null) + { + var varCod = ida.Variante.Codigo.ToString() ?? "?"; + var id = $"it_{linea}_{itin}_I_{varCod}"; + var trayectoIda = ConstruirTextoRecorridoVariante(ida); + var textVisible = string.IsNullOrWhiteSpace(trayectoIda) + ? $"{linea}.{itin}-I — {ida.Linea.Nombre}" + : $"{linea}.{itin}-I — {trayectoIda}"; + var text = $"{linea}.{itin}-I — {ida.Linea.Nombre}"; + + text = textVisible; + + list.Add(new ElementoItinerario + { + Id = id, + Text = text, + SearchAliases = ObtenerAliasesBusquedaItinerario(ida, aliasesCache), + Variantes = new List { ida } + }); + } + + // 3) ✅ Si existe Vuelta: añadir item V + if (vuelta is not null) + { + var varCod = vuelta.Variante.Codigo.ToString() ?? "?"; + var id = $"it_{linea}_{itin}_V_{varCod}"; + var trayectoVuelta = ConstruirTextoRecorridoVariante(vuelta); + var textVisible = string.IsNullOrWhiteSpace(trayectoVuelta) + ? $"{linea}.{itin}-V — {vuelta.Linea.Nombre}" + : $"{linea}.{itin}-V — {trayectoVuelta}"; + var text = $"{linea}.{itin}-V — {vuelta.Linea.Nombre}"; + + text = textVisible; + + list.Add(new ElementoItinerario + { + Id = id, + Text = text, + SearchAliases = ObtenerAliasesBusquedaItinerario(vuelta, aliasesCache), + Variantes = new List { vuelta } + }); + } + + // 4) ✅ Si existen ambos: añadir item combinado IV + if (ida is not null && vuelta is not null) + { + var id = $"it_{linea}_{itin}_IV"; + var text = $"{linea}.{itin} — {nombreLinea} (I+V)"; + + list.Add(new ElementoItinerario + { + Id = id, + Text = text, + SearchAliases = new[] { ida, vuelta } + .SelectMany(v => ObtenerAliasesBusquedaItinerario(v, aliasesCache)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(), + Variantes = new List { ida, vuelta } + }); + } + } + + _itItems = list; + + _itSeleccionadoId ??= _itItems.FirstOrDefault()?.Id; + } + + + + private List _itItems = new(); + private string? _itSeleccionadoId; + + + private int _paradaPopupToken = 0; + + + /// + /// Gestiona lineas de alternativa. + /// + private string LineasDeAlternativa(AlternativaRuta alt) + { + if (alt is null || alt.EsSoloAPie || alt.AlternativaBus?.Tramos is null) + return ""; + + return TextoLineasDeTramos(alt.AlternativaBus.Tramos); + } + + private static string TextoLineasDeTramos(IReadOnlyList? tramos) + { + if (tramos is null || tramos.Count == 0) + return ""; + + var sb = new StringBuilder(); + string? ultimoCodigoMostrado = null; + TramoBus? tramoAnteriorEnSecuencia = null; + + foreach (var tramo in tramos) + { + var codigo = CodigoLineaItinerarioSentido(tramo); + if (string.IsNullOrWhiteSpace(codigo)) + continue; + + var repetidoConsecutivo = string.Equals(codigo, ultimoCodigoMostrado, StringComparison.OrdinalIgnoreCase); + if (!repetidoConsecutivo) + { + if (sb.Length > 0) + { + var separador = tramoAnteriorEnSecuencia is not null && + !UtilidadesTransitoDbus.EsTransbordoReal(tramoAnteriorEnSecuencia, tramo) + ? " + " + : ", "; + sb.Append(separador); + } + + sb.Append(codigo); + ultimoCodigoMostrado = codigo; + } + + tramoAnteriorEnSecuencia = tramo; + } + + return sb.ToString(); + } + + private static List CompactarCodigosLineaConsecutivos(IEnumerable codigos) + { + var resultado = new List(); + string? ultimoCodigoMostrado = null; + + foreach (var codigo in codigos) + { + if (string.IsNullOrWhiteSpace(codigo)) + continue; + + if (string.Equals(codigo, ultimoCodigoMostrado, StringComparison.OrdinalIgnoreCase)) + continue; + + resultado.Add(codigo); + ultimoCodigoMostrado = codigo; + } + + return resultado; + } + + /// + /// Gestiona itis de alternativa. + /// + private string ItisDeAlternativa(AlternativaRuta alt) + { + if (alt is null || alt.EsSoloAPie || alt.AlternativaBus?.Tramos is null) + return ""; + + return TextoLineasDeTramos(alt.AlternativaBus.Tramos); + } + + + + + + + private HashSet _previewLineasActivas = new(); + /// + /// Indica si esta vista previa activa. + /// + private bool EstaPreviewActiva(string id) + { + return _previewLineasActivas.Contains(id); + } + + // --- “doble click” por software --- + private CancellationTokenSource? _ctsDobleClick; + private (double lat, double lon)? _primerClickPendiente; + + /// + /// + /// + private bool Cerca(double lat1, double lon1, double lat2, double lon2, double maxMetros) + => CalcularDistanciaMetros(lat1, lon1, lat2, lon2) <= maxMetros; + + + + + + // ajusta a gusto + private const int DBLCLICK_MS = 320; // ventana de doble click + private const double DBLCLICK_MAX_METROS = 18; // tolerancia espacial (para no exigir pixel-perfect) + + + private bool _elevPopupMinimizado = false; + + + private const string _versionApp = "(v-20260626a)"; + + + private double? _paradaElevG; + + + + private DetalleTramoRuta? _tramoElevDetalle; + + + /// + /// Gestiona cerrar popups. + /// + private async Task CerrarPopupsAsync() + { + _itinerarioClicado = null; + + _paradaClicada = null; + _popupInspectorVisible = false; + + if (_tramoElevDetalle is not null) + { + _tramoElevDetalle = null; + try { await JS.InvokeVoidAsync("rt.clearElevPoints", "elev"); } catch { } + } + } + + + + /// + /// Pinta puntos elevacion. + /// + private async Task PintarPuntosElevacionAsync(DetalleTramoRuta tramo) + { + if (tramo?.MuestrasElevacion is null || tramo.MuestrasElevacion.Count == 0) + { + try { await JS.InvokeVoidAsync("rt.clearElevPoints", "elev"); } catch { } + return; + } + + var pts = tramo.MuestrasElevacion.Select((p, i) => new + { + lat = p.Latitud, + lon = p.Longitud, + // etiqueta: Ini/Fin o índice + label = (i == 0) ? "Ini" : (i == tramo.MuestrasElevacion.Count - 1) ? "Fin" : p.Indice.ToString() + }).ToList(); + + try + { + await JS.InvokeVoidAsync("rt.drawElevPoints", "elev", pts, new + { + radius = 3, + opacity = 0.9, + fillOpacity = 0.9, + showLabels = true, + labelEvery = 1 + }); + } + catch { } + } + + + + + // ========================================================= + // CACHES (walking + bus filtrado) + // ========================================================= + + private static readonly ConcurrentDictionary _walkCache = + new(StringComparer.OrdinalIgnoreCase); + + // Bus cache (filtrado por rango): key = o|d|min|max|maxAlts + private static readonly ConcurrentDictionary> _busFilteredCache = + new(StringComparer.OrdinalIgnoreCase); + + // ========================================================= + // INDICES “BUS GRAPH” (construidos desde Lineas/Trayectos) + // ========================================================= + + private static readonly object _busIndexLock = new(); + private static bool _busIndexBuilt = false; + + private static List _infoVariantes = new(); + private static Dictionary> _indiceParadas = new(StringComparer.OrdinalIgnoreCase); + private static Dictionary> _paradasCercanas = new(StringComparer.OrdinalIgnoreCase); + + private static Dictionary _paradaByCode = new(StringComparer.OrdinalIgnoreCase); + + // Grid de paradas (para “paradas cerca del punto”) + private static Dictionary<(int cx, int cy), List> _gridParadas = new(); + private static int _gridScale = 400; // ~ 250-300m en lat + + /// + /// + /// + private static (int cx, int cy) Cell(double lat, double lon, int scale) + => ((int)Math.Floor(lat * scale), (int)Math.Floor(lon * scale)); + + private static void InvalidarBusIndex() + { + lock (_busIndexLock) + { + _busIndexBuilt = false; + _infoVariantes = new List(); + _indiceParadas = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _paradasCercanas = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _paradaByCode = new Dictionary(StringComparer.OrdinalIgnoreCase); + _gridParadas = new Dictionary<(int cx, int cy), List>(); + } + } + + + + /// + /// Gestiona cerrar popup elev. + /// + private async Task CerrarPopupElevAsync() + { + _elevPopupMinimizado = false; + + _tramoElevDetalle = null; + try { await JS.InvokeVoidAsync("rt.clearElevPoints", "elev"); } catch { } + } + + /// + /// Obtiene texto itinerario clicado. + /// + private string GetTextoItinerarioClicado() + { + if (_itinerarioClicado is null) + return ""; + + var vReal = GetVarianteRealClicada(); + if (vReal is null) + return _itinerarioClicado.Text; + + var linea = vReal.Linea?.Codigo?.ToString() ?? "?"; + var itin = vReal.CodigoItinerario; + var sentido = NormalizarSentidoCodigo(vReal.Sentido?.Codigo); // I / V + var nombre = vReal.Linea?.Nombre ?? ""; + var trayecto = ConstruirTextoRecorridoVariante(vReal); + + if (!string.IsNullOrWhiteSpace(trayecto)) + return $"{linea}.{itin}-{sentido} — {trayecto}"; + + // ✅ Siempre devuelve el texto del sentido pinchado, no "I+V" + return $"{linea}.{itin}-{sentido} — {nombre}"; + } + + /// + /// Asegura que bus indice built este disponible. + /// + private void EnsureBusIndexBuilt() + { + if (_busIndexBuilt) return; + + lock (_busIndexLock) + { + if (_busIndexBuilt) return; + + // 1) map parada por código + _paradaByCode = RedBusServicio.Paradas + .GroupBy(p => p.Codigo, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + // 2) construir InfoVariante + indiceParadas + _infoVariantes = new List(4096); + _indiceParadas = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var linea in RedBusServicio.Lineas) + { + foreach (var itinerario in linea.Itinerarios) + { + foreach (var sentido in itinerario.Sentidos) + { + foreach (var variante in sentido.Variantes) + { + if (variante.IdsTrayectos.Count == 0) continue; + + if (!RedBusServicio.Trayectos.TryGetValue(variante.IdsTrayectos[0], out var t0)) + continue; + + var info = new InfoVariante + { + Linea = linea, + Sentido = sentido, + Variante = variante, + CodigoItinerario = itinerario.Codigo + }; + + info.CodigosParadas.Add(t0.CodigoParadaOrigen); + + foreach (var idTr in variante.IdsTrayectos) + { + if (RedBusServicio.Trayectos.TryGetValue(idTr, out var t)) + info.CodigosParadas.Add(t.CodigoParadaDestino); + } + + _infoVariantes.Add(info); + + for (int i = 0; i < info.CodigosParadas.Count; i++) + { + var cod = info.CodigosParadas[i]; + if (!_indiceParadas.TryGetValue(cod, out var lista)) + { + lista = new List(4); + _indiceParadas[cod] = lista; + } + + lista.Add(new AparicionParada { Variante = info, IndiceParada = i }); + } + } + } + } + } + + // 3) grid de paradas (para query en radio) + var dict = new Dictionary<(int, int), List>(); + foreach (var p in RedBusServicio.Paradas) + { + var c = Cell(p.Latitud, p.Longitud, _gridScale); + if (!dict.TryGetValue(c, out var list)) + { + list = new List(16); + dict[c] = list; + } + list.Add(p); + } + _gridParadas = dict; + + // 4) paradas cercanas para transbordo andando (sin O(n^2)) + _paradasCercanas = new Dictionary>(StringComparer.OrdinalIgnoreCase); + const double MAX_TRANSBORDO = 250; + double deg = MAX_TRANSBORDO / 111_000.0; + int step = (int)Math.Ceiling(deg * _gridScale); + if (step < 1) step = 1; + + foreach (var p in RedBusServicio.Paradas) + { + var c0 = Cell(p.Latitud, p.Longitud, _gridScale); + + for (int dx = -step; dx <= step; dx++) + { + for (int dy = -step; dy <= step; dy++) + { + var c = (c0.cx + dx, c0.cy + dy); + if (!_gridParadas.TryGetValue(c, out var list)) continue; + + foreach (var q in list) + { + if (ReferenceEquals(p, q)) continue; + + var d = CalcularDistanciaMetros(p.Latitud, p.Longitud, q.Latitud, q.Longitud); + if (d <= MAX_TRANSBORDO) + { + AddCercana(p.Codigo, q.Codigo); + } + } + } + } + } + + void AddCercana(string a, string b) + { + if (!_paradasCercanas.TryGetValue(a, out var l)) + { + l = new List(8); + _paradasCercanas[a] = l; + } + if (!l.Contains(b, StringComparer.OrdinalIgnoreCase)) + l.Add(b); + } + + _busIndexBuilt = true; + } + } + + /// + /// Consulta paradas en radio. + /// + private List QueryParadasEnRadio(double lat, double lon, int radioMetros) + { + double deg = Math.Max(0.0005, radioMetros / 111_000.0); + int step = (int)Math.Ceiling(deg * _gridScale); + if (step < 1) step = 1; + + var c0 = Cell(lat, lon, _gridScale); + + var res = new List(128); + for (int dx = -step; dx <= step; dx++) + { + for (int dy = -step; dy <= step; dy++) + { + var c = (c0.cx + dx, c0.cy + dy); + if (_gridParadas.TryGetValue(c, out var list)) + res.AddRange(list); + } + } + return res; + } +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.ModelosHorarios.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.ModelosHorarios.cs new file mode 100644 index 0000000..837bdb6 --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.ModelosHorarios.cs @@ -0,0 +1,245 @@ +using System.Globalization; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.JSInterop; +using RutasDBUS.Modelos.Importacion; + +namespace RutasDBUS.Components.Pages; + +public partial class PlanificadorRutas +{ + private const long MaxModeloHorarioUploadBytes = 250L * 1024 * 1024; + + private IReadOnlyList _modelosHorarios = Array.Empty(); + private string _modeloHorarioSeleccionadoId = ""; + private string _mensajeModelosHorarios = ""; + private bool _modelosHorariosCargando = false; + private bool _mostrarModelosHorariosPanel = false; + + private ModeloHorarioDbusInfo? ModeloHorarioActivo => + _modelosHorarios.FirstOrDefault(modelo => modelo.Activo); + + private void AbrirModelosHorariosPanel() + { + _mostrarModelosHorariosPanel = true; + } + + private void CerrarModelosHorariosPanel() + { + _mostrarModelosHorariosPanel = false; + } + + private async Task CargarModelosHorariosAsync() + { + _modelosHorariosCargando = true; + + try + { + _modelosHorarios = await ArchivoImportacionDbus.ListarModelosAsync(); + var activo = ModeloHorarioActivo ?? _modelosHorarios.FirstOrDefault(); + _modeloHorarioSeleccionadoId = activo?.Id ?? ""; + } + catch (Exception ex) + { + _modelosHorarios = Array.Empty(); + _modeloHorarioSeleccionadoId = ""; + _mensajeModelosHorarios = "No se han podido leer los modelos: " + ex.Message; + } + finally + { + _modelosHorariosCargando = false; + } + } + + private async Task ActualizarModelosHorariosAsync() + { + _mensajeModelosHorarios = ""; + await CargarModelosHorariosAsync(); + if (string.IsNullOrWhiteSpace(_mensajeModelosHorarios)) + _mensajeModelosHorarios = _modelosHorarios.Count == 0 + ? "No hay ZIPs disponibles." + : "Lista actualizada."; + } + + private async Task SeleccionarModeloHorarioAsync() + { + if (string.IsNullOrWhiteSpace(_modeloHorarioSeleccionadoId)) + { + _mensajeModelosHorarios = "Selecciona un ZIP."; + return; + } + + var seleccionado = _modelosHorarios + .FirstOrDefault(modelo => string.Equals(modelo.Id, _modeloHorarioSeleccionadoId, StringComparison.OrdinalIgnoreCase)); + + if (seleccionado?.Activo == true) + { + _mensajeModelosHorarios = "Ese modelo ya esta cargado."; + return; + } + + _modelosHorariosCargando = true; + _mensajeModelosHorarios = "Cargando modelo..."; + + try + { + await ArchivoImportacionDbus.SeleccionarModeloAsync(_modeloHorarioSeleccionadoId); + await RecargarModeloHorarioActivoAsync(); + await CargarModelosHorariosAsync(); + + var activo = ModeloHorarioActivo; + _mensajeModelosHorarios = activo is null + ? "Modelo cargado." + : "Cargado: " + activo.NombreArchivo; + } + catch (Exception ex) + { + _mensajeModelosHorarios = "No se ha podido cargar el modelo: " + ex.Message; + } + finally + { + _modelosHorariosCargando = false; + } + } + + private async Task SubirModeloHorarioAsync(InputFileChangeEventArgs e) + { + var archivo = e.File; + if (archivo is null) + return; + + if (!archivo.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + _mensajeModelosHorarios = "Solo se pueden subir archivos .zip."; + return; + } + + _modelosHorariosCargando = true; + _mensajeModelosHorarios = "Subiendo ZIP..."; + + try + { + await using var stream = archivo.OpenReadStream(MaxModeloHorarioUploadBytes); + var modelo = await ArchivoImportacionDbus.GuardarModeloSubidoAsync(archivo.Name, stream); + + _modeloHorarioSeleccionadoId = modelo.Id; + await RecargarModeloHorarioActivoAsync(); + await CargarModelosHorariosAsync(); + _mensajeModelosHorarios = "Subido y cargado: " + modelo.NombreArchivo; + } + catch (Exception ex) + { + _mensajeModelosHorarios = "No se ha podido subir el ZIP: " + ex.Message; + } + finally + { + _modelosHorariosCargando = false; + } + } + + private async Task LimpiarDatosLocalesAsync() + { + bool confirmado; + try + { + confirmado = await JS.InvokeAsync( + "confirm", + "¿Seguro que quieres limpiar los datos locales de este navegador? Se borrarán historial, configuración guardada, cachés y datos locales de este sitio."); + } + catch (JSException) + { + return; + } + + if (!confirmado) + return; + + _modelosHorariosCargando = true; + _mensajeModelosHorarios = "Limpiando datos locales..."; + + try + { + await JS.InvokeVoidAsync("rt.clearLocalBrowserData"); + _mensajeModelosHorarios = "Datos locales borrados. Recargando..."; + await InvokeAsync(StateHasChanged); + await Task.Delay(250); + await JS.InvokeVoidAsync("rt.reloadPage"); + } + catch (Exception ex) + { + _mensajeModelosHorarios = "No se han podido limpiar los datos locales: " + ex.Message; + } + finally + { + _modelosHorariosCargando = false; + } + } + + private async Task RecargarModeloHorarioActivoAsync() + { + _ctsCalculo?.Cancel(); + IniciarNuevaVersionPintadoRuta(); + + await CerrarTodasLasPreviewsAsync(); + await CerrarPopupsAsync(); + + if (_mapa is not null) + { + await ClearRoutesJsAsync(); + try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } + } + + ResetEstadoRuta(limpiarOpciones: true); + PlanificadorRutasServicio.InvalidarCachesModelo(); + _busFilteredCache.Clear(); + InvalidarBusIndex(); + + _itItems.Clear(); + _itSeleccionadoId = null; + _itFiltro = ""; + _selectItKey++; + _nextDeps.Clear(); + _horariosOk = false; + _horariosError = null; + + _tareaCargaRed = RedBusServicio.CargarRedAsync(); + await _tareaCargaRed; + + if (_usarHorariosTeoricos) + await EnsureHorariosLoadedAsync(); + + if (RedBusServicio.Paradas.Count > 0) + { + EnsureBusIndexBuilt(); + BuildItinerariosDropdown(); + SincronizarSeleccionItinerarioConFiltro(); + } + + _paradasPintadas = false; + if (_mapa is not null) + { + if (_mostrarParadas) + await DibujarParadasAsync(); + else + await OcultarParadasAsync(); + + await SincronizarMarcadoresOrigenDestinoJsAsync(); + _paradasPintadas = _mostrarParadas; + } + + StateHasChanged(); + } + + private static string FormatearTamanoModelo(long bytes) + { + if (bytes >= 1024L * 1024L) + return (bytes / 1024d / 1024d).ToString("0.0", CultureInfo.InvariantCulture) + " MB"; + + if (bytes >= 1024L) + return (bytes / 1024d).ToString("0", CultureInfo.InvariantCulture) + " KB"; + + return bytes.ToString(CultureInfo.InvariantCulture) + " B"; + } + + private static string FormatearFechaModelo(DateTime fechaUtc) + => fechaUtc.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture); +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs new file mode 100644 index 0000000..707c81b --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs @@ -0,0 +1,360 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.JSInterop; + +namespace RutasDBUS.Components.Pages; + +public partial class PlanificadorRutas +{ + private const string ClaveConfiguracionLocal = "rutasdbus.planificador.config.v1"; + private bool _configuracionLocalCargada = false; + private bool _mostrarConfirmacionRestablecer = false; + + /// + /// Instantánea persistida de la configuración del planificador. + /// + private sealed class ConfiguracionLocalPlanificador + { + public bool? MostrarParadas { get; set; } + public bool UsarHorariosTeoricos { get; set; } + public int SegundosPorParadaBusManual { get; set; } + public bool ActivarRangoLineas { get; set; } + public int LineaMin { get; set; } + public int LineaMax { get; set; } + public List? LineasPermitidas { get; set; } + public bool ActivarRangoItinerarios { get; set; } + public int ItinerarioMin { get; set; } + public int ItinerarioMax { get; set; } + public bool ActivarRangoTransbordos { get; set; } + public int MinTransbordosPermitidos { get; set; } + public int MaxTransbordosPermitidos { get; set; } + public bool UsarHoraSimulada { get; set; } + public string? FechaSimulada { get; set; } + public string? HoraSimulada { get; set; } + public double? FactorPenalizacionDistanciaPie { get; set; } + public double? ParametroPenalizacionDistanciaPieMetros { get; set; } + public double? CoeficienteEsfuerzoAltura { get; set; } + public double? PesoTransbordoRankingMinutos { get; set; } + public double VelocidadPieKmH { get; set; } + public double VelocidadBusKmH { get; set; } + public int ElevMaxPuntosPorTramo { get; set; } + public int RadioCandidatasMetros { get; set; } + public int MargenTransbordoSegundos { get; set; } + public string? WalkProvider { get; set; } + public int NivelBrilloMapa { get; set; } + public bool UsarColorRealLineaEnItinerarios { get; set; } + public bool UsarOsrmParaRanking { get; set; } + public string? CriterioPrioritarioRuta { get; set; } + public string? PreferenciaEtiquetaRuta { get; set; } + } + + /// + /// Carga la configuración persistida del navegador y la aplica al estado actual. + /// + private async Task CargarConfiguracionLocalAsync() + { + try + { + var json = await JS.InvokeAsync("rt.loadLocalValue", ClaveConfiguracionLocal); + if (string.IsNullOrWhiteSpace(json)) + return; + + var config = JsonSerializer.Deserialize(json); + if (config is null) + return; + + AplicarConfiguracionLocal(config); + } + catch (JSException) + { + } + catch (JsonException) + { + } + finally + { + _configuracionLocalCargada = true; + } + } + + /// + /// Guarda la configuración actual del planificador en el navegador. + /// + private async Task PersistirConfiguracionAsync() + { + if (!_configuracionLocalCargada) + return; + + try + { + if (!_usarHorariosTeoricos) + _segundosPorParadaBusManual = Math.Max(0, _segundosPorParadaBus); + + var json = JsonSerializer.Serialize(CrearConfiguracionLocal()); + await JS.InvokeVoidAsync("rt.saveLocalValue", ClaveConfiguracionLocal, json); + } + catch (JSException) + { + } + } + + /// + /// Sincroniza el valor manual de segundos por parada y persiste la configuración. + /// + private async Task OnSegundosPorParadaBusChanged() + { + _segundosPorParadaBus = Math.Max(0, _segundosPorParadaBus); + _segundosPorParadaBusManual = _segundosPorParadaBus; + await PersistirConfiguracionAsync(); + } + + /// + /// Construye la instantánea persistible a partir del estado actual. + /// + private ConfiguracionLocalPlanificador CrearConfiguracionLocal() + => new() + { + MostrarParadas = _mostrarParadas, + UsarHorariosTeoricos = _usarHorariosTeoricos, + SegundosPorParadaBusManual = Math.Max(0, _segundosPorParadaBusManual), + ActivarRangoLineas = _activarRangoLineas, + LineaMin = _lineaMin, + LineaMax = _lineaMax, + LineasPermitidas = ObtenerLineasPermitidasSeleccionadas().ToList(), + ActivarRangoItinerarios = _activarRangoItinerarios, + ItinerarioMin = _itinerarioMin, + ItinerarioMax = _itinerarioMax, + ActivarRangoTransbordos = _activarRangoTransbordos, + MinTransbordosPermitidos = _minTransbordosPermitidos, + MaxTransbordosPermitidos = _maxTransbordosPermitidos, + UsarHoraSimulada = _usarHoraSimulada, + FechaSimulada = _fechaSimulada.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + HoraSimulada = _horaSimulada.ToString("HH:mm:ss", CultureInfo.InvariantCulture), + FactorPenalizacionDistanciaPie = _factorPenalizacionDistanciaPie, + ParametroPenalizacionDistanciaPieMetros = _parametroPenalizacionDistanciaPieMetros, + CoeficienteEsfuerzoAltura = _coeficienteEsfuerzoAltura, + PesoTransbordoRankingMinutos = _pesoTransbordoRankingMinutos, + VelocidadPieKmH = _velocidadPieKmH, + VelocidadBusKmH = _velocidadBusKmH, + ElevMaxPuntosPorTramo = _elevMaxPuntosPorTramo, + RadioCandidatasMetros = _radioCandidatasMetros, + MargenTransbordoSegundos = _margenTransbordoSegundos, + WalkProvider = _walkProvider.ToString(), + NivelBrilloMapa = _nivelBrilloMapa, + UsarColorRealLineaEnItinerarios = _usarColorRealLineaEnItinerarios, + UsarOsrmParaRanking = _usarOsrmParaRanking, + CriterioPrioritarioRuta = _criterioPrioritarioRuta, + PreferenciaEtiquetaRuta = _preferenciaEtiquetaRuta + }; + + /// + /// Aplica al componente una configuración previamente persistida. + /// + private void AplicarConfiguracionLocal(ConfiguracionLocalPlanificador config) + { + _mostrarParadas = config.MostrarParadas ?? true; + _usarHorariosTeoricos = config.UsarHorariosTeoricos; + _segundosPorParadaBusManual = Math.Clamp(config.SegundosPorParadaBusManual, 0, 3600); + _segundosPorParadaBus = _segundosPorParadaBusManual; + + _lineaMin = Math.Clamp(config.LineaMin, 1, 200); + _lineaMax = Math.Clamp(config.LineaMax, 1, 200); + _lineasPermitidas.Clear(); + foreach (var linea in NormalizarLineasPermitidas(config.LineasPermitidas, config.LineaMin, config.LineaMax)) + _lineasPermitidas.Add(linea); + _activarRangoLineas = config.ActivarRangoLineas; + + _activarRangoItinerarios = config.ActivarRangoItinerarios; + _itinerarioMin = Math.Clamp(config.ItinerarioMin, 1, 99); + _itinerarioMax = Math.Clamp(config.ItinerarioMax, 1, 99); + + _activarRangoTransbordos = config.ActivarRangoTransbordos; + _minTransbordosPermitidos = Math.Clamp(config.MinTransbordosPermitidos, 0, 10); + _maxTransbordosPermitidos = Math.Clamp(config.MaxTransbordosPermitidos, 0, 10); + + _usarHoraSimulada = config.UsarHoraSimulada; + if (DateOnly.TryParse(config.FechaSimulada, out var fechaSimulada)) + _fechaSimulada = fechaSimulada; + if (TimeOnly.TryParse(config.HoraSimulada, out var horaSimulada)) + _horaSimulada = horaSimulada; + + if (config.FactorPenalizacionDistanciaPie is { } factor && double.IsFinite(factor)) + _factorPenalizacionDistanciaPie = Math.Abs(factor - 1.3) < 0.0001 ? 1.6 : factor; + if (config.ParametroPenalizacionDistanciaPieMetros is { } parametro && double.IsFinite(parametro)) + _parametroPenalizacionDistanciaPieMetros = parametro; + if (config.CoeficienteEsfuerzoAltura is { } coeficienteAltura && double.IsFinite(coeficienteAltura)) + _coeficienteEsfuerzoAltura = Math.Abs(coeficienteAltura - 1.6) < 0.0001 + ? CoeficienteEsfuerzoAlturaPorDefecto + : coeficienteAltura; + if (config.PesoTransbordoRankingMinutos is { } pesoTransbordo && double.IsFinite(pesoTransbordo)) + _pesoTransbordoRankingMinutos = Math.Abs(pesoTransbordo - 8.0) < 0.0001 ? 4.0 : Math.Max(0, pesoTransbordo); + _velocidadPieKmH = Math.Clamp(config.VelocidadPieKmH, 0.5, 25.0); + _velocidadBusKmH = Math.Clamp( + Math.Abs(config.VelocidadBusKmH - 15.0) < 0.0001 || + Math.Abs(config.VelocidadBusKmH - 16.6) < 0.0001 + ? 16.0 + : config.VelocidadBusKmH, + 1.0, + 120.0); + _elevMaxPuntosPorTramo = Math.Clamp(config.ElevMaxPuntosPorTramo, 2, 999); + _radioCandidatasMetros = Math.Clamp(config.RadioCandidatasMetros, 200, 3500); + _margenTransbordoSegundos = Math.Clamp(config.MargenTransbordoSegundos, 0, 1800); + _nivelBrilloMapa = Math.Clamp(config.NivelBrilloMapa, 0, 4); + _usarColorRealLineaEnItinerarios = config.UsarColorRealLineaEnItinerarios; + _usarOsrmParaRanking = config.UsarOsrmParaRanking; + _criterioPrioritarioRuta = NormalizarCriterioRuta(config.CriterioPrioritarioRuta, config.PreferenciaEtiquetaRuta); + SincronizarPreferenciaDesdeCriterioRuta(); + + if (Enum.TryParse(config.WalkProvider, ignoreCase: true, out var walkProvider)) + _walkProvider = walkProvider; + + SincronizarTextosPenalizacionPie(); + SincronizarSegundosPorParadaConHorarios(); + } + + private static IReadOnlyList NormalizarLineasPermitidas( + IReadOnlyList? lineas, + int lineaMinLegacy, + int lineaMaxLegacy) + { + if (lineas is { Count: > 0 }) + { + return lineas + .Where(linea => linea > 0 && linea <= 999) + .Distinct() + .OrderBy(linea => linea) + .ToList(); + } + + if (lineaMinLegacy <= 0 && lineaMaxLegacy <= 0) + return Array.Empty(); + + var min = Math.Clamp(Math.Min(lineaMinLegacy, lineaMaxLegacy), 1, 999); + var max = Math.Clamp(Math.Max(lineaMinLegacy, lineaMaxLegacy), 1, 999); + var esRangoPorDefecto = min <= 1 && max >= 200; + if (esRangoPorDefecto) + return Array.Empty(); + + return Enumerable.Range(min, max - min + 1).ToList(); + } + + /// + /// Abre el cuadro de confirmación para restablecer valores. + /// + private void PedirConfirmacionRestablecer() + { + _mostrarConfirmacionRestablecer = true; + } + + /// + /// Cierra el cuadro de confirmación de restablecer. + /// + private void CancelarConfirmacionRestablecer() + { + _mostrarConfirmacionRestablecer = false; + } + + /// + /// Restablece la configuracion avanzada a los valores por defecto de la aplicacion. + /// + private async Task RestablecerConfiguracionPorDefectoAsync() + { + _mostrarConfirmacionRestablecer = false; + bool confirmado = true; + try + { + confirmado = await JS.InvokeAsync("confirm", "¿Seguro que quieres restablecer la configuración avanzada a sus valores por defecto?"); + } + catch (JSException) + { + confirmado = false; + } + + if (!confirmado) + return; + + _mostrarParadas = true; + _usarHorariosTeoricos = true; + _segundosPorParadaBusManual = 30; + _segundosPorParadaBus = 30; + + _activarRangoItinerarios = true; + _itinerarioMin = 1; + _itinerarioMax = 99; + + _activarRangoTransbordos = true; + _minTransbordosPermitidos = 0; + _maxTransbordosPermitidos = 3; + + _factorPenalizacionDistanciaPie = 1.6; + _parametroPenalizacionDistanciaPieMetros = 100.0; + _coeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto; + _pesoTransbordoRankingMinutos = 4.0; + _velocidadPieKmH = 3.6; + _velocidadBusKmH = 16.0; + _minutosTransbordo = 5; + _margenTransbordoSegundos = 120; + _elevMaxPuntosPorTramo = 999; + _radioCandidatasMetros = 1200; + _walkProvider = ProveedorCaminata.Valhalla; + _nivelBrilloMapa = 0; + _usarColorRealLineaEnItinerarios = false; + _usarOsrmParaRanking = false; + + if (_usarHoraSimulada) + StopClock(); + else + StartClock(); + SincronizarTextosPenalizacionPie(); + SincronizarSegundosPorParadaConHorarios(); + await RefrescarHoraUIAsync(); + await PersistirConfiguracionAsync(); + StateHasChanged(); + } + + /// + /// Restablece la configuración desde el modal personalizado centrado. + /// + private async Task ConfirmarRestablecerConfiguracionAsync() + { + _mostrarConfirmacionRestablecer = false; + + _mostrarParadas = true; + _usarHorariosTeoricos = true; + _segundosPorParadaBusManual = 30; + _segundosPorParadaBus = 30; + + _activarRangoItinerarios = true; + _itinerarioMin = 1; + _itinerarioMax = 99; + + _activarRangoTransbordos = true; + _minTransbordosPermitidos = 0; + _maxTransbordosPermitidos = 3; + + _factorPenalizacionDistanciaPie = 1.6; + _parametroPenalizacionDistanciaPieMetros = 100.0; + _coeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto; + _pesoTransbordoRankingMinutos = 4.0; + _velocidadPieKmH = 3.6; + _velocidadBusKmH = 16.0; + _minutosTransbordo = 5; + _margenTransbordoSegundos = 120; + _elevMaxPuntosPorTramo = 999; + _radioCandidatasMetros = 1200; + _walkProvider = ProveedorCaminata.Valhalla; + _nivelBrilloMapa = 0; + _usarColorRealLineaEnItinerarios = false; + _usarOsrmParaRanking = false; + + if (_usarHoraSimulada) + StopClock(); + else + StartClock(); + SincronizarTextosPenalizacionPie(); + SincronizarSegundosPorParadaConHorarios(); + await RefrescarHoraUIAsync(); + await PersistirConfiguracionAsync(); + StateHasChanged(); + } +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs new file mode 100644 index 0000000..221b52b --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs @@ -0,0 +1,2285 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using LeafletForBlazor; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using RutasDBUS.Modelos.Planificacion; +using RutasDBUS.Modelos.RedBus; +using RutasDBUS.Servicios; +using RutasDBUS.Servicios.Caminata; +using RutasDBUS.Servicios.Horarios; +using RutasDBUS.Servicios.Planificacion; +using RutasDBUS.Servicios.RedBus; + +namespace RutasDBUS.Components.Pages; + +public partial class PlanificadorRutas : ComponentBase +{ + // ========================================================= + // Itinerarios: rango (estricto durante BFS) + // ========================================================= + + /// + /// Gestiona dentro rango. + /// + private bool ItinerarioDentroRango(int codigoItinerario) + { + if (!_activarRangoItinerarios) return true; + int min = Math.Min(_itinerarioMin, _itinerarioMax); + int max = Math.Max(_itinerarioMin, _itinerarioMax); + return codigoItinerario >= min && codigoItinerario <= max; + } + + /// + /// Gestiona permitida. + /// + private bool VariantePermitida(InfoVariante v) + => ItinerarioDentroRango(v.CodigoItinerario) && LineaDentroRango(v); + + private IReadOnlyList ObtenerLineasPermitidasSeleccionadas() + => _lineasPermitidas + .Where(linea => linea > 0) + .Distinct() + .OrderBy(linea => linea) + .ToList(); + + private IReadOnlyList LineasFiltroDisponibles() + { + var lineas = _infoVariantes + .Select(CodigoLineaAsInt) + .Concat(RedBusServicio.Lineas.Select(CodigoLineaAsInt)) + .Where(linea => linea != int.MaxValue && linea > 0) + .Distinct() + .OrderBy(linea => linea) + .ToList(); + + if (lineas.Count > 0) + return lineas; + + return _lineasPermitidas + .Where(linea => linea > 0) + .Distinct() + .OrderBy(linea => linea) + .ToList(); + } + + private bool LineaFiltroSeleccionada(int linea) + => _lineasPermitidas.Contains(linea); + + private bool TodasLineasFiltroSeleccionadas() + { + var disponibles = LineasFiltroDisponibles(); + return disponibles.Count > 0 && disponibles.All(_lineasPermitidas.Contains); + } + + private string TextoBotonTodasLineasPermitidas() + => TodasLineasFiltroSeleccionadas() ? "Ninguna" : "Todas"; + + private string TextoResumenLineasPermitidas() + { + var seleccionadas = ObtenerLineasPermitidasSeleccionadas(); + var disponibles = LineasFiltroDisponibles(); + if (seleccionadas.Count == 0) + return "Ninguna linea seleccionada"; + + if (disponibles.Count > 0 && seleccionadas.Count >= disponibles.Count) + return $"Todas las lineas ({disponibles.Count})"; + + return seleccionadas.Count <= 12 + ? string.Join(", ", seleccionadas) + : $"{seleccionadas.Count} lineas seleccionadas"; + } + + private async Task ToggleLineaFiltroAsync(int linea, ChangeEventArgs args) + { + var seleccionada = args.Value is bool valor && valor; + if (seleccionada) + _lineasPermitidas.Add(linea); + else + _lineasPermitidas.Remove(linea); + + _busFilteredCache.Clear(); + await PersistirConfiguracionAsync(); + } + + private async Task ToggleTodasLineasPermitidasAsync() + { + var disponibles = LineasFiltroDisponibles(); + if (disponibles.Count > 0 && disponibles.All(_lineasPermitidas.Contains)) + { + _lineasPermitidas.Clear(); + } + else + { + foreach (var linea in disponibles) + _lineasPermitidas.Add(linea); + } + + _busFilteredCache.Clear(); + await PersistirConfiguracionAsync(); + } + + + // ========================================================= + // Selección de candidatas: por ubicación, pero SOLO si están + // en itinerarios permitidos + // ========================================================= + + /// + /// Gestiona tiene aparicion permitida. + /// + private bool ParadaTieneAparicionPermitida(string codigoParada) + { + if (!_indiceParadas.TryGetValue(codigoParada, out var apps)) return false; + foreach (var a in apps) + if (VariantePermitida(a.Variante)) + return true; + return false; + } + + /// + /// Selecciona candidatas solo itinerarios. + /// + private async Task> SeleccionarCandidatasSoloItinerariosAsync(double lat, double lon, CancellationToken ct) + { + EnsureBusIndexBuilt(); + + int radio = Math.Clamp(_radioCandidatasMetros, 200, 3500); + int k = Math.Clamp(_maxCandidatasPorLado, 3, 20); + + // 1) pool por grid + var pool = QueryParadasEnRadio(lat, lon, radio); + + // 2) filtrar: solo paradas que estén en itinerarios permitidos + var tmp = new List(pool.Count); + + foreach (var p in pool) + { + ct.ThrowIfCancellationRequested(); + if ((_activarRangoItinerarios || _activarRangoLineas) && !ParadaTieneAparicionPermitida(p.Codigo)) + continue; + + + var d = CalcularDistanciaMetros(lat, lon, p.Latitud, p.Longitud); + if (d <= radio * 1.9) + { + tmp.Add(new CandidataParada + { + Parada = p, + DistEucl = d, + DistWalk = d, + TiempoWalk = EstimarDuracionPieSegundos(d) // ✅ SIEMPRE con velocidad configurada + }); + } + } + + tmp.Sort((a, b) => a.DistEucl.CompareTo(b.DistEucl)); + if (tmp.Count == 0) return tmp; + + // Pre-take (barato) + int preTake = Math.Min(32, Math.Max(k + 10, k * 3)); + if (tmp.Count > preTake) tmp = tmp.Take(preTake).ToList(); + + // En estricto: NO usamos OSRM para ranking (por rendimiento) + if (!_usarOsrmParaRanking || _activarRangoItinerarios) + return tmp.Take(k).ToList(); + + // Refinar con OSRM con concurrencia limitada (pero tiempo a pie = distancia / velocidad) + var sem = new SemaphoreSlim(4, 4); + var tasks = new List(tmp.Count); + + foreach (var c in tmp) + { + tasks.Add(Task.Run(async () => + { + await sem.WaitAsync(ct); + try + { + ct.ThrowIfCancellationRequested(); + var (_, dist, _) = await WalkCached(lat, lon, c.Parada.Latitud, c.Parada.Longitud); + if (dist > 0) + { + c.DistWalk = dist; + c.TiempoWalk = EstimarDuracionPieSegundos(dist); // ✅ velocidad configurada + } + } + catch { } + finally { sem.Release(); } + }, ct)); + } + + try { await Task.WhenAll(tasks); } catch { } + + return tmp + .OrderBy(x => x.TiempoWalk) + .ThenBy(x => x.DistEucl) + .Take(k) + .ToList(); + } + + + // ========================================================= + // ELEVACIÓN (Open-Elevation) — solo tramos andando + // ========================================================= + + /// + /// Gestiona alt. + /// + private static string FmtAlt(double? v) => v is null ? "-" : $"{Math.Round(v.Value):0} m"; + + /// + /// Formatea altitudes tramo. + /// + private static string FormatearAltitudesTramo(double? ini, double? fin, double? min, double? max) + { + var sb = new System.Text.StringBuilder(); + + sb.Append("
"); + sb.Append($"
Altitud:
"); + sb.Append($"
• (ini→fin) = {FmtAlt(ini)} → {FmtAlt(fin)}
"); + + if (min is not null && max is not null) + sb.Append($"
• (min/max) = {Math.Round(min.Value):0} m – {Math.Round(max.Value):0} m
"); + + sb.Append("
"); + + return sb.ToString(); + } + + + + + + /// + /// Obtiene elevacion punto. + /// + private async Task GetElevationPointAsync(double lat, double lon, CancellationToken ct) + { + var k = ElevKey(lat, lon); + + if (_elevCache.TryGetValue(k, out var cached)) + return cached; + + try + { + await ObtenerElevacionesPorLoteAsync(new List<(double lat, double lon)> { (lat, lon) }, ct); + return _elevCache.TryGetValue(ElevKey(lat, lon), out var elev) ? elev : (double?)null; + } + catch + { + return null; + } + } + + /// + /// Actualiza las alturas (Z) de origen y destino para el resumen del detalle de ruta. + /// Prioriza IDEE y usa OpenElevation como respaldo. + /// + private async Task ActualizarAlturasResumenRutaAsync(CancellationToken ct) + { + if (_coordenadasOrigen is null || _coordenadasDestino is null) + { + _altitudOrigenResumenRuta = null; + _altitudDestinoResumenRuta = null; + return; + } + + var origen = (_coordenadasOrigen[0], _coordenadasOrigen[1]); + var destino = (_coordenadasDestino[0], _coordenadasDestino[1]); + + try + { + var faltanIdee = new List<(double lat, double lon)>(2); + if (!_elevIdeeCache.ContainsKey(ClaveElevacionIdee(origen.Item1, origen.Item2))) + faltanIdee.Add(origen); + if (!_elevIdeeCache.ContainsKey(ClaveElevacionIdee(destino.Item1, destino.Item2))) + faltanIdee.Add(destino); + + if (faltanIdee.Count > 0) + await ObtenerElevacionesIdeePorLoteAsync(faltanIdee, CancellationToken.None); + + double? zOrigen = _elevIdeeCache.TryGetValue(ClaveElevacionIdee(origen.Item1, origen.Item2), out var gOri) + ? gOri + : (double?)null; + double? zDestino = _elevIdeeCache.TryGetValue(ClaveElevacionIdee(destino.Item1, destino.Item2), out var gDst) + ? gDst + : (double?)null; + + var faltanOpen = new List<(double lat, double lon)>(2); + if (zOrigen is null && !_elevCache.ContainsKey(ElevKey(origen.Item1, origen.Item2))) + faltanOpen.Add(origen); + if (zDestino is null && !_elevCache.ContainsKey(ElevKey(destino.Item1, destino.Item2))) + faltanOpen.Add(destino); + + if (faltanOpen.Count > 0) + await ObtenerElevacionesPorLoteAsync(faltanOpen, CancellationToken.None); + + if (zOrigen is null && _elevCache.TryGetValue(ElevKey(origen.Item1, origen.Item2), out var oOri)) + zOrigen = oOri; + if (zDestino is null && _elevCache.TryGetValue(ElevKey(destino.Item1, destino.Item2), out var oDst)) + zDestino = oDst; + + _altitudOrigenResumenRuta = zOrigen; + _altitudDestinoResumenRuta = zDestino; + } + catch + { + _altitudOrigenResumenRuta = null; + _altitudDestinoResumenRuta = null; + } + } + + + private static readonly ConcurrentDictionary _elevIdeeCache = + new(StringComparer.OrdinalIgnoreCase); + + // IDEE: en el correo indican que 6 decimales es suficiente + private const int ELEV_IDEE_DECIMALES_CACHE = 6; + + /// + /// Gestiona idee clave. + /// + private static string ClaveElevacionIdee(double lat, double lon) + { + double rLat = Math.Round(lat, ELEV_IDEE_DECIMALES_CACHE); + double rLon = Math.Round(lon, ELEV_IDEE_DECIMALES_CACHE); + return $"{rLat.ToString("0.000000", CultureInfo.InvariantCulture)},{rLon.ToString("0.000000", CultureInfo.InvariantCulture)}"; + } + + private sealed class RespuestaElevacionIdee + { + public string? id { get; set; } + public List? values { get; set; } + } + + /// + /// Recupera elevations idee el lote. + /// + private async Task ObtenerElevacionesIdeePorLoteAsync(List<(double lat, double lon)> points, CancellationToken ct) + { + if (points.Count == 0) return; + + // Construir FeatureCollection GeoJSON, OJO: coordinates = [lon, lat] + var features = new List(points.Count); + foreach (var (lat, lon) in points) + { + ct.ThrowIfCancellationRequested(); + + double rLat = Math.Round(lat, ELEV_IDEE_DECIMALES_CACHE); + double rLon = Math.Round(lon, ELEV_IDEE_DECIMALES_CACHE); + + features.Add(new + { + type = "Feature", + geometry = new + { + type = "Point", + coordinates = new[] { rLon, rLat } + } + }); + } + + var geomObj = new + { + type = "FeatureCollection", + features + }; + + // En la API, "geom" va como STRING con el JSON dentro + string geomJson = JsonSerializer.Serialize(geomObj); + + var body = new + { + inputs = new + { + crs = 4326, + formato = "geojson", + geom = geomJson, + outputFormat = "array", + withCoord = false + } + }; + + const string url = "https://api-processes.idee.es/processes/getElevation/execution"; + + try + { + using var req = new HttpRequestMessage(HttpMethod.Post, url); + req.Headers.Accept.ParseAdd("application/json"); + req.Content = new StringContent(JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json"); + + using var resp = await Http.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) return; + + var json = await resp.Content.ReadAsStringAsync(ct); + + var data = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (data?.values is null || data.values.Count == 0) return; + + // values viene en el mismo orden que mandas los puntos + int n = Math.Min(points.Count, data.values.Count); + for (int i = 0; i < n; i++) + { + var (lat, lon) = points[i]; + var k = ClaveElevacionIdee(lat, lon); + _elevIdeeCache[k] = data.values[i]; + } + } + catch + { + // silencioso: elevación es informativa + } + } + + + + private static readonly ConcurrentDictionary _elevCache = + new(StringComparer.OrdinalIgnoreCase); + + // Ajustes (rendimiento) + /* private const int ELEV_MAX_PUNTOS_POR_TRAMO = 24; */ + private int _elevMaxPuntosPorTramo = 999; // configurable + private const int ELEV_OPEN_TIMEOUT_MS = 1500; + // muestreo por tramo + private const int ELEV_BATCH_MAX_PUNTOS = 90; // por request (prudente) + private const int ELEV_DECIMALES_CACHE = 6; // redondeo coord para cache + + /// + /// Gestiona clave. + /// + private static string ElevKey(double lat, double lon) + { + double rLat = Math.Round(lat, ELEV_DECIMALES_CACHE); + double rLon = Math.Round(lon, ELEV_DECIMALES_CACHE); + return $"{rLat.ToString("0.000000", CultureInfo.InvariantCulture)},{rLon.ToString("0.000000", CultureInfo.InvariantCulture)}"; + } + + private static List<(double lat, double lon)> SampleLine(double[,] line, int maxPoints) + { + int n = line.GetLength(0); + if (n <= 0) return new(); + + if (maxPoints < 2) maxPoints = 2; + + if (n <= maxPoints) + { + var all = new List<(double, double)>(n); + for (int i = 0; i < n; i++) + all.Add((line[i, 0], line[i, 1])); + return all; + } + + // muestreo equiespaciado incluyendo extremos + var res = new List<(double, double)>(maxPoints); + for (int i = 0; i < maxPoints; i++) + { + double t = (maxPoints == 1) ? 0 : (double)i / (maxPoints - 1); + int idx = (int)Math.Round(t * (n - 1)); + if (idx < 0) idx = 0; + if (idx >= n) idx = n - 1; + + var p = (line[idx, 0], line[idx, 1]); + if (res.Count == 0 || res[^1].Item1 != p.Item1 || res[^1].Item2 != p.Item2) + res.Add(p); + } + + return res; + } + + + + + + /// + /// Construye elev samples. + /// + private async Task> ConstruirMuestrasElevacionAsync(double[,] line, CancellationToken ct) + { + var sampled = SampleLine(line, _elevMaxPuntosPorTramo); + + // --- IDEE: pedir misses primero, porque es la fuente importante --- + var missesIdee = new List<(double lat, double lon)>(); + foreach (var (lat, lon) in sampled) + { + var k = ClaveElevacionIdee(lat, lon); + if (!_elevIdeeCache.ContainsKey(k)) + missesIdee.Add((Math.Round(lat, ELEV_IDEE_DECIMALES_CACHE), Math.Round(lon, ELEV_IDEE_DECIMALES_CACHE))); + } + + for (int i = 0; i < missesIdee.Count; i += ELEV_BATCH_MAX_PUNTOS) + { + ct.ThrowIfCancellationRequested(); + await ObtenerElevacionesIdeePorLoteAsync(missesIdee.Skip(i).Take(ELEV_BATCH_MAX_PUNTOS).ToList(), ct); + } + + // --- OpenElevation: best-effort y con timeout corto --- + var missesOpen = new List<(double lat, double lon)>(); + foreach (var (lat, lon) in sampled) + { + var k = ElevKey(lat, lon); + if (!_elevCache.ContainsKey(k)) + missesOpen.Add((Math.Round(lat, ELEV_DECIMALES_CACHE), Math.Round(lon, ELEV_DECIMALES_CACHE))); + } + + for (int i = 0; i < missesOpen.Count; i += ELEV_BATCH_MAX_PUNTOS) + { + ct.ThrowIfCancellationRequested(); + await ObtenerElevacionesPorLoteAsync(missesOpen.Skip(i).Take(ELEV_BATCH_MAX_PUNTOS).ToList(), ct); + } + + // --- montar resultado (UNA sola vez) --- + var res = new List(sampled.Count); + for (int i = 0; i < sampled.Count; i++) + { + ct.ThrowIfCancellationRequested(); + var (lat, lon) = sampled[i]; + + var elevOpen = _elevCache.TryGetValue(ElevKey(lat, lon), out var e1) ? e1 : (double?)null; + var elevIdee = _elevIdeeCache.TryGetValue(ClaveElevacionIdee(lat, lon), out var e2) ? e2 : (double?)null; + + res.Add(new MuestraElevacion + { + Indice = i, + Latitud = lat, + Longitud = lon, + Elevacion = elevOpen, + ElevacionIdee = elevIdee + }); + } + + return res; + } + + /// + /// Gestiona tramo bus es basura. + /// + private bool PrimerTramoBusEsBasura( + RutaBusAlternativa alternativa, + double[] coordsOrigen, + double[] coordsDestino) + { + if (alternativa?.Tramos is null || alternativa.Tramos.Count < 2) + return false; // si solo hay un bus, no aplicamos esta regla + + var tramos = alternativa.Tramos; + + var primer = tramos[0]; + var segundo = tramos[1]; + + // Parada real donde subes al primer bus + string codIniPrimer = primer.Variante.CodigosParadas[primer.IndiceInicio]; + + // Parada real donde bajas del primer bus + string codFinPrimer = primer.Variante.CodigosParadas[primer.IndiceFin]; + + // Parada real donde subes al segundo bus + string codIniSegundo = segundo.Variante.CodigosParadas[segundo.IndiceInicio]; + + if (!_paradaByCode.TryGetValue(codIniPrimer, out var pIniPrimer)) + return false; + + if (!_paradaByCode.TryGetValue(codFinPrimer, out var pFinPrimer)) + return false; + + if (!_paradaByCode.TryGetValue(codIniSegundo, out var pIniSegundo)) + return false; + + // Distancia del primer tramo bus + double distPrimerBus = 0; + { + double distTmp; + var lineas = RedBusServicio.ConstruirLineasBus(new List { primer }, out distTmp); + distPrimerBus = Math.Max(0, distTmp); + } + + // Nº de paradas del primer tramo + int paradasPrimerBus = ContarParadasRecorridas(primer); + + // Caminata del transbordo tras ese primer tramo + double distWalkTransfer = CalcularDistanciaMetros( + pFinPrimer.Latitud, pFinPrimer.Longitud, + pIniSegundo.Latitud, pIniSegundo.Longitud); + + // Distancia desde origen hasta la parada de subida + double distPieInicio = CalcularDistanciaMetros( + coordsOrigen[0], coordsOrigen[1], + pIniPrimer.Latitud, pIniPrimer.Longitud); + + // Distancia desde donde te deja ese primer bus hasta destino + double distDesdeBajada1ADestino = CalcularDistanciaMetros( + pFinPrimer.Latitud, pFinPrimer.Longitud, + coordsDestino[0], coordsDestino[1]); + + // Distancia desde donde subes al primer bus hasta destino + double distDesdeSubida1ADestino = CalcularDistanciaMetros( + pIniPrimer.Latitud, pIniPrimer.Longitud, + coordsDestino[0], coordsDestino[1]); + + double avanceUtil = distDesdeSubida1ADestino - distDesdeBajada1ADestino; + + // ===================================================== + // Reglas SOLO para detectar "primer bus absurdo" + // ===================================================== + + // 1) Bus muy corto + transbordo andando largo + if (distPrimerBus <= 350 && distWalkTransfer >= 250) + return true; + + // 2) Apenas 1 parada + luego caminata importante + if (paradasPrimerBus <= 1 && distWalkTransfer >= 180) + return true; + + // 3) El primer bus aporta poco avance real hacia destino + if (avanceUtil <= 180 && distWalkTransfer >= 180) + return true; + + // 4) Subes andando poco, haces mini-bus, y luego una caminata grande + if (distPieInicio <= 180 && distPrimerBus <= 500 && distWalkTransfer >= 300) + return true; + + // 5) El transbordo andando es casi tan grande como el propio tramo bus + if (distPrimerBus > 0 && distWalkTransfer >= distPrimerBus * 0.9 && distWalkTransfer >= 200) + return true; + + return false; + } + + string FmtDist(double? d) + { + if (d is null) return "-"; + if (d.Value < 50) return $"{d.Value:0.0} m"; + return $"{Math.Round(d.Value):0} m"; + } + + string FmtPend(double? pend, double? distPrev) + { + if (pend is null || distPrev is null) return "-"; + if (distPrev.Value < 15) return "-"; + return $"{pend.Value:+0.0;-0.0;0.0} %"; + } + + + + + + + + + + private sealed class RespuestaOpenElevation + { + public List? results { get; set; } + } + private sealed class ResultadoOpenElevation + { + public double latitude { get; set; } + public double longitude { get; set; } + public double elevation { get; set; } + } + + /// + /// Recupera elevations el lote. + /// + private async Task ObtenerElevacionesPorLoteAsync(List<(double lat, double lon)> points, CancellationToken ct) + { + if (points.Count == 0) return; + + // locations=lat,lon|lat,lon|... + var parts = new List(points.Count); + foreach (var (lat, lon) in points) + { + ct.ThrowIfCancellationRequested(); + parts.Add($"{lat.ToString(CultureInfo.InvariantCulture)},{lon.ToString(CultureInfo.InvariantCulture)}"); + } + + string url = "https://openelevation.tecnosis.net/api/v1/lookup?locations=" + string.Join("|", parts); + + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + using var ctsTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + ctsTimeout.CancelAfter(ELEV_OPEN_TIMEOUT_MS); + + using var resp = await Http.SendAsync(req, ctsTimeout.Token); + if (!resp.IsSuccessStatusCode) return; + + var json = await resp.Content.ReadAsStringAsync(ctsTimeout.Token); + + var data = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (data?.results is null || data.results.Count == 0) return; + + foreach (var r in data.results) + { + var k = ElevKey(r.latitude, r.longitude); + _elevCache[k] = r.elevation; + } + } + catch + { + // silencioso: elevación es informativa + } + } + + private async Task<(double? min, double? max)> GetElevationMinMaxForLineAsync(double[,]? line, CancellationToken ct) + { + if (line is null) return (null, null); + if (line.GetLength(0) <= 0) return (null, null); + + var sampled = SampleLine(line, _elevMaxPuntosPorTramo); + if (sampled.Count == 0) return (null, null); + + // misses + var misses = new List<(double lat, double lon)>(sampled.Count); + foreach (var (lat, lon) in sampled) + { + ct.ThrowIfCancellationRequested(); + var k = ElevKey(lat, lon); + if (!_elevCache.ContainsKey(k)) + { + // redondeo para que cachee mejor y mande menos variedad + misses.Add((Math.Round(lat, ELEV_DECIMALES_CACHE), Math.Round(lon, ELEV_DECIMALES_CACHE))); + } + } + + // pedir en lotes + if (misses.Count > 0) + { + for (int i = 0; i < misses.Count; i += ELEV_BATCH_MAX_PUNTOS) + { + ct.ThrowIfCancellationRequested(); + var batch = misses.Skip(i).Take(ELEV_BATCH_MAX_PUNTOS).ToList(); + await ObtenerElevacionesPorLoteAsync(batch, ct); + } + } + + // calcular min/max con lo disponible + double? min = null; + double? max = null; + + foreach (var (lat, lon) in sampled) + { + ct.ThrowIfCancellationRequested(); + var k = ElevKey(lat, lon); + if (_elevCache.TryGetValue(k, out var elev)) + { + min = (min is null) ? elev : Math.Min(min.Value, elev); + max = (max is null) ? elev : Math.Max(max.Value, elev); + } + } + + return (min, max); + } + + /// + /// Formatea elevacion minimo maximo. + /// + private static string FormatearElevacionMinMax(double? min, double? max) + { + if (min is null || max is null) return ""; + return $" · Altitud: {Math.Round(min.Value):0}-{Math.Round(max.Value):0} m"; + } + + /// + /// Gestiona geometria completa variante. + /// + private double[,]? ConstruirGeometriaCompletaVariante(InfoVariante v) + { + if (v?.CodigosParadas is null || v.CodigosParadas.Count < 2) return null; + + // tramo completo: desde la primera parada a la última + var tramo = new TramoBus + { + Variante = v, + IndiceInicio = 0, + IndiceFin = v.CodigosParadas.Count - 1 + }; + + double dist; + var lineas = RedBusServicio.ConstruirLineasBus(new List { tramo }, out dist); + + // ConstruirLineasBus devuelve lista de lineas por tramo (aquí solo 1) + if (lineas is null || lineas.Count == 0) return null; + + return lineas[0]; + } + + + // ========================================================= + // Rutado BUS filtrado (BFS + filtroVariante) + // ========================================================= + + /// + /// Busca ruta bus con filtro. + /// + private List? BuscarRutaBusConFiltro( + string codigoParadaOrigen, + string codigoParadaDestino, + Func filtroVariante, + out List codigosParadasTransbordo, + int? maxTransbordos = null) + { + codigosParadasTransbordo = new(); + + if (!_indiceParadas.TryGetValue(codigoParadaOrigen, out var aparicionesOrigen)) + return null; + if (!_indiceParadas.TryGetValue(codigoParadaDestino, out var aparicionesDestino)) + return null; + + // Estado = (variante, índice) + // Coste = nº de transbordos (cambios de variante) desde que subes al primer bus + var dq = new ColaDoble<(InfoVariante Variante, int IndiceParada)>(); + + var dist = new Dictionary<(InfoVariante Variante, int IndiceParada), int>(); + var padre = new Dictionary<(InfoVariante Variante, int IndiceParada), + (InfoVariante Variante, int IndiceParada)?>(); + + // Inicial: subir al primer bus NO cuenta como transbordo => 0 + foreach (var ap in aparicionesOrigen) + { + if (!filtroVariante(ap.Variante)) + continue; + + var nodo = (ap.Variante, ap.IndiceParada); + if (dist.ContainsKey(nodo)) continue; + + dist[nodo] = 0; + padre[nodo] = null; + dq.PushFront(nodo); + } + + (InfoVariante Variante, int IndiceParada)? objetivo = null; + + while (dq.Count > 0) + { + var actual = dq.PopFront(); + var (variante, indice) = actual; + + int transUsados = dist[actual]; + if (maxTransbordos is not null && transUsados > maxTransbordos.Value) + continue; + + string codigoParadaActual = variante.CodigosParadas[indice]; + + if (codigoParadaActual == codigoParadaDestino) + { + objetivo = actual; + break; // ✅ 0-1 BFS => primer objetivo es mínimo en transbordos + } + + // ----------------------- + // 1) AVANZAR (coste 0) + // ----------------------- + if (indice + 1 < variante.CodigosParadas.Count) + { + var sig = (variante, indice + 1); + int nuevo = transUsados; // coste 0 + + if (!dist.TryGetValue(sig, out var best) || nuevo < best) + { + dist[sig] = nuevo; + padre[sig] = actual; + dq.PushFront(sig); // coste 0 => delante + } + } + + // ----------------------- + // 2) TRANSBORDO MISMA PARADA (coste 1 si cambia variante) + // ----------------------- + if (_indiceParadas.TryGetValue(codigoParadaActual, out var aparicionesParada)) + { + foreach (var ap in aparicionesParada) + { + if (!filtroVariante(ap.Variante)) + continue; + + // si es exactamente el mismo estado, nada + if (ReferenceEquals(ap.Variante, variante) && ap.IndiceParada == indice) + continue; + + int nuevo = transUsados + 1; // cambio de bus = +1 + if (maxTransbordos is not null && nuevo > maxTransbordos.Value) + continue; + + var sig = (ap.Variante, ap.IndiceParada); + + if (!dist.TryGetValue(sig, out var best) || nuevo < best) + { + dist[sig] = nuevo; + padre[sig] = actual; + dq.PushBack(sig); // coste 1 => detrás + } + } + } + + // ----------------------- + // 3) TRANSBORDO ANDANDO A PARADAS CERCANAS (coste 1 si cambia variante) + // ----------------------- + if (_paradasCercanas.TryGetValue(codigoParadaActual, out var vecinas)) + { + foreach (var codVecino in vecinas) + { + if (!_indiceParadas.TryGetValue(codVecino, out var aparicionesVecino)) + continue; + + foreach (var ap in aparicionesVecino) + { + if (!filtroVariante(ap.Variante)) + continue; + + int nuevo = transUsados + 1; + if (maxTransbordos is not null && nuevo > maxTransbordos.Value) + continue; + + var sig = (ap.Variante, ap.IndiceParada); + + if (!dist.TryGetValue(sig, out var best) || nuevo < best) + { + dist[sig] = nuevo; + padre[sig] = actual; + dq.PushBack(sig); + } + } + } + } + } + + if (objetivo is null) + return null; + + // reconstrucción camino + var nodosCamino = new List<(InfoVariante Variante, int IndiceParada)>(); + var nodoActual = objetivo.Value; + + while (true) + { + nodosCamino.Add(nodoActual); + if (!padre.TryGetValue(nodoActual, out var anterior) || anterior is null) + break; + nodoActual = anterior.Value; + } + + nodosCamino.Reverse(); + + int numeroTransbordos; + var tramos = ConstruirTramosYTransbordosDesdeCamino( + nodosCamino, + codigoParadaOrigen, + codigoParadaDestino, + out codigosParadasTransbordo, + out numeroTransbordos); + + // ✅ númeroTransbordos aquí será coherente con el 0-1 BFS (mínimo transbordos) + return tramos; + } + + + /// + /// Gestiona tramos y transbordos desde camino. + /// + private List ConstruirTramosYTransbordosDesdeCamino( + List<(InfoVariante Variante, int IndiceParada)> nodosCamino, + string codigoParadaOrigen, + string codigoParadaDestino, + out List codigosParadasTransbordo, + out int numeroTransbordos) + { + codigosParadasTransbordo = new List(); + + for (int i = 1; i < nodosCamino.Count; i++) + { + var previo = nodosCamino[i - 1]; + var ahora = nodosCamino[i]; + + if (!ReferenceEquals(previo.Variante, ahora.Variante)) + { + string codigoParada = ahora.Variante.CodigosParadas[ahora.IndiceParada]; + if (!codigosParadasTransbordo.Contains(codigoParada) && + codigoParada != codigoParadaOrigen && + codigoParada != codigoParadaDestino) + { + codigosParadasTransbordo.Add(codigoParada); + } + } + } + + var tramos = new List(); + if (nodosCamino.Count <= 1) + { + numeroTransbordos = 0; + return tramos; + } + + var varianteActual = nodosCamino[0].Variante; + int indiceInicio = nodosCamino[0].IndiceParada; + int ultimoIndice = nodosCamino[0].IndiceParada; + + for (int i = 1; i < nodosCamino.Count; i++) + { + var (variante, indice) = nodosCamino[i]; + + if (ReferenceEquals(variante, varianteActual) && indice == ultimoIndice + 1) + { + ultimoIndice = indice; + continue; + } + + if (ultimoIndice > indiceInicio) + { + tramos.Add(new TramoBus + { + Variante = varianteActual, + IndiceInicio = indiceInicio, + IndiceFin = ultimoIndice + }); + } + + varianteActual = variante; + indiceInicio = indice; + ultimoIndice = indice; + } + + if (ultimoIndice > indiceInicio) + { + tramos.Add(new TramoBus + { + Variante = varianteActual, + IndiceInicio = indiceInicio, + IndiceFin = ultimoIndice + }); + } + + codigosParadasTransbordo = new List(); + for (var i = 1; i < tramos.Count; i++) + { + if (!UtilidadesTransitoDbus.EsTransbordoReal(tramos[i - 1], tramos[i])) + continue; + + var codigoParada = tramos[i].CodigoParadaInicio; + if (!codigosParadasTransbordo.Contains(codigoParada, StringComparer.OrdinalIgnoreCase) && + codigoParada != codigoParadaOrigen && + codigoParada != codigoParadaDestino) + { + codigosParadasTransbordo.Add(codigoParada); + } + } + + numeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(tramos); + return tramos; + } + + /// + /// Busca rutas bus alternativas filtradas. + /// + private IReadOnlyList BuscarRutasBusAlternativasFiltradas( + string codigoParadaOrigen, + string codigoParadaDestino, + int maxAlternativas) + { + EnsureBusIndexBuilt(); + + // ----------------------------------- + // RANGO transbordos (único): + // - maxTrans entra al BFS como poda (si rango activo) + // - minTrans se aplica solo como criterio de aceptación + // ----------------------------------- + int minTrans = 0; + int? maxTrans = null; + + if (_activarRangoTransbordos) + { + minTrans = Math.Max(0, _minTransbordosPermitidos); + var maxT = Math.Max(0, _maxTransbordosPermitidos); + + if (minTrans > maxT) (minTrans, maxT) = (maxT, minTrans); + + maxTrans = maxT; // ✅ poda BFS + } + + int minIt = Math.Min(_itinerarioMin, _itinerarioMax); + int maxIt = Math.Max(_itinerarioMin, _itinerarioMax); + + int minLin = Math.Min(_lineaMin, _lineaMax); + int maxLin = Math.Max(_lineaMin, _lineaMax); + var lineasPermitidas = ObtenerLineasPermitidasSeleccionadas(); + var lineasPermitidasKey = string.Join(",", lineasPermitidas); + + // ✅ Cache key incluye rango de transbordos (min/max) + var key = + $"{codigoParadaOrigen}|{codigoParadaDestino}" + + $"|itR:{(_activarRangoItinerarios ? 1 : 0)}|{minIt}|{maxIt}" + + $"|lnR:{(_activarRangoLineas ? 1 : 0)}|{minLin}|{maxLin}|lnL:{lineasPermitidasKey}" + + $"|alts:{maxAlternativas}" + + $"|txR:{(_activarRangoTransbordos ? 1 : 0)}|minT:{minTrans}|maxT:{(maxTrans ?? -1)}"; + + if (_busFilteredCache.TryGetValue(key, out var cached)) + return cached; + + var res = new List(Math.Max(1, maxAlternativas)); + if (maxAlternativas <= 0) + { + _busFilteredCache[key] = res; + return res; + } + + // Filtro por itinerarios/lineas durante BFS + Func filtro = _ => true; + if (_activarRangoItinerarios || _activarRangoLineas) + filtro = VariantePermitida; + + string FirmaRuta(List tramos) => + string.Join(";", tramos.Select(t => + $"{t.Variante.Linea.Codigo}-{t.Variante.CodigoItinerario}-{NormalizarSentidoCodigo(t.Variante.Sentido?.Codigo)}-{t.Variante.Variante.Codigo}-{t.IndiceInicio}-{t.IndiceFin}")); + + // Firmas para evitar duplicados + var firmas = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Cola de "prohibiciones" para forzar alternativas + var prohibicionesPendientes = new Queue>(); + prohibicionesPendientes.Enqueue(new HashSet()); // principal + + // Evitar repetir exactamente la misma combinación de variantes prohibidas + var firmasProhibiciones = new HashSet(StringComparer.OrdinalIgnoreCase); + firmasProhibiciones.Add(""); // vacío = principal + + // Límite de seguridad para no explotar tiempo + // Si hay mínimo > 0, damos más margen de exploración + int limiteIntentos = (_activarRangoTransbordos && minTrans > 0) ? 10 : 12; + int intentos = 0; + + while (prohibicionesPendientes.Count > 0 && + res.Count < maxAlternativas && + intentos < limiteIntentos) + { + intentos++; + + var prohibidas = prohibicionesPendientes.Dequeue(); + + bool FiltroConProhibidas(InfoVariante v) + => filtro(v) && !prohibidas.Contains(v); + + var ruta = BuscarRutaBusConFiltro( + codigoParadaOrigen, + codigoParadaDestino, + FiltroConProhibidas, + out var trans, + maxTrans); + + if (ruta is null || ruta.Count == 0) + continue; + + var firma = FirmaRuta(ruta); + + // Si la ruta ya la vimos, no la añadimos + if (!firmas.Add(firma)) + continue; + + double dist; + _ = RedBusServicio.ConstruirLineasBus(ruta, out dist); + + var alt = new RutaBusAlternativa + { + Tramos = ruta, + CodigosParadasTransbordo = trans, + NumeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(ruta), + DistanciaTotalBus = dist + }; + + // ✅ Aceptar solo si cumple rango (min/max) + if (CumpleFiltroTransbordos(alt.NumeroTransbordos)) + { + res.Add(alt); + } + + // ✅ Seguir explorando alternativas: + // generar nuevas búsquedas prohibiendo una variante de esta ruta + var variantesRuta = ruta.Select(t => t.Variante).Distinct().ToList(); + + foreach (var vProhibir in variantesRuta) + { + if (prohibicionesPendientes.Count >= 20) + break; + + var nuevoSet = new HashSet(prohibidas); + nuevoSet.Add(vProhibir); + + // firma estable de prohibiciones (por identidad lógica) + var firmaProh = string.Join("|", + nuevoSet + .Select(v => + $"{v.Linea.Codigo}-{v.CodigoItinerario}-{NormalizarSentidoCodigo(v.Sentido?.Codigo)}-{v.Variante.Codigo}") + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)); + + if (firmasProhibiciones.Add(firmaProh)) + prohibicionesPendientes.Enqueue(nuevoSet); + } + } + + _busFilteredCache[key] = res; + return res; + } + + // ========================================================= + // Deduplicación fuerte + coste + // ========================================================= + + /// + /// Gestiona iv. + /// + private static string SentidoIV(int indiceInicio, int indiceFin) + => indiceFin >= indiceInicio ? "I" : "V"; + + /// + /// Normaliza sentido codigo. + /// + private static string NormalizarSentidoCodigo(string? codigo) + { + if (string.IsNullOrWhiteSpace(codigo)) return "?"; + + var s = codigo.Trim(); + + // aceptar códigos ya normalizados + if (s.Equals("V", StringComparison.OrdinalIgnoreCase)) return "V"; + if (s.Equals("I", StringComparison.OrdinalIgnoreCase)) return "I"; + + // aceptar textos + if (s.Equals("Vuelta", StringComparison.OrdinalIgnoreCase)) return "V"; + if (s.Equals("Ida", StringComparison.OrdinalIgnoreCase)) return "I"; + + // fallback razonable + var c = char.ToUpperInvariant(s[0]); + if (c == 'V') return "V"; + if (c == 'I') return "I"; + return "?"; + } + + /// + /// Gestiona linea itinerario sentido. + /// + private static string CodigoLineaItinerarioSentido(TramoBus tramo) + { + var v = tramo.Variante; + + var linea = v.Linea.Codigo?.ToString() ?? "?"; + var itin = v.CodigoItinerario; + + // ✅ sentido real del JSON + var sentido = NormalizarSentidoCodigo(v.Sentido?.Codigo); + + return $"{linea}.{itin}-{sentido}"; + } + + + /// + /// Gestiona ruta bus. + /// + private static string FirmaRutaBus(RutaBusAlternativa alt) + { + return string.Join(">", + alt.Tramos.Select(t => $"{CodigoLineaItinerarioSentido(t)}:{t.IndiceInicio}->{t.IndiceFin}") + ); + } + + /// + /// Calcula transbordos reales. + /// + private static int CalcularTransbordosReales(RutaBusAlternativa alternativa) + { + return UtilidadesTransitoDbus.ContarTransbordosReales(alternativa?.Tramos); + } + + + + /// + /// Gestiona aprox seg. + /// + private double CosteAproxSeg(AlternativaRuta alt) + { + if (alt.EsSoloAPie) + return alt.DuracionTotalAproxSegundos; + + double t = 0; + t += alt.TiempoPieInicio; + t += alt.TiempoPieFin; + double extraParadas = 0; + foreach (var tb in alt.AlternativaBus!.Tramos) + extraParadas += ContarParadasRecorridas(tb) * Math.Max(0, _segundosPorParadaBus); + + t += EstimarDuracionBusSegundos(alt.AlternativaBus!.DistanciaTotalBus) + extraParadas; + + t += alt.NumeroTransbordosReales * (_minutosTransbordo * 60.0); + return t; + } + + // ========================================================= + // Acciones UI + // ========================================================= + + /// + /// Resuelve parada desde texto. + /// + private Parada? ResolverParadaDesdeTexto(string texto) + { + if (string.IsNullOrWhiteSpace(texto)) + return null; + + texto = texto.Trim(); + + int idx = texto.LastIndexOf('('); + if (idx >= 0 && texto.EndsWith(")")) + { + var codigoRaw = texto.Substring(idx + 1, texto.Length - idx - 2).Trim(); + var codNorm = NormalizarCodigoParada(codigoRaw); + + var paradaPorCodigo = RedBusServicio.Paradas.FirstOrDefault(p => + NormalizarCodigoParada(p.Codigo).Equals(codNorm, StringComparison.OrdinalIgnoreCase)); + + if (paradaPorCodigo is not null) + return paradaPorCodigo; + } + + var codNorm2 = NormalizarCodigoParada(texto); + + var porCodigo = RedBusServicio.Paradas.FirstOrDefault(p => + NormalizarCodigoParada(p.Codigo).Equals(codNorm2, StringComparison.OrdinalIgnoreCase)); + + if (porCodigo is not null) + return porCodigo; + + var porNombre = RedBusServicio.Paradas + .FirstOrDefault(p => + p.Nombre.Contains(texto, StringComparison.OrdinalIgnoreCase) || + p.NombreCorto.Contains(texto, StringComparison.OrdinalIgnoreCase) || + p.Abreviatura.Contains(texto, StringComparison.OrdinalIgnoreCase)); + + return porNombre; + } + + /// + /// Cambia alternativa absoluta. + /// + private async Task CambiarAlternativaAbsoluta(int indice) + { + await CerrarTodasLasPreviewsAsync(); + if (indice < 0 || indice >= _opcionesRuta.Count) return; + + _indiceAlternativaSeleccionada = indice; + await CerrarPopupsAsync(); + + ResetEstadoRuta(limpiarOpciones: false, limpiarMensaje: false); + _mensajeRuta = "Representando ruta seleccionada..."; + StateHasChanged(); + await Task.Yield(); + await PintarOpcionRutaCompleta(_opcionesRuta[indice]); + await RegistrarEscenarioActualEnHistorialLocalAsync(); + } + + /// + /// Cambia el criterio prioritario de rutas sugeridas. + /// + private async Task CambiarCriterioPrioritarioRutasAsync(string criterio) + { + var criterioNormalizado = NormalizarCriterioRuta(criterio); + if (string.Equals(_criterioPrioritarioRuta, criterioNormalizado, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _criterioPrioritarioRuta = criterioNormalizado; + SincronizarPreferenciaDesdeCriterioRuta(); + await PersistirConfiguracionAsync(); + + if (_mapa is not null && _coordenadasOrigen is not null && _coordenadasDestino is not null) + { + _omitirRegistroHistorialEnSiguienteCalculo = true; + await CalcularRutaCompletaAsync(); + return; + } + + if (_opcionesRuta.Count == 0) + { + StateHasChanged(); + return; + } + + OrdenarOpcionesRutaSegunCriterio(); + var indicePreferido = BuscarIndicePreferidoPorCriterio(criterioNormalizado); + if (indicePreferido < 0 || indicePreferido >= _opcionesRuta.Count) + { + StateHasChanged(); + return; + } + + await CerrarTodasLasPreviewsAsync(); + _indiceAlternativaSeleccionada = indicePreferido; + await CerrarPopupsAsync(); + ResetEstadoRuta(limpiarOpciones: false, limpiarMensaje: false); + _mensajeRuta = "Representando ruta seleccionada..."; + StateHasChanged(); + await Task.Yield(); + await PintarOpcionRutaCompleta(_opcionesRuta[indicePreferido]); + StateHasChanged(); + } + + private int BuscarIndicePreferidoPorCriterio(string criterio) + { + if (string.Equals(criterio, CriterioRutaEquilibrado, StringComparison.OrdinalIgnoreCase)) + return BuscarIndiceEquilibrado(); + + if (string.Equals(criterio, CriterioRutaAndar, StringComparison.OrdinalIgnoreCase)) + { + return BuscarPrimerIndiceBusOrdenadoPor( + opcion => CalcularCosteAndarEquilibradoSegundos(opcion), + opcion => DuracionOrdenacion(opcion)); + } + + return BuscarPrimerIndiceBusOrdenadoPor( + opcion => DuracionOrdenacion(opcion), + opcion => TiempoPieOrdenacion(opcion)); + } + + private int BuscarIndiceEquilibrado() + { + var candidatas = _opcionesRuta + .Select((opcion, indice) => new { opcion, indice }) + .Where(x => !x.opcion.EsSoloAPie) + .ToList(); + + if (candidatas.Count == 0) + return _opcionesRuta.Count > 0 ? 0 : -1; + + var duraciones = candidatas + .Select(x => DuracionOrdenacion(x.opcion)) + .Where(double.IsFinite) + .ToList(); + + var costesAndar = candidatas + .Select(x => CalcularCosteAndarEquilibradoSegundos(x.opcion)) + .Where(double.IsFinite) + .ToList(); + + if (duraciones.Count == 0 || costesAndar.Count == 0) + return candidatas + .OrderBy(x => x.opcion.PuntuacionCoste) + .ThenBy(x => DuracionOrdenacion(x.opcion)) + .Select(x => x.indice) + .FirstOrDefault(); + + var minDuracion = duraciones.Min(); + var maxDuracion = duraciones.Max(); + var minAndar = costesAndar.Min(); + var maxAndar = costesAndar.Max(); + + return candidatas + .Select(x => + { + var sacrificioTiempo = CalcularSacrificioNormalizado( + DuracionOrdenacion(x.opcion), + minDuracion, + maxDuracion); + var sacrificioAndar = CalcularSacrificioNormalizado( + CalcularCosteAndarEquilibradoSegundos(x.opcion), + minAndar, + maxAndar); + return new + { + x.indice, + x.opcion, + PeorSacrificio = Math.Max(sacrificioTiempo, sacrificioAndar), + SacrificioMedio = (sacrificioTiempo + sacrificioAndar) / 2.0 + }; + }) + .OrderBy(x => x.PeorSacrificio) + .ThenBy(x => x.SacrificioMedio) + .ThenBy(x => x.opcion.NumeroTransbordosReales) + .ThenBy(x => x.opcion.TiempoEsperaTotalSegundos) + .ThenBy(x => x.opcion.PuntuacionCoste) + .Select(x => x.indice) + .FirstOrDefault(); + } + + private void OrdenarOpcionesRutaSegunCriterio() + { + if (_opcionesRuta.Count <= 1) + return; + + IEnumerable rutasBus = _opcionesRuta.Where(opcion => !opcion.EsSoloAPie); + if (CriterioPrioritarioAndarActivo) + { + rutasBus = rutasBus + .OrderBy(CalcularCosteAndarEquilibradoSegundos) + .ThenBy(DuracionOrdenacion) + .ThenBy(opcion => opcion.PuntuacionCoste); + } + else if (CriterioPrioritarioTiempoActivo) + { + rutasBus = rutasBus + .OrderBy(DuracionOrdenacion) + .ThenBy(TiempoPieOrdenacion) + .ThenBy(opcion => opcion.PuntuacionCoste); + } + + var soloPie = _opcionesRuta + .Where(opcion => opcion.EsSoloAPie) + .OrderBy(DuracionOrdenacion) + .ThenBy(opcion => opcion.DistanciaSoloAPie); + + var ordenadas = rutasBus.Concat(soloPie).ToList(); + _opcionesRuta.Clear(); + _opcionesRuta.AddRange(ordenadas); + RenumerarEtiquetasGenericasOpcionesRuta(); + } + + private void RenumerarEtiquetasGenericasOpcionesRuta() + { + for (var i = 0; i < _opcionesRuta.Count; i++) + { + var etiqueta = _opcionesRuta[i].Etiqueta; + if (!EsEtiquetaOpcionGenerica(etiqueta)) + continue; + + _opcionesRuta[i].Etiqueta = $"Opcion {i + 1}"; + } + } + + private static bool EsEtiquetaOpcionGenerica(string? etiqueta) + { + if (string.IsNullOrWhiteSpace(etiqueta)) + return false; + + return Regex.IsMatch( + etiqueta.Trim(), + @"^Opci(?:o|ó|ó)n\s+\d+$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + private int BuscarPrimerIndiceBusOrdenadoPor( + Func criterioPrincipal, + Func criterioSecundario) + { + var indiceBus = _opcionesRuta + .Select((opcion, indice) => new { opcion, indice }) + .Where(x => !x.opcion.EsSoloAPie) + .OrderBy(x => criterioPrincipal(x.opcion)) + .ThenBy(x => criterioSecundario(x.opcion)) + .ThenBy(x => x.opcion.PuntuacionCoste) + .Select(x => x.indice) + .FirstOrDefault(-1); + + if (indiceBus >= 0) + return indiceBus; + + return _opcionesRuta.Count > 0 ? 0 : -1; + } + + private double DuracionOrdenacion(AlternativaRuta alternativa) + => double.IsFinite(alternativa.DuracionTotalAproxSegundos) + ? alternativa.DuracionTotalAproxSegundos + : double.PositiveInfinity; + + private double TiempoPieOrdenacion(AlternativaRuta alternativa) + { + if (alternativa.EsSoloAPie) + return DuracionOrdenacion(alternativa); + + var tiempoTransbordosPie = EstimarDuracionPieSegundos(alternativa.DistanciaPieTransbordosAprox); + return Math.Max(0, alternativa.TiempoPieInicio) + + Math.Max(0, tiempoTransbordosPie) + + Math.Max(0, alternativa.TiempoPieFin); + } + + /// + /// Intercambia origen destino. + /// + private async Task IntercambiarOrigenDestino() + { + (_coordenadasOrigen, _coordenadasDestino) = (_coordenadasDestino, _coordenadasOrigen); + (_textoBusquedaOrigen, _textoBusquedaDestino) = (_textoBusquedaDestino, _textoBusquedaOrigen); + (_ubicacionOrigenSeleccionada, _ubicacionDestinoSeleccionada) = (_ubicacionDestinoSeleccionada, _ubicacionOrigenSeleccionada); + _sugerenciasBusquedaOrigen.Clear(); + _sugerenciasBusquedaDestino.Clear(); + _campoBusquedaActivo = null; + + await ClearRoutesJsAsync(); + await CerrarTodasLasPreviewsAsync(); + await SincronizarMarcadoresOrigenDestinoJsAsync(); + + if (_mapa is not null) + { + try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } + } + + if (_coordenadasOrigen is not null && _coordenadasDestino is not null) + await CalcularRutaCompletaAsync(); + + StateHasChanged(); + } + + // ========================================================= + // Duración exacta (tarjetas = detalle) + velocidad a pie configurada + // ========================================================= + + /// + /// Calcula duracion exacta. + /// + private async Task CalcularDuracionExactaAsync(AlternativaRuta alt, CancellationToken ct) + { + if (alt.EsSoloAPie) + return alt.DuracionSoloAPie > 0 ? alt.DuracionSoloAPie : alt.DuracionTotalAproxSegundos; + + var tramosBus = alt.AlternativaBus!.Tramos; + + // 0) Hora base (real/simulada) + var tCursor = AhoraLocal(); + + // Si horarios ON, asegúrate de tenerlos cargados + if (_usarHorariosTeoricos) + await EnsureHorariosLoadedAsync(); + + // 1) Resolver paradas reales inicio/fin de bus + Parada paradaInicioBus = alt.OrigenParada; + Parada paradaFinBus = alt.DestinoParada; + + if (tramosBus.Count > 0) + { + var primerTramo = tramosBus.First(); + var ultimoTramo = tramosBus.Last(); + + string codIniReal = primerTramo.Variante.CodigosParadas[primerTramo.IndiceInicio]; + string codFinReal = ultimoTramo.Variante.CodigosParadas[ultimoTramo.IndiceFin]; + + paradaInicioBus = _paradaByCode.TryGetValue(codIniReal, out var pIni) ? pIni : alt.OrigenParada; + paradaFinBus = _paradaByCode.TryGetValue(codFinReal, out var pFin) ? pFin : alt.DestinoParada; + } + + double total = 0; + + // ✅ resetea distancias (para “Menos andar”, etc.) + alt.DistanciaPieInicioAprox = 0; + alt.DistanciaPieTransbordosAprox = 0; + alt.DistanciaPieFinAprox = 0; + + // 2) Origen -> inicio bus (walk) + ct.ThrowIfCancellationRequested(); + var w1 = await WalkCached(_coordenadasOrigen![0], _coordenadasOrigen[1], paradaInicioBus.Latitud, paradaInicioBus.Longitud); + + double distPie1 = (w1.dist > 0) ? w1.dist + : CalcularDistanciaMetros(_coordenadasOrigen[0], _coordenadasOrigen[1], paradaInicioBus.Latitud, paradaInicioBus.Longitud); + + alt.DistanciaPieInicioAprox = distPie1; + var durPie1 = EstimarDuracionPieSegundos(distPie1); + + tCursor = AddSecondsSafe(tCursor, durPie1); + total += durPie1; + + // 3) BUS tramo a tramo (con esperas reales si horarios OK) + Parada paradaUltimaBus = paradaInicioBus; + + for (int i = 0; i < tramosBus.Count; i++) + { + ct.ThrowIfCancellationRequested(); + + var tramo = tramosBus[i]; + var v = tramo.Variante; + + string codParadaIni = v.CodigosParadas[tramo.IndiceInicio]; + string codParadaFin = v.CodigosParadas[tramo.IndiceFin]; + + var pIniTramo = _paradaByCode.TryGetValue(codParadaIni, out var p0) ? p0 : paradaUltimaBus; + var pFinTramo = _paradaByCode.TryGetValue(codParadaFin, out var p1) ? p1 : paradaUltimaBus; + + // ---- Transbordo (si i>0): caminar si cambia parada + penalización fija ---- + if (i > 0) + { + if (!paradaUltimaBus.Codigo.Equals(codParadaIni, StringComparison.OrdinalIgnoreCase)) + { + var wt = await WalkCached( + paradaUltimaBus.Latitud, paradaUltimaBus.Longitud, + pIniTramo.Latitud, pIniTramo.Longitud); + + double distT = (wt.dist > 0) ? wt.dist + : CalcularDistanciaMetros( + paradaUltimaBus.Latitud, paradaUltimaBus.Longitud, + pIniTramo.Latitud, pIniTramo.Longitud); + + alt.DistanciaPieTransbordosAprox += distT; + + var durWalkT = EstimarDuracionPieSegundos(distT); + tCursor = AddSecondsSafe(tCursor, durWalkT); + total += durWalkT; + } + } + + // ---- Bus con horarios reales ---- + bool usoHorarios = _usarHorariosTeoricos && _horariosOk; + + if (usoHorarios) + { + var fecha = DateOnly.FromDateTime(tCursor); + var horaMin = TimeOnly.FromDateTime(tCursor); + + string lineaCod = LineaCodigoStr(v); + int itin = v.CodigoItinerario; + string sentido = NormalizarSentidoCodigo(v.Sentido?.Codigo); + + if (Horarios.TryGetNextBusSegment( + codParadaIni, codParadaFin, + lineaCod, itin, sentido, + fecha, horaMin, + out var dep, out var arr, out var _)) + { + // Espera real si llegas antes de la salida + if (dep > tCursor) + { + var wait = (dep - tCursor).TotalSeconds; + if (wait > 0) + { + tCursor = dep; + total += wait; + } + } + + // Duración real bus + var durBus = (arr - dep).TotalSeconds; + if (durBus < 0) durBus = 0; + + tCursor = AddSecondsSafe(tCursor, durBus); + total += durBus; + + paradaUltimaBus = pFinTramo; + continue; + } + // si falla el tramo, caemos al fallback geométrico + } + + // ---- Fallback (como antes) ---- + // (geometría + segundos por parada) + double distanciaTramo = 0; + { + // construir línea geom para este tramo (como haces en PintarRutaDesdeAlternativa) + double distBusTotal; + var lineasBus = RedBusServicio.ConstruirLineasBus(tramosBus, out distBusTotal); + var lineaGeom = lineasBus[i]; + + for (int p = 1; p < lineaGeom.GetLength(0); p++) + distanciaTramo += CalcularDistanciaMetros(lineaGeom[p - 1, 0], lineaGeom[p - 1, 1], lineaGeom[p, 0], lineaGeom[p, 1]); + } + + int paradasTramo = ContarParadasRecorridas(tramo); + double extraParadasSeg = paradasTramo * Math.Max(0, _segundosPorParadaBus); + + double duracionBusSegundos = EstimarDuracionBusSegundos(distanciaTramo) + extraParadasSeg; + + tCursor = AddSecondsSafe(tCursor, duracionBusSegundos); + total += duracionBusSegundos; + + paradaUltimaBus = pFinTramo; + } + + // 4) Fin bus -> destino (walk) + ct.ThrowIfCancellationRequested(); + var w2 = await WalkCached(paradaFinBus.Latitud, paradaFinBus.Longitud, _coordenadasDestino![0], _coordenadasDestino[1]); + + double distPie2 = (w2.dist > 0) ? w2.dist + : CalcularDistanciaMetros(paradaFinBus.Latitud, paradaFinBus.Longitud, _coordenadasDestino[0], _coordenadasDestino[1]); + + alt.DistanciaPieFinAprox = distPie2; + + var durPie2 = EstimarDuracionPieSegundos(distPie2); + tCursor = AddSecondsSafe(tCursor, durPie2); + total += durPie2; + + // ✅ guardar transbordos reales (para chips) + alt.NumeroTransbordosReales = CalcularTransbordosReales(alt.AlternativaBus); + + return total; + } + + /// + /// Busca ruta desde buscador. + /// + private async Task BuscarRutaDesdeBuscador() + { + if (string.IsNullOrWhiteSpace(_textoBusquedaOrigen) || + string.IsNullOrWhiteSpace(_textoBusquedaDestino)) + { + return; + } + + var po = await ResolverUbicacionBusquedaAsync(CampoBusquedaMapa.Origen); + var pd = await ResolverUbicacionBusquedaAsync(CampoBusquedaMapa.Destino); + + if (po is null || pd is null) + { + _mensajeRuta = "No he podido interpretar el origen o el destino. Prueba a seleccionar una sugerencia de la lista."; + _hayRutaValida = false; + StateHasChanged(); + return; + } + + _coordenadasOrigen = new[] { po.Latitud, po.Longitud }; + _coordenadasDestino = new[] { pd.Latitud, pd.Longitud }; + _textoBusquedaOrigen = po.TextoEntrada; + _textoBusquedaDestino = pd.TextoEntrada; + _ubicacionOrigenSeleccionada = po; + _ubicacionDestinoSeleccionada = pd; + _sugerenciasBusquedaOrigen.Clear(); + _sugerenciasBusquedaDestino.Clear(); + _campoBusquedaActivo = null; + + if (_mapa is not null) + { + await ClearRoutesJsAsync(); + try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } + + await _mapa.Geometric.DisplayPointsFromArray.deleteAllPoints(); + await SincronizarMarcadoresOrigenDestinoJsAsync(); + + if (_mostrarParadas) + await DibujarParadasAsync(); + } + + await CerrarTodasLasPreviewsAsync(); + await CerrarPopupsAsync(); + await CalcularRutaCompletaAsync(); + } + /// + /// Muestra inspector. + /// + private async Task MostrarInspectorAsync(double lat, double lon) + { + _inspectorLat = lat; + _inspectorLon = lon; + + try + { + // ------------------------- + // OpenElevation (cache primero) + // ------------------------- + var kOpen = ElevKey(lat, lon); + if (_elevCache.TryGetValue(kOpen, out var cachedOpen)) + { + _inspectorElev = cachedOpen; + } + else + { + await ObtenerElevacionesPorLoteAsync( + new List<(double lat, double lon)> { (lat, lon) }, + CancellationToken.None); + + _inspectorElev = _elevCache.TryGetValue(ElevKey(lat, lon), out var elevOpen) + ? elevOpen + : (double?)null; + } + + // ------------------------- + // IDEE (cache primero) + // ------------------------- + var kIdee = ClaveElevacionIdee(lat, lon); + if (_elevIdeeCache.TryGetValue(kIdee, out var cachedIdee)) + { + _inspectorElevIdee = cachedIdee; + } + else + { + await ObtenerElevacionesIdeePorLoteAsync( + new List<(double lat, double lon)> { (lat, lon) }, + CancellationToken.None); + + _inspectorElevIdee = _elevIdeeCache.TryGetValue(ClaveElevacionIdee(lat, lon), out var elevIdee) + ? elevIdee + : (double?)null; + } + } + catch + { + _inspectorElev = null; + _inspectorElevIdee = null; + } + + _popupInspectorVisible = true; + StateHasChanged(); + } + + + /// + /// Gestiona hacer clic en mapa. + /// + private async Task AlHacerClickEnMapa(RealTimeMap.ClicksMapArgs e) + { + var lat = e.location.latitude; + var lon = e.location.longitude; + + // Inspector (Z) sigue siendo click simple inmediato + if (_inspectorActivo) + { + await MostrarInspectorAsync(lat, lon); + return; + } + + // ¿Este click cae SOBRE una parada? + var paradaHit = await DetectarParadaHitAsync(lat, lon); + if (paradaHit is null && !_mostrarParadas) + paradaHit = await DetectarParadaDestacadaHitAsync(lat, lon); + + // ===================================================== + // 2º click (si había uno pendiente) + // ===================================================== + if (_primerClickPendiente is (double lat1, double lon1)) + { + _ctsDobleClick?.Cancel(); + _ctsDobleClick?.Dispose(); + _ctsDobleClick = null; + + _primerClickPendiente = null; + _paradaPrimerClickPendiente = null; // ✅ si era parada, anulamos popup + + // si los dos clicks son cerca => doble click => PONER PUNTO SIEMPRE + if (Cerca(lat1, lon1, lat, lon, DBLCLICK_MAX_METROS)) + { + // Si el segundo click cae en parada, ponemos el punto EXACTO en la parada + if (paradaHit is not null) + await EjecutarSeleccionOrigenDestinoAsync(paradaHit.Latitud, paradaHit.Longitud); + else + await EjecutarSeleccionOrigenDestinoAsync(lat, lon); + } + + return; + } + + // ===================================================== + // 1º click (armamos la ventana de doble click) + // ===================================================== + _primerClickPendiente = (lat, lon); + _paradaPrimerClickPendiente = paradaHit; + + _ctsDobleClick = new CancellationTokenSource(); + var ct = _ctsDobleClick.Token; + + _ = Task.Run(async () => + { + try + { + await Task.Delay(DBLCLICK_MS, ct); + + if (ct.IsCancellationRequested) return; + + // Se acabó la ventana: NO hubo doble click + var parada = _paradaPrimerClickPendiente; + + _primerClickPendiente = null; + _paradaPrimerClickPendiente = null; + + // Si era parada => click simple => mostrar popup + if (parada is not null) + { + await InvokeAsync(async () => + { + await MostrarPopupParadaAsync(parada); + }); + } + // Si NO era parada: click simple no hace nada (como querías) + } + catch { } + finally + { + if (!ct.IsCancellationRequested) + { + // por si acaso + _primerClickPendiente = null; + _paradaPrimerClickPendiente = null; + } + } + }); + } + + /// + /// Gestiona seleccion origen destino. + /// + private async Task EjecutarSeleccionOrigenDestinoAsync(double lat, double lon) + { + if (_coordenadasOrigen is null) + { + _coordenadasOrigen = new[] { lat, lon }; + + await SincronizarMarcadoresOrigenDestinoJsAsync(); + + _mensajeRuta = string.Empty; + _hayRutaValida = false; + StateHasChanged(); + return; + } + + if (_coordenadasDestino is null) + { + _coordenadasDestino = new[] { lat, lon }; + + await SincronizarMarcadoresOrigenDestinoJsAsync(); + + await CalcularRutaCompletaAsync(); + return; + } + + _coordenadasDestino = new[] { lat, lon }; + + if (_mapa is not null) + { + try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } + await ClearRoutesJsAsync(); + await SincronizarMarcadoresOrigenDestinoJsAsync(); + } + + await CalcularRutaCompletaAsync(); + } + + /// + /// Reacciona al arrastre de origen o destino desde el mapa. + /// + [JSInvokable] + public async Task OnEndpointDragged(string kind, double lat, double lon) + { + await InvokeAsync(async () => + { + if (string.Equals(kind, "origin", StringComparison.OrdinalIgnoreCase)) + { + _coordenadasOrigen = new[] { lat, lon }; + _ubicacionOrigenSeleccionada = null; + _sugerenciasBusquedaOrigen.Clear(); + } + else if (string.Equals(kind, "destination", StringComparison.OrdinalIgnoreCase)) + { + _coordenadasDestino = new[] { lat, lon }; + _ubicacionDestinoSeleccionada = null; + _sugerenciasBusquedaDestino.Clear(); + } + else + { + return; + } + + await SincronizarMarcadoresOrigenDestinoJsAsync(); + await CerrarTodasLasPreviewsAsync(); + await CerrarPopupsAsync(); + + if (_mapa is not null) + { + try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } + await ClearRoutesJsAsync(); + } + + if (_coordenadasOrigen is not null && _coordenadasDestino is not null) + { + await CalcularRutaCompletaAsync(); + } + else + { + ResetEstadoRuta(limpiarOpciones: true); + _mensajeRuta = string.Empty; + StateHasChanged(); + } + }); + } + + + + + // ========================================================= + // Cálculo completo (optimizado + estricto real) + // ========================================================= + + /// + /// Calcula ruta completa. + /// + private async Task CalcularRutaCompletaAsync() + { + if (_mapa is null || _coordenadasOrigen is null || _coordenadasDestino is null) + return; + + var cronometroCalculo = System.Diagnostics.Stopwatch.StartNew(); + + await CerrarTodasLasPreviewsAsync(); + + _ctsCalculo?.Cancel(); + _ctsCalculo = new CancellationTokenSource(); + var ct = _ctsCalculo.Token; + var omitirRegistroHistorialEsteCalculo = _omitirRegistroHistorialEnSiguienteCalculo; + _omitirRegistroHistorialEnSiguienteCalculo = false; + + int versionActual = Interlocked.Increment(ref _versionCalculo); + IniciarNuevaVersionPintadoRuta(); + + ResetEstadoRuta(limpiarOpciones: true); + + _mensajeRuta = "Calculando ruta…"; + StateHasChanged(); + + await ClearRoutesJsAsync(); + try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } + + try + { + ct.ThrowIfCancellationRequested(); + if (versionActual != _versionCalculo) + throw new OperationCanceledException(); + + var salidaBase = AhoraLocal(); + _ultimaHoraReferenciaRuta = salidaBase; + + var parametros = new ParametrosPlanificadorRuta + { + VelocidadPieKmH = _velocidadPieKmH, + FactorPenalizacionDistanciaPie = _factorPenalizacionDistanciaPie, + ParametroPenalizacionDistanciaPieMetros = _parametroPenalizacionDistanciaPieMetros, + PesoTransbordoRankingMinutos = _pesoTransbordoRankingMinutos, + VelocidadBusKmH = _velocidadBusKmH, + MinutosPorTransbordo = _minutosTransbordo, + MargenTransbordoSegundos = Math.Clamp(_margenTransbordoSegundos, 0, 1800), + SegundosPorParadaBus = _segundosPorParadaBus, + RadioParadasCandidatasMetros = _radioCandidatasMetros, + MaxParadasCandidatasPorLado = _maxCandidatasPorLado, + UsarOsrmParaRanking = _usarOsrmParaRanking, + ActivarRangoLineas = _activarRangoLineas, + LineaMin = _lineaMin, + LineaMax = _lineaMax, + LineasPermitidas = ObtenerLineasPermitidasSeleccionadas().ToList(), + ActivarRangoItinerarios = _activarRangoItinerarios, + ItinerarioMin = _itinerarioMin, + ItinerarioMax = _itinerarioMax, + ActivarRangoTransbordos = _activarRangoTransbordos, + MinTransbordosPermitidos = _minTransbordosPermitidos, + MaxTransbordosPermitidos = _maxTransbordosPermitidos, + MaxParesEvaluados = _activarRangoItinerarios ? 280 : 140, + MaxAlternativasBusPorPar = (_activarRangoTransbordos && _minTransbordosPermitidos > 0) + ? (_activarRangoItinerarios ? 8 : 6) + : (_activarRangoItinerarios ? 4 : 3), + UsarHorariosReales = _usarHorariosTeoricos, + FechaSalida = DateOnly.FromDateTime(salidaBase), + HoraSalida = TimeOnly.FromDateTime(salidaBase), + DescartarAlternativasSinHorario = false, + MaxEsperaInicialMinutos = 90, + MaxEsperaTransbordoMinutos = 60, + MaxEsperaTotalMinutos = 120, + PreferenciaEtiquetaRuta = _preferenciaEtiquetaRuta + }; + + var resultado = await PlanificadorRutasServicio.CalcularAsync( + _coordenadasOrigen, + _coordenadasDestino, + parametros, + ct); + + ct.ThrowIfCancellationRequested(); + if (versionActual != _versionCalculo) + throw new OperationCanceledException(); + + var alternativas = resultado.Alternativas; + var filtradas = alternativas + .Where(a => a.EsSoloAPie || a.AlternativaBus is null || !PrimerTramoBusEsBasura(a.AlternativaBus, _coordenadasOrigen, _coordenadasDestino)) + .ToList(); + + if (filtradas.Count == 0 && alternativas.Count > 0) + filtradas = alternativas; + + _opcionesRuta.Clear(); + _opcionesRuta.AddRange(filtradas); + OrdenarOpcionesRutaSegunCriterio(); + + if (!resultado.Ok || _opcionesRuta.Count == 0) + { + _mensajeRuta = string.IsNullOrWhiteSpace(resultado.Mensaje) + ? "No hay rutas disponibles para esta combinacion." + : resultado.Mensaje; + _hayRutaValida = false; + try + { + await ActualizarAlturasResumenRutaAsync(CancellationToken.None); + } + catch + { + } + _tiempoUltimoCalculoRuta = cronometroCalculo.Elapsed; + StateHasChanged(); + return; + } + + var idxPreferida = BuscarIndicePreferidoPorCriterio(_criterioPrioritarioRuta); + if (idxPreferida < 0 || idxPreferida >= _opcionesRuta.Count) + idxPreferida = 0; + + _indiceAlternativaSeleccionada = idxPreferida; + ResetEstadoRuta(limpiarOpciones: false, limpiarMensaje: false); + _mensajeRuta = "Representando ruta seleccionada..."; + StateHasChanged(); + await Task.Yield(); + await PintarOpcionRutaCompleta(_opcionesRuta[idxPreferida]); + if (!omitirRegistroHistorialEsteCalculo) + { + await RegistrarEscenarioActualEnHistorialLocalAsync(); + } + await ActualizarAlturasResumenRutaAsync(CancellationToken.None); + _tiempoUltimoCalculoRuta = cronometroCalculo.Elapsed; + StateHasChanged(); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _mensajeRuta = $"Error calculando ruta: {ex.Message}"; + _hayRutaValida = false; + try + { + await ActualizarAlturasResumenRutaAsync(CancellationToken.None); + } + catch + { + } + _tiempoUltimoCalculoRuta = cronometroCalculo.Elapsed; + StateHasChanged(); + } + } + + + + + + /// + /// Gestiona alternativa solo a pie. + /// + private static AlternativaRuta ClonarAlternativaSoloAPie(AlternativaRuta a) + { + // Copia “suficiente”: referencia a la línea (geometría) se puede compartir sin problema. + // Lo importante es NO compartir la misma instancia porque cambiar Etiqueta afectaría al de abajo. + return new AlternativaRuta + { + EsSoloAPie = true, + ProveedorRuta = a.ProveedorRuta, + + LineaSoloAPie = a.LineaSoloAPie, + DistanciaSoloAPie = a.DistanciaSoloAPie, + DuracionSoloAPie = a.DuracionSoloAPie, + + DuracionTotalAproxSegundos = a.DuracionTotalAproxSegundos, + PuntuacionCoste = a.PuntuacionCoste, + + // mantenemos etiqueta original (luego la sobrescribimos a "Más rápida" arriba) + Etiqueta = a.Etiqueta + }; + } + + + // ========================================================= + // Pintado + // ========================================================= + +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs new file mode 100644 index 0000000..2166953 --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs @@ -0,0 +1,844 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using LeafletForBlazor; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using RutasDBUS.Modelos.Planificacion; +using RutasDBUS.Modelos.RedBus; +using RutasDBUS.Servicios; +using RutasDBUS.Servicios.Caminata; +using RutasDBUS.Servicios.Horarios; +using RutasDBUS.Servicios.Planificacion; +using RutasDBUS.Servicios.RedBus; + +namespace RutasDBUS.Components.Pages; + +public partial class PlanificadorRutas : ComponentBase +{ + /// + /// Asegura que elevacion tramo este disponible. + /// + private async Task EnsureElevacionTramoAsync(DetalleTramoRuta tramo) + { + if (tramo is null) return; + + // Si ya está calculado, nada + if (tramo.MuestrasElevacion is not null && tramo.MuestrasElevacion.Count > 0) return; + + // Necesitamos la geometría + if (tramo.Linea is null || tramo.Linea.GetLength(0) <= 0) return; + + if (tramo.ElevacionCargando) return; + + tramo.ElevacionCargando = true; + StateHasChanged(); + + try + { + var ctNone = CancellationToken.None; + + // 1) Samples bajo demanda, priorizando G y dejando O como best-effort + var samples = await ConstruirMuestrasElevacionAsync(tramo.Linea, ctNone); + tramo.MuestrasElevacion = samples; + + // 2) Resumen O solo con lo que ya haya llegado, sin bloquear más la UI + var muestrasOpen = samples + .Where(x => x.Elevacion is not null) + .Select(x => x.Elevacion!.Value) + .ToList(); + + tramo.ElevInicio = samples.FirstOrDefault()?.Elevacion; + tramo.ElevFin = samples.LastOrDefault()?.Elevacion; + tramo.ElevMin = muestrasOpen.Count > 0 ? muestrasOpen.Min() : null; + tramo.ElevMax = muestrasOpen.Count > 0 ? muestrasOpen.Max() : null; + } + catch + { + // silencioso: la elevación es informativa + tramo.MuestrasElevacion ??= new List(); + } + finally + { + tramo.ElevacionCargando = false; + StateHasChanged(); + } + } + + + + + + /// + /// Pinta opcion ruta completa. + /// + private async Task PintarOpcionRutaCompleta(AlternativaRuta opcion) + { + var versionPintado = IniciarNuevaVersionPintadoRuta(); + var mensajeAnterior = _mensajeRuta; + + try + { + await ClearRoutesJsAsync(); + ThrowIfPintadoObsoleto(versionPintado); + + if (opcion.EsSoloAPie) + { + await PintarRutaSoloAPie(opcion.LineaSoloAPie, opcion.DistanciaSoloAPie, versionPintado: versionPintado); + } + else + { + await PintarRutaDesdeAlternativa(opcion.OrigenParada, opcion.DestinoParada, opcion.AlternativaBus!, versionPintado); + ThrowIfPintadoObsoleto(versionPintado); + await PintarParadasSolucionSeleccionadaAsync(opcion, versionPintado); + } + + ThrowIfPintadoObsoleto(versionPintado); + await CerrarPopupsAsync(); + + ThrowIfPintadoObsoleto(versionPintado); + await FitRoutesJsAsync(); + ThrowIfPintadoObsoleto(versionPintado); + StateHasChanged(); + } + catch (OperationCanceledException) + { + if (string.IsNullOrWhiteSpace(_mensajeRuta)) + _mensajeRuta = mensajeAnterior; + + StateHasChanged(); + } + catch (Exception ex) + { + _mensajeRuta = $"No se ha podido representar la ruta seleccionada: {ex.Message}"; + _hayRutaValida = false; + StateHasChanged(); + } + } + + /// + /// Pinta las paradas recorridas por la solución seleccionada con el color de cada tramo de bus. + /// + private async Task PintarParadasSolucionSeleccionadaAsync(AlternativaRuta opcion, int versionPintado) + { + await ClearRouteStopsJsAsync(); + LimpiarParadasRutaVisibles(); + ThrowIfPintadoObsoleto(versionPintado); + + var tramos = opcion.AlternativaBus?.Tramos; + if (opcion.EsSoloAPie || tramos is null || tramos.Count == 0) + return; + + for (var indice = 0; indice < tramos.Count; indice++) + { + var tramo = tramos[indice]; + if (tramo?.Variante is null) + continue; + + var paradasTramo = BuildParadasForTramoBus(tramo); + if (paradasTramo.Count == 0) + continue; + + var stops = BuildStopsDesdeParadas(paradasTramo); + + var id = $"route_stops_{indice}_{tramo.Variante.Linea.Codigo}_{tramo.Variante.CodigoItinerario}_{NormalizarSentidoCodigo(tramo.Variante.Sentido?.Codigo)}"; + var color = ColorCssDesdeVariante(tramo.Variante); + + await DrawRouteStopsJsAsync(id, stops, color, radius: 8, weight: 2, opacity: 1.0, fillOpacity: 1.0); + RegistrarParadasRutaVisibles(id, paradasTramo); + ThrowIfPintadoObsoleto(versionPintado); + } + } + + /// + /// Pinta ruta solo a pie. + /// + private async Task PintarRutaSoloAPie(double[,]? lineaPre = null, double distPre = 0, int versionPintado = 0) + { + if (_mapa is null || _coordenadasOrigen is null || _coordenadasDestino is null) return; + + ResetEstadoRuta(limpiarMensaje: false); + ThrowIfPintadoObsoleto(versionPintado); + + double[,]? linea = lineaPre; + double distancia = distPre; + + if (linea is null) + { + var w = await WalkCached(_coordenadasOrigen[0], _coordenadasOrigen[1], _coordenadasDestino[0], _coordenadasDestino[1]); + linea = w.line; + distancia = w.dist; + ThrowIfPintadoObsoleto(versionPintado); + } + + if (linea is null) + { + _mensajeRuta = "No he podido calcular una ruta a pie entre los puntos seleccionados."; + _hayRutaValida = false; + StateHasChanged(); + return; + } + + // ✅ duración por velocidad configurada + double duracion = (distancia > 0) ? EstimarDuracionPieSegundos(distancia) : 0; + + _distanciaAPieInicio = distancia; + _duracionTotalSegundos = duracion; + + // ✅ Guardamos el tramo andando SIN calcular elevación + _tramosRuta.Add(new DetalleTramoRuta + { + Tipo = "A pie", + Descripcion = CapitalizarPalabrasClave("Camina desde tu punto de origen hasta tu destino final."), + DistanciaMetros = distancia, + DuracionSegundos = duracion, + + // clave para lazy elevation + Linea = linea, + + // elevación aún no + ElevInicio = null, + ElevFin = null, + ElevMin = null, + ElevMax = null, + MuestrasElevacion = null + }); + + ThrowIfPintadoObsoleto(versionPintado); + await DrawRouteJsAsync("walk_full", linea, "#dc2626", 5, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + ThrowIfPintadoObsoleto(versionPintado); + + _mensajeRuta = CapitalizarPalabrasClave( + $"Puedes realizar todo el recorrido andando: {FormatearDistancia(distancia)} aproximadamente." + ); + + _hayRutaValida = true; + ThrowIfPintadoObsoleto(versionPintado); + StateHasChanged(); + } + + + /// + /// Pinta ruta desde alternativa. + /// + private async Task PintarRutaDesdeAlternativa( + Parada paradaOrigenCandidata, + Parada paradaDestinoCandidata, + RutaBusAlternativa alternativa, + int versionPintado) + { + if (_mapa is null || _coordenadasOrigen is null || _coordenadasDestino is null) return; + + ResetEstadoRuta(limpiarMensaje: false); + ThrowIfPintadoObsoleto(versionPintado); + + var tramosBus = alternativa.Tramos; + var lineasBus = RedBusServicio.ConstruirLineasBus(tramosBus, out var distanciaBusTotal); + + Parada paradaInicioBus = paradaOrigenCandidata; + Parada paradaFinBus = paradaDestinoCandidata; + + if (tramosBus.Count > 0 && lineasBus.Count > 0) + { + var primerTramo = tramosBus.First(); + var ultimoTramo = tramosBus.Last(); + paradaInicioBus = ResolverParadaFisica( + primerTramo.CodigoParadaInicio, + ObtenerPrimerPunto(lineasBus[0]), + paradaOrigenCandidata); + paradaFinBus = ResolverParadaFisica( + ultimoTramo.CodigoParadaFin, + ObtenerUltimoPunto(lineasBus[^1]), + paradaDestinoCandidata); + } + + _paradaOrigenSeleccionada = paradaInicioBus; + _paradaDestinoSeleccionada = paradaFinBus; + + _numeroTransbordos = CalcularTransbordosReales(alternativa); + + _primerTransbordoseleccionado = null; + if (alternativa.CodigosParadasTransbordo is not null && alternativa.CodigosParadasTransbordo.Count > 0) + { + var codigoPrimera = alternativa.CodigosParadasTransbordo[0]; + _primerTransbordoseleccionado = ResolverParadaFisica(codigoPrimera); + } + + _distanciaAPieInicio = 0; + _distanciaAPieFin = 0; + _distanciaEnBus = 0; + _duracionTotalSegundos = 0; + + var now = AhoraLocal(); + var tCursor = now; + + if (_usarHorariosTeoricos) + { + await EnsureHorariosLoadedAsync(); + ThrowIfPintadoObsoleto(versionPintado); + } + + // ----------------------- + // Pie 1 + // ----------------------- + var w1 = await WalkCached(_coordenadasOrigen[0], _coordenadasOrigen[1], paradaInicioBus.Latitud, paradaInicioBus.Longitud); + ThrowIfPintadoObsoleto(versionPintado); + if (w1.line is not null) + { + _distanciaAPieInicio = w1.dist; + + var durPie1 = (w1.dist > 0) ? EstimarDuracionPieSegundos(w1.dist) : 0; + var tIni = tCursor; + tCursor = AddSecondsSafe(tCursor, durPie1); + + _duracionTotalSegundos += durPie1; + + _tramosRuta.Add(new DetalleTramoRuta + { + Tipo = "A pie", + Descripcion = CapitalizarPalabrasClave( + $"{HHmm(tIni)} -> {HHmm(tCursor)} - Desde tu punto de origen hasta la Parada \"{paradaInicioBus.Nombre}\" ({CodigoParadaFormateado(paradaInicioBus.Codigo)})." + ), + DistanciaMetros = w1.dist, + DuracionSegundos = durPie1, + Linea = w1.line + }); + + ThrowIfPintadoObsoleto(versionPintado); + await DrawRouteJsAsync("walk_1", w1.line, "#dc2626", 4, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + ThrowIfPintadoObsoleto(versionPintado); + } + + // ----------------------- + // Bus + // ----------------------- + _distanciaEnBus = distanciaBusTotal; + var lineasUsadasVisuales = new List(Math.Max(1, tramosBus.Count)); + + Parada paradaUltimaBus = paradaInicioBus; + var ultimoPuntoDibujado = w1.line is not null + ? ObtenerUltimoPunto(w1.line) + : new[] { paradaInicioBus.Latitud, paradaInicioBus.Longitud }; + + for (int i = 0; i < tramosBus.Count; i++) + { + var tramo = tramosBus[i]; + var esTransbordoReal = i > 0 && UtilidadesTransitoDbus.EsTransbordoReal(tramosBus[i - 1], tramo); + var infoVariante = tramo.Variante; + var lineaGeom = lineasBus[i]; + + string codParadaIni = infoVariante.CodigosParadas[tramo.IndiceInicio]; + string codParadaFin = infoVariante.CodigosParadas[tramo.IndiceFin]; + + var puntoInicioTramo = ObtenerPrimerPunto(lineaGeom); + var puntoFinTramo = ObtenerUltimoPunto(lineaGeom); + + var pIniTramo = ResolverParadaFisica(codParadaIni, puntoInicioTramo, i == 0 ? paradaInicioBus : paradaUltimaBus); + var pFinTramo = ResolverParadaFisica(codParadaFin, puntoFinTramo, i == tramosBus.Count - 1 ? paradaFinBus : paradaUltimaBus); + var distanciaUnion = CalcularDistanciaEntrePuntos(ultimoPuntoDibujado, puntoInicioTramo); + + if (ultimoPuntoDibujado is not null && puntoInicioTramo is not null && distanciaUnion > 0) + { + if (distanciaUnion <= UmbralCosturaVisualMetros) + { + EstablecerPrimerPunto(lineaGeom, ultimoPuntoDibujado); + puntoInicioTramo = ultimoPuntoDibujado; + } + else if (!esTransbordoReal && i > 0 && distanciaUnion <= UmbralUnionPeatonalAutomaticaMetros) + { + EstablecerPrimerPunto(lineaGeom, ultimoPuntoDibujado); + puntoInicioTramo = ultimoPuntoDibujado; + } + else if ((i == 0 || esTransbordoReal) && distanciaUnion <= UmbralUnionPeatonalAutomaticaMetros) + { + var union = await WalkCached( + ultimoPuntoDibujado[0], + ultimoPuntoDibujado[1], + puntoInicioTramo[0], + puntoInicioTramo[1]); + ThrowIfPintadoObsoleto(versionPintado); + + var lineaUnion = union.line ?? ConstruirLineaDirecta(ultimoPuntoDibujado, puntoInicioTramo); + var distanciaUnionReal = union.dist > 0 ? union.dist : distanciaUnion; + var duracionUnion = EstimarDuracionPieSegundos(distanciaUnionReal); + var tIniWalkUnion = tCursor; + tCursor = AddSecondsSafe(tCursor, duracionUnion); + + _duracionTotalSegundos += duracionUnion; + if (i == 0) + _distanciaAPieInicio += distanciaUnionReal; + + _tramosRuta.Add(new DetalleTramoRuta + { + Tipo = i == 0 ? "A pie (Ajuste acceso)" : "A pie (Transbordo)", + Descripcion = CapitalizarPalabrasClave( + i == 0 + ? $"{HHmm(tIniWalkUnion)} -> {HHmm(tCursor)} - Camina desde el final del acceso peatonal hasta la Parada \"{pIniTramo.Nombre}\" ({CodigoParadaFormateado(pIniTramo.Codigo)})." + : $"{HHmm(tIniWalkUnion)} -> {HHmm(tCursor)} - Camina desde la Parada \"{paradaUltimaBus.Nombre}\" ({CodigoParadaFormateado(paradaUltimaBus.Codigo)}) hasta \"{pIniTramo.Nombre}\" ({CodigoParadaFormateado(pIniTramo.Codigo)})." + ), + DistanciaMetros = distanciaUnionReal, + DuracionSegundos = duracionUnion, + Linea = lineaUnion + }); + + ThrowIfPintadoObsoleto(versionPintado); + await DrawRouteJsAsync($"walk_union_{i}", lineaUnion, "#dc2626", 4, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + ThrowIfPintadoObsoleto(versionPintado); + ultimoPuntoDibujado = ObtenerUltimoPunto(lineaUnion); + } + } + + var distanciaTransferenciaPendiente = CalcularDistanciaEntrePuntos(ultimoPuntoDibujado, puntoInicioTramo); + if (i > 0 && + esTransbordoReal && + ultimoPuntoDibujado is not null && + puntoInicioTramo is not null && + distanciaTransferenciaPendiente > UmbralUnionPeatonalAutomaticaMetros) + { + _mensajeRuta = "La alternativa seleccionada requiere un transbordo a pie demasiado largo. Recalcula la ruta para elegir otra combinacion."; + StateHasChanged(); + return; + } + + if (i > 0 && + esTransbordoReal && + ultimoPuntoDibujado is not null && + puntoInicioTramo is not null && + distanciaTransferenciaPendiente > UmbralCosturaVisualMetros) + { + var wt = await WalkCached( + ultimoPuntoDibujado[0], + ultimoPuntoDibujado[1], + puntoInicioTramo[0], + puntoInicioTramo[1]); + ThrowIfPintadoObsoleto(versionPintado); + + var lineaTransferencia = wt.line ?? ConstruirLineaDirecta(ultimoPuntoDibujado, puntoInicioTramo); + var distanciaTransferenciaReal = wt.dist > 0 ? wt.dist : distanciaTransferenciaPendiente; + var duracionTransferencia = EstimarDuracionPieSegundos(distanciaTransferenciaReal); + var tIniWalkTransfer = tCursor; + tCursor = AddSecondsSafe(tCursor, duracionTransferencia); + + _duracionTotalSegundos += duracionTransferencia; + + _tramosRuta.Add(new DetalleTramoRuta + { + Tipo = "A pie (Transbordo)", + Descripcion = CapitalizarPalabrasClave( + $"{HHmm(tIniWalkTransfer)} -> {HHmm(tCursor)} - Camina desde la Parada \"{paradaUltimaBus.Nombre}\" ({CodigoParadaFormateado(paradaUltimaBus.Codigo)}) hasta \"{pIniTramo.Nombre}\" ({CodigoParadaFormateado(pIniTramo.Codigo)})." + ), + DistanciaMetros = distanciaTransferenciaReal, + DuracionSegundos = duracionTransferencia, + Linea = lineaTransferencia + }); + + ThrowIfPintadoObsoleto(versionPintado); + await DrawRouteJsAsync($"walk_transfer_real_{i}", lineaTransferencia, "#dc2626", 4, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + ThrowIfPintadoObsoleto(versionPintado); + + ultimoPuntoDibujado = ObtenerUltimoPunto(lineaTransferencia); + if (ultimoPuntoDibujado is not null) + { + EstablecerPrimerPunto(lineaGeom, ultimoPuntoDibujado); + puntoInicioTramo = ultimoPuntoDibujado; + } + } + + // ----------------------- + // Transbordo andando + // ----------------------- + if (false && i > 0 && ultimoPuntoDibujado is null) + { + if (!paradaUltimaBus.Codigo.Equals(codParadaIni, StringComparison.OrdinalIgnoreCase)) + { + var wt = await WalkCached( + paradaUltimaBus.Latitud, paradaUltimaBus.Longitud, + pIniTramo.Latitud, pIniTramo.Longitud); + ThrowIfPintadoObsoleto(versionPintado); + + if (wt.line is not null) + { + var durTrans = (wt.dist > 0) ? EstimarDuracionPieSegundos(wt.dist) : 0; + var tIniWalk = tCursor; + tCursor = AddSecondsSafe(tCursor, durTrans); + + _duracionTotalSegundos += durTrans; + + _tramosRuta.Add(new DetalleTramoRuta + { + Tipo = "A pie (Transbordo)", + Descripcion = CapitalizarPalabrasClave( + $"{HHmm(tIniWalk)} -> {HHmm(tCursor)} - Camina desde la Parada \"{paradaUltimaBus.Nombre}\" ({CodigoParadaFormateado(paradaUltimaBus.Codigo)}) hasta \"{pIniTramo.Nombre}\" ({CodigoParadaFormateado(pIniTramo.Codigo)})." + ), + DistanciaMetros = wt.dist, + DuracionSegundos = durTrans, + Linea = wt.line + }); + + ThrowIfPintadoObsoleto(versionPintado); + await DrawRouteJsAsync($"walk_transfer_{i}", wt.line, "#dc2626", 4, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + ThrowIfPintadoObsoleto(versionPintado); + } + } + } + + // ----------------------- + // Intentar horario real + // ----------------------- + string codLineaItinSentido = CodigoLineaItinerarioSentido(tramo); + string codLineaItinSentidoMostrado = codLineaItinSentido; + string lineaCod = LineaCodigoStr(infoVariante); + int itin = infoVariante.CodigoItinerario; + string sentido = NormalizarSentidoCodigo(infoVariante.Sentido?.Codigo); + + DateTime? depT = null; + DateTime? arrT = null; + + if (_usarHorariosTeoricos && _horariosOk) + { + var primeraSalidaAceptable = esTransbordoReal + ? tCursor.AddSeconds(Math.Clamp(_margenTransbordoSegundos, 0, 1800)) + : tCursor; + var fecha = DateOnly.FromDateTime(primeraSalidaAceptable); + var horaMin = TimeOnly.FromDateTime(primeraSalidaAceptable); + + if (Horarios.TryGetNextBusSegment( + codParadaIni, + codParadaFin, + lineaCod, + itin, + sentido, + fecha, + horaMin, + out var dep, + out var arr, + out var expIdReal)) + { + depT = dep; + arrT = arr; + + if (!string.IsNullOrWhiteSpace(expIdReal)) + { + var codigoReal = Horarios.GetLineaItinerarioSentidoText(expIdReal, lineaCod); + if (!string.IsNullOrWhiteSpace(codigoReal) && !codigoReal.Contains('?')) + codLineaItinSentidoMostrado = codigoReal; + } + } + } + + // ----------------------- + // Si hay horario real: usarlo + // Si no: NO inventar horas + // ----------------------- + if (depT is not null && arrT is not null) + { + void AgregarContinuidadCircularEnBus(DateTime inicio, DateTime fin, double duracionSegundos) + { + _tramosRuta.Add(new DetalleTramoRuta + { + Tipo = duracionSegundos > 1 ? "Espera en bus" : "Continuidad en bus", + Descripcion = CapitalizarPalabrasClave( + $"{HHmm(inicio)} -> {HHmm(fin)} - continuidad circular de la linea {codLineaItinSentidoMostrado}; Parada \"{pIniTramo.Nombre}\" ({CodigoParadaFormateado(pIniTramo.Codigo)})." + ), + DistanciaMetros = 0, + DuracionSegundos = Math.Max(0, duracionSegundos) + }); + } + + if (depT.Value > tCursor) + { + var wait = (depT.Value - tCursor).TotalSeconds; + if (wait > 1) + { + var tIniWait = tCursor; + tCursor = depT.Value; + var esperaEnBus = i > 0 && !esTransbordoReal; + + _duracionTotalSegundos += wait; + + if (esperaEnBus) + { + AgregarContinuidadCircularEnBus(tIniWait, tCursor, wait); + } + else + { + _tramosRuta.Add(new DetalleTramoRuta + { + Tipo = "Espera", + Descripcion = CapitalizarPalabrasClave( + $"{HHmm(tIniWait)} -> {HHmm(tCursor)} - En \"{pIniTramo.Nombre}\" ({CodigoParadaFormateado(pIniTramo.Codigo)})." + ), + DistanciaMetros = 0, + DuracionSegundos = wait + }); + } + } + } + else if (i > 0 && !esTransbordoReal && Math.Abs((depT.Value - tCursor).TotalSeconds) <= 1) + { + AgregarContinuidadCircularEnBus(tCursor, tCursor, 0); + } + + var durBus = (arrT.Value - depT.Value).TotalSeconds; + if (durBus < 0) durBus = 0; + + _duracionTotalSegundos += durBus; + tCursor = arrT.Value; + + _tramosRuta.Add(new DetalleTramoRuta + { + Tipo = "Bus", + Descripcion = CapitalizarPalabrasClave( + $"{HHmm(depT.Value)} -> {HHmm(arrT.Value)} - Linea {codLineaItinSentidoMostrado} ({infoVariante.Linea.Nombre})." + ), + DistanciaMetros = 0, + DuracionSegundos = durBus, + VarianteBus = infoVariante + }); + } + else + { + double distanciaTramo = 0; + for (int p = 1; p < lineaGeom.GetLength(0); p++) + { + distanciaTramo += CalcularDistanciaMetros( + lineaGeom[p - 1, 0], lineaGeom[p - 1, 1], + lineaGeom[p, 0], lineaGeom[p, 1]); + } + + int paradasTramo = ContarParadasRecorridas(tramo); + double extraParadasSeg = paradasTramo * Math.Max(0, _segundosPorParadaBus); + double duracionBusSegundos = EstimarDuracionBusSegundos(distanciaTramo) + extraParadasSeg; + + _duracionTotalSegundos += duracionBusSegundos; + tCursor = AddSecondsSafe(tCursor, duracionBusSegundos); + + _tramosRuta.Add(new DetalleTramoRuta + { + Tipo = "Bus", + Descripcion = CapitalizarPalabrasClave( + $"Linea {codLineaItinSentidoMostrado} ({infoVariante.Linea.Nombre})." + ), + DistanciaMetros = distanciaTramo, + DuracionSegundos = duracionBusSegundos, + VarianteBus = infoVariante + }); + } + + lineasUsadasVisuales.Add(codLineaItinSentidoMostrado); + + string colorCss = RedBusServicio.ConvertirColorArgbACss(infoVariante.Linea.ColorArgb); + var fullId = $"full_{infoVariante.Linea.Codigo}_{infoVariante.CodigoItinerario}_{NormalizarSentidoCodigo(infoVariante.Sentido?.Codigo)}_{infoVariante.Variante.Codigo}_{i}_{tramo.IndiceInicio}_{tramo.IndiceFin}"; + var dashBus = string.Equals(NormalizarSentidoCodigo(infoVariante.Sentido?.Codigo), "V", StringComparison.OrdinalIgnoreCase) + ? DashArrayBusVuelta + : null; + ThrowIfPintadoObsoleto(versionPintado); + await DrawRouteJsAsync(fullId, lineaGeom, colorCss, 5, 0.9, dashArray: dashBus, arrowEnd: true); + ThrowIfPintadoObsoleto(versionPintado); + + paradaUltimaBus = pFinTramo; + ultimoPuntoDibujado = ObtenerUltimoPunto(lineaGeom) ?? new[] { pFinTramo.Latitud, pFinTramo.Longitud }; + } + + // ----------------------- + // Pie 2 + // ----------------------- + var w2 = await WalkCached(paradaFinBus.Latitud, paradaFinBus.Longitud, _coordenadasDestino[0], _coordenadasDestino[1]); + ThrowIfPintadoObsoleto(versionPintado); + + if (w2.line is not null) + { + var inicioPieFinal = ObtenerPrimerPunto(w2.line); + var distanciaUnionFinal = CalcularDistanciaEntrePuntos(ultimoPuntoDibujado, inicioPieFinal); + if (ultimoPuntoDibujado is not null && inicioPieFinal is not null && distanciaUnionFinal > 0) + { + if (distanciaUnionFinal <= UmbralCosturaVisualMetros) + { + EstablecerPrimerPunto(w2.line, ultimoPuntoDibujado); + } + else if (distanciaUnionFinal <= UmbralUnionPeatonalAutomaticaMetros) + { + var unionFinal = await WalkCached( + ultimoPuntoDibujado[0], + ultimoPuntoDibujado[1], + inicioPieFinal[0], + inicioPieFinal[1]); + ThrowIfPintadoObsoleto(versionPintado); + + var lineaUnionFinal = unionFinal.line ?? ConstruirLineaDirecta(ultimoPuntoDibujado, inicioPieFinal); + var distanciaUnionFinalReal = unionFinal.dist > 0 ? unionFinal.dist : distanciaUnionFinal; + var duracionUnionFinal = EstimarDuracionPieSegundos(distanciaUnionFinalReal); + var tIniUnionFinal = tCursor; + tCursor = AddSecondsSafe(tCursor, duracionUnionFinal); + + _duracionTotalSegundos += duracionUnionFinal; + _distanciaAPieFin += distanciaUnionFinalReal; + + _tramosRuta.Add(new DetalleTramoRuta + { + Tipo = "A pie (Ajuste salida)", + Descripcion = CapitalizarPalabrasClave( + $"{HHmm(tIniUnionFinal)} -> {HHmm(tCursor)} - Camina desde el punto real de bajada hasta la Parada \"{paradaFinBus.Nombre}\" ({CodigoParadaFormateado(paradaFinBus.Codigo)})." + ), + DistanciaMetros = distanciaUnionFinalReal, + DuracionSegundos = duracionUnionFinal, + Linea = lineaUnionFinal + }); + + ThrowIfPintadoObsoleto(versionPintado); + await DrawRouteJsAsync("walk_union_final", lineaUnionFinal, "#dc2626", 4, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + ThrowIfPintadoObsoleto(versionPintado); + EstablecerPrimerPunto(w2.line, ObtenerUltimoPunto(lineaUnionFinal)); + } + } + + if (w2.dist > 0) + _distanciaAPieFin += w2.dist; + + var durPie2 = (w2.dist > 0) ? EstimarDuracionPieSegundos(w2.dist) : 0; + var tIni2 = tCursor; + tCursor = AddSecondsSafe(tCursor, durPie2); + + _duracionTotalSegundos += durPie2; + + _tramosRuta.Add(new DetalleTramoRuta + { + Tipo = "A pie", + Descripcion = CapitalizarPalabrasClave( + $"{HHmm(tIni2)} -> {HHmm(tCursor)} - Desde la Parada \"{paradaFinBus.Nombre}\" ({CodigoParadaFormateado(paradaFinBus.Codigo)}) hasta tu destino." + ), + DistanciaMetros = w2.dist, + DuracionSegundos = durPie2, + Linea = w2.line + }); + + ThrowIfPintadoObsoleto(versionPintado); + await DrawRouteJsAsync("walk_2", w2.line, "#dc2626", 4, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + ThrowIfPintadoObsoleto(versionPintado); + } + + var lineasUsadas = lineasUsadasVisuales.Count > 0 + ? CompactarCodigosLineaConsecutivos(lineasUsadasVisuales) + : CompactarCodigosLineaConsecutivos(tramosBus.Select(t => CodigoLineaItinerarioSentido(t))); + + string txtLineas = (lineasUsadas.Count == 0) + ? "" + : $" Lineas: {string.Join(", ", lineasUsadas)}."; + + int tramosCount = tramosBus?.Count ?? 0; + int trans = UtilidadesTransitoDbus.ContarTransbordosReales(tramosBus); + + _mensajeRuta = CapitalizarPalabrasClave( + $"Inicio {HHmm(now)}. Camina {FormatearDistanciaDetalleRuta(_distanciaAPieInicio)} hasta \"{_paradaOrigenSeleccionada?.Nombre}\" ({CodigoParadaFormateado(_paradaOrigenSeleccionada?.Codigo)}). " + + $"Luego viaja en bus {FormatearDistanciaDetalleRuta(_distanciaEnBus)}" + + (tramosCount > 0 ? $" en {tramosCount} tramo(s)" : "") + + (trans == 0 ? "." : $" con {trans} transbordo(s).") + + txtLineas + " " + + $"Finalmente camina {FormatearDistanciaDetalleRuta(_distanciaAPieFin)} hasta tu destino." + ); + + _hayRutaValida = true; + ThrowIfPintadoObsoleto(versionPintado); + StateHasChanged(); + } + + + + + + /// + /// Recalcula con parametros. + /// + private async Task RecalcularConParametros() + { + if (_coordenadasOrigen is null || _coordenadasDestino is null) return; + await PersistirConfiguracionAsync(); + + if (_mapa is not null) + { + try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } + await ClearRoutesJsAsync(); + } + await CerrarPopupsAsync(); + + await CalcularRutaCompletaAsync(); + StateHasChanged(); + } + + /// + /// Reinicia seleccion. + /// + private async Task ReiniciarSeleccion() + { + await CerrarTodasLasPreviewsAsync(); + + await CerrarPopupsAsync(); + + _ctsCalculo?.Cancel(); + IniciarNuevaVersionPintadoRuta(); + + _coordenadasOrigen = null; + _coordenadasDestino = null; + _textoBusquedaOrigen = string.Empty; + _textoBusquedaDestino = string.Empty; + _ubicacionOrigenSeleccionada = null; + _ubicacionDestinoSeleccionada = null; + _sugerenciasBusquedaOrigen.Clear(); + _sugerenciasBusquedaDestino.Clear(); + _campoBusquedaActivo = null; + + _mensajeRuta = string.Empty; + _hayRutaValida = false; + _paradaOrigenSeleccionada = null; + _paradaDestinoSeleccionada = null; + _primerTransbordoseleccionado = null; + _numeroTransbordos = 0; + _distanciaAPieInicio = _distanciaAPieFin = _distanciaEnBus = 0; + _paradaClicada = null; + _tramosRuta.Clear(); + _duracionTotalSegundos = 0; + _opcionesRuta.Clear(); + _indiceAlternativaSeleccionada = 0; + + if (_mapa != null) + { + await ClearRoutesJsAsync(); + await ClearEndpointMarkersJsAsync(); + try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } + + await _mapa.Geometric.DisplayPointsFromArray.deleteAllPoints(); + if (_mostrarParadas) + await DibujarParadasAsync(); + else + await OcultarParadasAsync(); // (repone origen/destino si hubiera, aquí ya no hay, pero ok) + + } + + StateHasChanged(); + } + + // ========================================================= + // Distancia rápida + // ========================================================= + /// + /// Calcula distancia metros. + /// + private static double CalcularDistanciaMetros(double lat1, double lon1, double lat2, double lon2) + { + const double R = 6371000.0; // metros + static double ToRad(double x) => x * Math.PI / 180.0; + + var dLat = ToRad(lat2 - lat1); + var dLon = ToRad(lon2 - lon1); + + var a = + Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(ToRad(lat1)) * Math.Cos(ToRad(lat2)) * + Math.Sin(dLon / 2) * Math.Sin(dLon / 2); + + var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + return R * c; + } +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.TiempoYHorarios.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.TiempoYHorarios.cs new file mode 100644 index 0000000..62092f7 --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.TiempoYHorarios.cs @@ -0,0 +1,470 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using LeafletForBlazor; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using RutasDBUS.Modelos.Planificacion; +using RutasDBUS.Modelos.RedBus; +using RutasDBUS.Servicios; +using RutasDBUS.Servicios.Caminata; +using RutasDBUS.Servicios.Horarios; +using RutasDBUS.Servicios.Planificacion; +using RutasDBUS.Servicios.RedBus; + +namespace RutasDBUS.Components.Pages; + +public partial class PlanificadorRutas : ComponentBase +{ + /// + /// Gestiona hmm. + /// + private static string HHmm(DateTime dt) => dt.ToString("HH:mm", CultureInfo.InvariantCulture); + + /// + /// + /// + private static DateTime Compose(DateOnly d, TimeOnly t) + => d.ToDateTime(t); + + /// + /// Añade seconds safe. + /// + private static DateTime AddSecondsSafe(DateTime dt, double seconds) + { + if (seconds <= 0) return dt; + return dt.AddSeconds(seconds); + } + + // Para sacar de InfoVariante lo que necesitamos para horarios + /// + /// Gestiona codigo str. + /// + private static string LineaCodigoStr(InfoVariante v) => v?.Linea?.Codigo?.ToString() ?? "?"; + + // ======================= + // HORARIOS (ZIP) + // ======================= + private bool _usarHorariosTeoricos = true; + + private bool _horariosOk = false; + private string? _horariosError = null; + + /// + /// Sincroniza la penalizacion manual por parada con el modo horario activo. + /// + private void SincronizarSegundosPorParadaConHorarios() + { + if (_usarHorariosTeoricos) + { + _segundosPorParadaBus = 0; + return; + } + + if (_segundosPorParadaBus == 0 && _segundosPorParadaBusManual >= 0) + _segundosPorParadaBus = _segundosPorParadaBusManual; + } + + /// + /// Gestiona lista horarios con actual. + /// + private List ConstruirListaHorariosConActual( + List<(TimeOnly dep, string expId, string rutaId)> deps, + TimeOnly now) + { + var ordenados = deps + .OrderBy(x => x.dep) + .ToList(); + + int idxProximo = ordenados.FindIndex(x => x.dep >= now); + + var lista = new List(ordenados.Count); + + for (int i = 0; i < ordenados.Count; i++) + { + var d = ordenados[i]; + + var itiClave = Horarios.GetLineaItinerarioSentidoText(d.expId, d.rutaId); + + lista.Add(new ProximaSalidaVm( + Hora: d.dep.ToString("HH:mm"), + LineaTxt: Horarios.GetLineaItinerarioSentidoNombreText(d.expId, d.rutaId), + RutaId: d.rutaId, + ExpId: d.expId, + EsProximo: i == idxProximo, + AnchorId: i == idxProximo ? "horario-proximo-anchor" : "", + ItinerarioClave: itiClave)); + } + + return lista; + } + + + /// + /// Calcula el orden lógico de una línea visible del popup, respetando TB y enviando las líneas B al bloque 100. + /// + private static int ResolverOrdenLineaHorario(string? lineaVisible, string? rutaId) + { + var texto = (lineaVisible ?? "").Trim().ToUpperInvariant(); + + var matchTb = Regex.Match(texto, @"^TB(\d+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (matchTb.Success && + int.TryParse(matchTb.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var tb)) + { + return tb; + } + + var matchBuho = Regex.Match(texto, @"^B(\d+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (matchBuho.Success && + int.TryParse(matchBuho.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var buho)) + { + return 100 + buho; + } + + if (int.TryParse(texto, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeroDirecto)) + return numeroDirecto; + + if (int.TryParse(rutaId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idRuta)) + return idRuta; + + var numeroExtraido = UtilidadesTransitoDbus.ExtraerNumeroLinea(texto); + return numeroExtraido ?? int.MaxValue; + } + + /// + /// Extrae el orden lógico visible de línea, itinerario y sentido para los horarios del popup de parada. + /// + private static (int lineaOrden, string lineaVisible, int itinerarioOrden, string sentidoOrden) ParseClaveOrdenHorario(string? rutaId, string? lineaTxt) + { + if (string.IsNullOrWhiteSpace(lineaTxt)) + return (ResolverOrdenLineaHorario(null, rutaId), "", int.MaxValue, "?"); + + var txt = lineaTxt.Trim(); + var separadorDestino = txt.IndexOf(" - ", StringComparison.Ordinal); + if (separadorDestino > 0) + txt = txt[..separadorDestino].Trim(); + + var match = Regex.Match( + txt, + @"^(?[^.\s]+)\.(?\d+)-(?[A-Za-z?]+)$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + if (!match.Success) + return (ResolverOrdenLineaHorario(null, rutaId), "", int.MaxValue, "?"); + + var lineaVisible = match.Groups["linea"].Value.Trim().ToUpperInvariant(); + var sentido = match.Groups["sentido"].Value.Trim().ToUpperInvariant(); + + int itinerario = int.MaxValue; + int.TryParse(match.Groups["itin"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out itinerario); + + return (ResolverOrdenLineaHorario(lineaVisible, rutaId), lineaVisible, itinerario, sentido); + } + + private IEnumerable NextDepsOrdenados + { + get + { + var ordenadosPorClave = _nextDeps + .Select(x => new + { + Item = x, + Orden = ParseClaveOrdenHorario(x.RutaId, x.LineaTxt) + }); + + if (_horarioOrden == HorarioOrden.PorHora) + { + return ordenadosPorClave + .OrderBy(x => x.Item.Hora, StringComparer.Ordinal) + .ThenBy(x => x.Orden.lineaOrden) + .ThenBy(x => x.Orden.lineaVisible, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Orden.itinerarioOrden) + .ThenBy(x => x.Orden.sentidoOrden, StringComparer.OrdinalIgnoreCase) + .Select(x => x.Item); + } + + return ordenadosPorClave + .OrderBy(x => x.Orden.lineaOrden) + .ThenBy(x => x.Orden.lineaVisible, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Orden.itinerarioOrden) + .ThenBy(x => x.Orden.sentidoOrden, StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Item.Hora, StringComparer.Ordinal) + .Select(x => x.Item); + } + } + + /// + /// Alterna orden horarios. + /// + private void ToggleOrdenHorarios() + { + _horarioOrden = _horarioOrden == HorarioOrden.PorHora + ? HorarioOrden.PorItinerario + : HorarioOrden.PorHora; + + if (_horarioOrden == HorarioOrden.PorHora) + _scrollHorariosPendiente = true; + } + + + + // Próximas salidas para el popup de parada + /// + /// Inicializa una nueva instancia de la clase ProximaSalidaVm. + /// + private sealed record ProximaSalidaVm( + string Hora, + string LineaTxt, + string RutaId, + string ExpId, + bool EsProximo, + string AnchorId, + string ItinerarioClave); + + private List _nextDeps = new(); + /// + /// Gestiona txt desde ruta id. + /// + private string LineaTxtDesdeRutaId(string? rutaId) + { + var rid = CodigoSinDecimal(rutaId); + + if (string.IsNullOrWhiteSpace(rid)) + return $"L\u00ednea ?"; + + // Intenta mapear contra tu JSON de RedBus (Lineas) + var linea = RedBusServicio.Lineas + .FirstOrDefault(l => CodigoSinDecimal(l.Codigo?.ToString()) == rid); + + if (linea is not null) + return $"L\u00ednea {CodigoSinDecimal(linea.Codigo?.ToString())} - {linea.Nombre}"; + + // Fallback si no encontramos la línea en el JSON + return $"L\u00ednea {rid}"; + } + /// + /// Gestiona toggle hora simulada. + /// + private async Task OnToggleHoraSimulada(ChangeEventArgs e) + { + _usarHoraSimulada = e?.Value is bool b && b; + + if (_usarHoraSimulada) + { + var ahora = DateTime.Now; + _fechaSimulada = DateOnly.FromDateTime(ahora); + _horaSimulada = TimeOnly.FromDateTime(ahora); + StopClock(); + } + else + { + StartClock(); + } + + await RefrescarHoraUIAsync(); + await PersistirConfiguracionAsync(); + } + + // ====== HORA (real / simulada) ====== + private bool _usarHoraSimulada = true; + private TimeOnly _horaSimulada = TimeOnly.FromDateTime(DateTime.Now); + private DateOnly _fechaSimulada = DateOnly.FromDateTime(DateTime.Now); + + + /// + /// Gestiona local. + /// + private DateTime AhoraLocal() + { + if (_usarHoraSimulada) + return _fechaSimulada.ToDateTime(_horaSimulada); + + return DateTime.Now; + } + + /// + /// Inicializa la hora simulada con la hora real actual y la deja parada. + /// + protected override void OnInitialized() + { + SincronizarSegundosPorParadaConHorarios(); + + if (!_usarHoraSimulada) + return; + + var ahora = DateTime.Now; + _fechaSimulada = DateOnly.FromDateTime(ahora); + _horaSimulada = TimeOnly.FromDateTime(ahora); + + var es = new CultureInfo("es-ES"); + _horaAhoraTxt = es.TextInfo.ToTitleCase(ahora.ToString("ddd dd/MM HH:mm:ss", es)); + } + + /// + /// Gestiona change fecha simulada. + /// + private async Task OnChangeFechaSimulada(ChangeEventArgs e) + { + var s = e?.Value?.ToString(); + if (!string.IsNullOrWhiteSpace(s) && DateOnly.TryParse(s, out var d)) + _fechaSimulada = d; + + if (_usarHoraSimulada) + await RefrescarHoraUIAsync(); + + await PersistirConfiguracionAsync(); + } + + /// + /// Gestiona change hora simulada. + /// + private async Task OnChangeHoraSimulada(ChangeEventArgs e) + { + var s = e?.Value?.ToString(); + if (!string.IsNullOrWhiteSpace(s) && TimeOnly.TryParse(s, out var t)) + _horaSimulada = t; + + if (_usarHoraSimulada) + await RefrescarHoraUIAsync(); + + await PersistirConfiguracionAsync(); + } + + /// + /// Ajusta la fecha y la hora simuladas al momento actual. + /// + private async Task IrAAhoraSimuladoAsync() + { + var ahora = DateTime.Now; + _fechaSimulada = DateOnly.FromDateTime(ahora); + _horaSimulada = TimeOnly.FromDateTime(ahora); + + if (_usarHoraSimulada) + await RefrescarHoraUIAsync(); + + await PersistirConfiguracionAsync(); + StateHasChanged(); + } + + /// + /// Restablece hora pruebas. + /// + private async Task ResetHoraPruebas() + { + _usarHoraSimulada = false; + _fechaSimulada = DateOnly.FromDateTime(DateTime.Now); + _horaSimulada = TimeOnly.FromDateTime(DateTime.Now); + + StartClock(); + await RefrescarHoraUIAsync(); + } + + + + private string _horaAhoraTxt = ""; + private PeriodicTimer? _clockTimer; + private CancellationTokenSource? _clockCts; + + + /// + /// Gestiona clock. + /// + private void StartClock() + { + if (_clockTimer is not null) return; + + _clockCts = new CancellationTokenSource(); + _clockTimer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + + _ = Task.Run(async () => + { + try + { + while (await _clockTimer.WaitForNextTickAsync(_clockCts.Token)) + { + // ✅ Solo actualizamos si NO estamos en simulada + if (_usarHoraSimulada) continue; + + var now = DateTime.Now; + var es = new CultureInfo("es-ES"); + _horaAhoraTxt = es.TextInfo.ToTitleCase(now.ToString("ddd dd/MM HH:mm:ss", es)); + + await InvokeAsync(StateHasChanged); // ✅ como antes + } + } + catch { /* cancel */ } + }); + } + + + /// + /// Gestiona clock. + /// + private void StopClock() + { + _clockCts?.Cancel(); + _clockTimer?.Dispose(); + _clockCts?.Dispose(); + + _clockTimer = null; + _clockCts = null; + } + + /// + /// Actualiza hora ui. + /// + private async Task RefrescarHoraUIAsync() + { + // pinta la hora (real o simulada) y refresca UI + var es = new CultureInfo("es-ES"); + + DateTime now; + if (_usarHoraSimulada) + now = _fechaSimulada.ToDateTime(_horaSimulada); + else + now = DateTime.Now; + + _horaAhoraTxt = es.TextInfo.ToTitleCase(now.ToString("ddd dd/MM HH:mm:ss", es)); + await InvokeAsync(StateHasChanged); + } + + /// + /// + /// + public void Dispose() + { + _clockCts?.Cancel(); + _clockTimer?.Dispose(); + _clockCts?.Dispose(); + StopRefrescoHistorialCompartido(); + + _dotRef?.Dispose(); + } + + /// + /// + /// + private static IEnumerable Range(int from, int to, int step = 1) + { + if (step <= 0) step = 1; + + if (from <= to) + { + for (int i = from; i <= to; i += step) yield return i; + } + else + { + for (int i = from; i >= to; i -= step) yield return i; + } + } + + private Task<(double[,]? line, double dist, double dur)> WalkCached(double lat1, double lon1, double lat2, double lon2) + { + return _walkProvider == ProveedorCaminata.Valhalla + ? WalkValhallaCached(lat1, lon1, lat2, lon2) + : WalkOsrmCached(lat1, lon1, lat2, lon2); + } + +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.razor b/RutasDBUS/Components/Pages/PlanificadorRutas.razor new file mode 100644 index 0000000..356bcb9 --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.razor @@ -0,0 +1,1598 @@ +@page "/" +@rendermode InteractiveServer + +@using System.Collections.Concurrent +@using System.Globalization +@using System.Text.Json +@using LeafletForBlazor +@using RutasDBUS.Modelos.Planificacion +@using RutasDBUS.Modelos.RedBus +@using RutasDBUS.Servicios +@using RutasDBUS.Servicios.RedBus +@using RutasDBUS.Servicios.Caminata +@using RutasDBUS.Servicios.Planificacion +@using System.Text.RegularExpressions; +@using System.Text +@using RutasDBUS.Servicios.Horarios + + + + + +
+ +
+ + +
+

+ Planificador de rutas en Dbus + @_versionApp +

+ + Haz doble clic en el mapa para marcar origen y destino + +
+ + +
+ + + + + + + + + + + + + + + + +
+ + +
+
+ 🕒 @_horaAhoraTxt +
+ + @if (_inspectorActivo) + { +
+ + Punto (Z) +
+ } + + + + + + + + +
+ +
+ + +
+ + +
+ @if (_coordsMouse is (double lat, double lon)) + { + Latitud: @lat.ToString("0.000000") · Longitud: @lon.ToString("0.000000") + } +
+ + + @if (_mostrarBuscadorMapa) + { +
+
+
+
+ + + @if (SugerenciasOrigenVisibles.Count > 0) + { +
+ @foreach (var sugerencia in SugerenciasOrigenVisibles) + { + + } +
+ } +
+ + + +
+ + + @if (SugerenciasDestinoVisibles.Count > 0) + { +
+ @foreach (var sugerencia in SugerenciasDestinoVisibles) + { + + } +
+ } +
+
+ +
+ + + +
+
+ +
+ } +
+ @* STACK DE POPUPS (izquierda) *@ + @if (_paradaClicada is not null || _itinerarioClicado is not null || _popupInspectorVisible) + { + + } + + + + + @if (_tramoElevDetalle is not null) +{ + @* ====== MODO MINIMIZADO: burbuja ====== *@ + if (_elevPopupMinimizado) + { + + } + else + { + @* ====== MODO NORMAL: popup grande ====== *@ + + } + } + + + + +
+ @if (!string.IsNullOrEmpty(_mensajeRuta) || _opcionesRuta.Count > 0) + { +
+
+
Rutas sugeridas
+
+ + + +
+
+ + @if (_opcionesRuta.Count > 0) + { +
+ @for (int i = 0; i < _opcionesRuta.Count; i++) + { + var alt = _opcionesRuta[i]; + bool seleccionada = i == _indiceAlternativaSeleccionada; + var index = i; + + + } +
+ } + +
+ +
+ @if (_hayRutaValida) + { + @if (_hayRutaValida) + { + @if (_opcionesRuta.Count > 0 && _indiceAlternativaSeleccionada >= 0 && _indiceAlternativaSeleccionada < _opcionesRuta.Count) + { + var alt = _opcionesRuta[_indiceAlternativaSeleccionada]; + + @* ========= RESUMEN BONITO ARRIBA (único) ========= *@ +

+ @if (alt.EsSoloAPie) + { + Ruta + solo a pie + : + @FormatearDistancia(alt.DistanciaSoloAPie) + aprox. + } + else + { + var tramosBus = alt.AlternativaBus?.Tramos ?? new List(); + var trans = alt.NumeroTransbordosReales; + + string txtLineas = TextoLineasDeTramos(tramosBus); + if (string.IsNullOrWhiteSpace(txtLineas)) + txtLineas = "-"; + + var tiempoBusTotal = FormatearDuracionBloqueBusResumen(_tramosRuta); + + Camina + @FormatearDistanciaDetalleRuta(alt.DistanciaPieInicioAprox) + . Viaja en BUS + @FormatearDistanciaDetalleRuta(alt.AlternativaBus!.DistanciaTotalBus) + en + @tramosBus.Count + tramo(s) con + @trans + transbordo(s), Total: + @tiempoBusTotal + , + Líneas: + @txtLineas + . Finalmente camina + @FormatearDistanciaDetalleRuta(alt.DistanciaPieFinAprox) + . + } +

+ } + + } + + +
+
+ + A pie: @FormatearDistanciaDetalleRuta(_distanciaAPieInicio) +
+
+ + BUS: @FormatearDuracionBloqueBusDetalle(_tramosRuta) +
+
+ + A pie: @FormatearDistanciaDetalleRuta(_distanciaAPieFin) +
+
+ + Origen: @_paradaOrigenSeleccionada?.Nombre +
+ @if (_numeroTransbordos > 0) + { +
+ + Transbordos: @_numeroTransbordos +
+ } +
+ + Destino: @_paradaDestinoSeleccionada?.Nombre +
+
+ + @if (_tramosRuta.Any()) + { +

Detalle de tramos:

+
    + @for (var i = 0; i < _tramosRuta.Count; i++) + { + var tramo = _tramosRuta[i]; + var tipoVisible = ObtenerTipoVisibleDetalleTramo(_tramosRuta, i); + +
  • + @(i + 1) +
    + + +
    + @tipoVisible: + + @{ + var partes = (tramo.Descripcion ?? "").Split("
    ", StringSplitOptions.None); + var principal = partes.Length > 0 ? partes[0] : ""; + var extra = partes.Length > 1 ? string.Join("
    ", partes.Skip(1)) : ""; + } + + @((MarkupString)principal) + - @FormatearDistanciaDetalleRuta(tramo.DistanciaMetros) + + @if (tramo.DuracionSegundos is double s && s > 0) + { + (@FormatearDuracion(s)) + } + + @if (!string.IsNullOrWhiteSpace(extra)) + { +
    + @((MarkupString)extra) +
    + } +
    + + +
    + + @if (tramo.Tipo.Equals("Bus", StringComparison.OrdinalIgnoreCase) && tramo.VarianteBus is not null) + { + var id = $"full_{tramo.VarianteBus.Linea.Codigo}_{tramo.VarianteBus.CodigoItinerario}_{tramo.VarianteBus.Sentido?.Codigo}_{tramo.VarianteBus.Variante.Codigo}"; + + + } + + @if (tramo.Linea is not null && tramo.Linea.GetLength(0) > 1 + && tramo.Tipo.StartsWith("A pie", StringComparison.OrdinalIgnoreCase)) + { + + + } + +
    +
    +
  • + + + } +
+ } + + + @if (_opcionesRuta.Count > 0 && _indiceAlternativaSeleccionada >= 0 && _indiceAlternativaSeleccionada < _opcionesRuta.Count) + { + var altSeleccionada = _opcionesRuta[_indiceAlternativaSeleccionada]; + var duracionVisible = altSeleccionada.DuracionTotalAproxSegundos > 0 + ? altSeleccionada.DuracionTotalAproxSegundos + : _duracionTotalSegundos; + var dRecta = DistanciaLineaRectaOrigenDestino(); + var dCorregida = CalcularDistanciaCorregidaAlternativa(altSeleccionada); + var xyzOrigen = FormatearResumenXYZOrigen(); + var xyzDestino = FormatearResumenXYZDestino(); + var tiempoCalculo = FormatearTiempoCalculoRuta(); + var balanceEquilibradoSeleccionado = CriterioPrioritarioEquilibradoActivo + ? FormatearBalanceEquilibradoAlternativa(altSeleccionada) + : string.Empty; + // var costePenalizado = ObtenerCostePenalizadoAlternativa(altSeleccionada); + // var incrementoPenalizacion = Math.Max(0, costePenalizado - Math.Max(0, duracionVisible)); + + if (duracionVisible > 0) + { +

+ Tiempo total estimado del trayecto: + @FormatearDuracion(duracionVisible). + +

+ } + + @if (dRecta > 0 || dCorregida > 0) + { +
+ @if (dRecta > 0) + { +
d.Recta O-D: @($"{Math.Round(dRecta):0} m")
+ } + @if (dCorregida > 0) + { +
d.Corregida a pie: @FormatearDistanciaDetalleRuta(dCorregida)
+ } + @if (!string.IsNullOrWhiteSpace(balanceEquilibradoSeleccionado)) + { +
Balance equilibrado: @balanceEquilibradoSeleccionado
+ } + @if (!string.IsNullOrWhiteSpace(xyzOrigen)) + { +
@xyzOrigen
+ } + @if (!string.IsNullOrWhiteSpace(xyzDestino)) + { +
@xyzDestino
+ } + @if (!string.IsNullOrWhiteSpace(tiempoCalculo)) + { +
Tiempo de calculo: @tiempoCalculo
+ } +@* @if (costePenalizado > 0) + { +
+ Coste penalizado: @FormatearDuracion(costePenalizado) + @if (incrementoPenalizacion > 0) + { + (+@FormatearDuracion(incrementoPenalizacion)) + } +
+ } *@ +
+ } + } + + } + else + { + var mensajeSinRuta = !string.IsNullOrWhiteSpace(_mensajeRuta) + ? _mensajeRuta + : "No hay rutas disponibles para esta combinacion."; + var dRectaSinRuta = DistanciaLineaRectaOrigenDestino(); + var xyzOrigenSinRuta = FormatearResumenXYZOrigen(); + var xyzDestinoSinRuta = FormatearResumenXYZDestino(); + var tiempoCalculoSinRuta = FormatearTiempoCalculoRuta(); + +

@mensajeSinRuta

+ + @if (dRectaSinRuta > 0 + || !string.IsNullOrWhiteSpace(xyzOrigenSinRuta) + || !string.IsNullOrWhiteSpace(xyzDestinoSinRuta)) + { +
+ @if (dRectaSinRuta > 0) + { +
d.Recta O-D: @($"{Math.Round(dRectaSinRuta):0} m")
+ } + @if (!string.IsNullOrWhiteSpace(xyzOrigenSinRuta)) + { +
@xyzOrigenSinRuta
+ } + @if (!string.IsNullOrWhiteSpace(xyzDestinoSinRuta)) + { +
@xyzDestinoSinRuta
+ } + @if (!string.IsNullOrWhiteSpace(tiempoCalculoSinRuta)) + { +
Tiempo de calculo: @tiempoCalculoSinRuta
+ } +
+ } + } +
+
+ } +
+ + @if (_mostrarHistorialRutas) + { +
+
+
Historial de rutas
+ +
+ +
+
+ + @if (_historialLocal.Count == 0) + { +
Sin historial.
+ } + else + { +
+ @foreach (var item in _historialLocal) + { +
+ + + + + +
+ } +
+ } +
+ +
+ + @if (_historialCompartido.Count == 0) + { +
Sin compartidos.
+ } + else + { +
+ @for (int i = 0; i < _historialCompartido.Count; i++) + { + var item = _historialCompartido[i]; + var numeroCompartido = i + 1; + +
+ + + +
+ } +
+ } +
+ + @if (!string.IsNullOrWhiteSpace(_mensajeHistorial)) + { +
+ @_mensajeHistorial +
+ } +
+
+ } + + @if (_historialCompartidoPendienteEliminarId is not null) + { +
+
+
Eliminar compartido
+
+ Seguro que quieres eliminar esta solucion de compartidos? +
+ +
+ + +
+
+
+ } + + +@* + *@ + @if (_mostrarConfigAvanzada) + { +
+
+
Configuración avanzada
+ +
+ +@*
+ + +
+ + +
+ Radio + + m · Máx paradas + +
+ + + En estricto, solo se consideran paradas que pertenezcan a itinerarios permitidos. + +
+
*@ +
+
+ + +
+ Segundos por parada:  +
+ @if (_usarHorariosTeoricos) + { + + + + } + else + { + + + + } +
+
+
+
+ +
+
+ +
+
+ + @* ========================= + RANGO DE LÍNEAS + ========================= *@ +
+
+ + + @if (_activarRangoLineas) + { +
+ + @TextoResumenLineasPermitidas() + + +
+
+ Lineas: + +
+ +
+ @foreach (var linea in LineasFiltroDisponibles()) + { + + } +
+
+
+ } + else + { +
Todas las lineas (@LineasFiltroDisponibles().Count)
+ } +
+
+ +@* ========================= + RANGO DE ITINERARIOS + ========================= *@ +
+
+ + +
+ Rango: + + + + a + + +
+
+
+ +@* ========================= + RANGO DE TRANSBORDOS + ========================= *@ +
+
+ + +
+ Rango: + + + + a + + +
+
+
+ + +
+
+ + +
+ + + + + +
+ +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + +
+ +
+ + + +
+
+ +@*
+ +
+ + + +
+
*@ +
+ + +
+ +
+
+ +
+ + + +
+ +
+ + +
+ + + + @OpacidadMapaPorcentaje% + +
+
+
+ } +
+ +@if (_mostrarModelosHorariosPanel) +{ +
+
+
+
+
Modelos DBUS
+
+ @(ModeloHorarioActivo?.NombreArchivo ?? "Sin modelo cargado") +
+
+ +
+ +
+ + +
+ + +
+ + + +
+ +
+ + @if (_modelosHorarios.Count > 0) + { +
+ ZIPs disponibles (@_modelosHorarios.Count) +
+ @foreach (var modelo in _modelosHorarios) + { +
+ @modelo.NombreArchivo + @modelo.Origen - @FormatearFechaModelo(modelo.FechaUtc) +
+ } +
+
+ } + + @if (_modelosHorariosCargando) + { +
Trabajando...
+ } + else if (!string.IsNullOrWhiteSpace(_mensajeModelosHorarios)) + { +
@_mensajeModelosHorarios
+ } +
+
+
+} + +@if (_mostrarConfirmacionRestablecer) +{ +
+
+
Restablecer configuración
+
+ ¿Seguro que quieres volver a los valores por defecto de la configuración avanzada? +
+ +
+ + +
+
+
+} + diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.razor.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.razor.cs new file mode 100644 index 0000000..bace222 --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.razor.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using RutasDBUS.Servicios.Caminata; +using RutasDBUS.Servicios.Geocodificacion; +using RutasDBUS.Servicios.Historial; +using RutasDBUS.Servicios.Horarios; +using RutasDBUS.Servicios.Importacion; +using RutasDBUS.Servicios.Planificacion; +using RutasDBUS.Servicios.RedBus; + +namespace RutasDBUS.Components.Pages; + +public partial class PlanificadorRutas : ComponentBase +{ + [Inject] protected ServicioHorariosDbus Horarios { get; set; } = default!; + [Inject] protected IServicioArchivoImportacionDbus ArchivoImportacionDbus { get; set; } = default!; + [Inject] protected IWebHostEnvironment Env { get; set; } = default!; + [Inject] protected IRedBusServicio RedBusServicio { get; set; } = default!; + [Inject] protected IPlanificadorRutas PlanificadorRutasServicio { get; set; } = default!; + [Inject] protected IHistorialRutasCompartidoService HistorialRutasCompartidoService { get; set; } = default!; + [Inject] protected IGeocodificadorLocalServicio GeocodificadorLocal { get; set; } = default!; + [Inject] protected IClienteOsrm EsrmCliente { get; set; } = default!; + [Inject] protected IJSRuntime JS { get; set; } = default!; + [Inject] protected IClienteValhalla ClienteValhalla { get; set; } = default!; + [Inject] protected HttpClient Http { get; set; } = default!; +} diff --git a/RutasDBUS/Components/Routes.razor b/RutasDBUS/Components/Routes.razor new file mode 100644 index 0000000..f756e19 --- /dev/null +++ b/RutasDBUS/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/RutasDBUS/Components/_Imports.razor b/RutasDBUS/Components/_Imports.razor new file mode 100644 index 0000000..88b5d2a --- /dev/null +++ b/RutasDBUS/Components/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using RutasDBUS +@using RutasDBUS.Components +@using LeafletForBlazor + diff --git a/RutasDBUS/Modelos/Callejero/GFA_DSET_AD_CSV.zip b/RutasDBUS/Modelos/Callejero/GFA_DSET_AD_CSV.zip new file mode 100644 index 0000000..83e416c Binary files /dev/null and b/RutasDBUS/Modelos/Callejero/GFA_DSET_AD_CSV.zip differ diff --git a/RutasDBUS/Modelos/Geocodificacion/LugarGeocodificado.cs b/RutasDBUS/Modelos/Geocodificacion/LugarGeocodificado.cs new file mode 100644 index 0000000..b6c6c46 --- /dev/null +++ b/RutasDBUS/Modelos/Geocodificacion/LugarGeocodificado.cs @@ -0,0 +1,13 @@ +namespace RutasDBUS.Modelos.Geocodificacion; + +public sealed class LugarGeocodificado +{ + public string Id { get; init; } = ""; + public string Nombre { get; init; } = ""; + public string Subtitulo { get; init; } = ""; + public string Tipo { get; init; } = ""; + public string Fuente { get; init; } = ""; + public string Municipio { get; init; } = ""; + public double Latitud { get; init; } + public double Longitud { get; init; } +} diff --git a/RutasDBUS/Modelos/Historial/RegistroHistorialRuta.cs b/RutasDBUS/Modelos/Historial/RegistroHistorialRuta.cs new file mode 100644 index 0000000..b0bf499 --- /dev/null +++ b/RutasDBUS/Modelos/Historial/RegistroHistorialRuta.cs @@ -0,0 +1,25 @@ +namespace RutasDBUS.Modelos.Historial; + +/// +/// Escenario guardado de origen/destino para reutilizar pruebas de rutas. +/// +public sealed class RegistroHistorialRuta +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string OrigenTexto { get; set; } = string.Empty; + public string DestinoTexto { get; set; } = string.Empty; + public double OrigenLatitud { get; set; } + public double OrigenLongitud { get; set; } + public double DestinoLatitud { get; set; } + public double DestinoLongitud { get; set; } + public bool UsarHorariosTeoricos { get; set; } = true; + public bool UsarHoraSimulada { get; set; } = true; + public string? FechaSimulada { get; set; } + public string? HoraSimulada { get; set; } + public string? CriterioPrioritarioRuta { get; set; } + public string? PreferenciaEtiquetaRuta { get; set; } + public bool ActivarFiltroLineas { get; set; } + public List LineasPermitidas { get; set; } = new(); + public DateTime FechaCreacionUtc { get; set; } = DateTime.UtcNow; + public DateTime FechaActualizacionUtc { get; set; } = DateTime.UtcNow; +} diff --git a/RutasDBUS/Modelos/Horarios/zip_DBUS_OPENDATA_20260408~0940.zip b/RutasDBUS/Modelos/Horarios/zip_DBUS_OPENDATA_20260408~0940.zip new file mode 100644 index 0000000..00ebdd1 Binary files /dev/null and b/RutasDBUS/Modelos/Horarios/zip_DBUS_OPENDATA_20260408~0940.zip differ diff --git a/RutasDBUS/Modelos/Horarios/zip_DBUS_OPENDATA_20260514~0800.zip b/RutasDBUS/Modelos/Horarios/zip_DBUS_OPENDATA_20260514~0800.zip new file mode 100644 index 0000000..3e231f7 Binary files /dev/null and b/RutasDBUS/Modelos/Horarios/zip_DBUS_OPENDATA_20260514~0800.zip differ diff --git a/RutasDBUS/Modelos/Horarios/zip_Importacion_20260213~0900.zip b/RutasDBUS/Modelos/Horarios/zip_Importacion_20260213~0900.zip new file mode 100644 index 0000000..c945de4 Binary files /dev/null and b/RutasDBUS/Modelos/Horarios/zip_Importacion_20260213~0900.zip differ diff --git a/RutasDBUS/Modelos/Importacion/ArchivoImportacionDbus.cs b/RutasDBUS/Modelos/Importacion/ArchivoImportacionDbus.cs new file mode 100644 index 0000000..848ac36 --- /dev/null +++ b/RutasDBUS/Modelos/Importacion/ArchivoImportacionDbus.cs @@ -0,0 +1,31 @@ +namespace RutasDBUS.Modelos.Importacion; + +public sealed class ArchivoImportacionDbus +{ + public string RutaZip { get; init; } = ""; + public DateTime FechaArchivoUtc { get; init; } + public IReadOnlyList Calendarios { get; init; } = Array.Empty(); + public IReadOnlyList CalendarioExcepciones { get; init; } = Array.Empty(); + public IReadOnlyList Expediciones { get; init; } = Array.Empty(); + public IReadOnlyList Operadores { get; init; } = Array.Empty(); + public IReadOnlyList Paradas { get; init; } = Array.Empty(); + public IReadOnlyList Patrones { get; init; } = Array.Empty(); + public IReadOnlyList Rutas { get; init; } = Array.Empty(); + public IReadOnlyList Tarifas { get; init; } = Array.Empty(); + public IReadOnlyList TiemposParada { get; init; } = Array.Empty(); + public IReadOnlyList TiemposReales { get; init; } = Array.Empty(); + public IReadOnlyList Transbordos { get; init; } = Array.Empty(); + public IReadOnlyList Vehiculos { get; init; } = Array.Empty(); + + public IReadOnlyDictionary ParadasPorId { get; init; } = new Dictionary(); + public IReadOnlyDictionary RutasPorId { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + public IReadOnlyDictionary ExpedicionesPorId { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + public IReadOnlyDictionary> TiemposParadaPorExpedicion { get; init; } = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + public IReadOnlyDictionary> PatronesPorId { get; init; } = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + public IReadOnlyDictionary> TransbordosPorRuta { get; init; } = + new Dictionary>(StringComparer.OrdinalIgnoreCase); +} diff --git a/RutasDBUS/Modelos/Importacion/FilasCsvDbus.cs b/RutasDBUS/Modelos/Importacion/FilasCsvDbus.cs new file mode 100644 index 0000000..3feac1e --- /dev/null +++ b/RutasDBUS/Modelos/Importacion/FilasCsvDbus.cs @@ -0,0 +1,149 @@ +namespace RutasDBUS.Modelos.Importacion; + +/// +/// Inicializa una nueva instancia de la clase FilaCalendarioCsv. +/// +public sealed record FilaCalendarioCsv( + string Id, + int Lunes, + int Martes, + int Miercoles, + int Jueves, + int Viernes, + int Sabado, + int Domingo, + DateOnly FechaInicio, + DateOnly FechaFin); + +/// +/// Inicializa una nueva instancia de la clase FilaCalendarioExcepcionCsv. +/// +public sealed record FilaCalendarioExcepcionCsv( + string Id, + DateOnly FechaEx, + int TipoExcepcion); + +/// +/// Inicializa una nueva instancia de la clase FilaExpedicionCsv. +/// +public sealed record FilaExpedicionCsv( + string Id, + string IdRuta, + string DirDestino, + int Direccion, + string CambioExp, + string IdPatronExp, + TimeOnly? HSalida, + string IdCal); + +/// +/// Inicializa una nueva instancia de la clase FilaOperadorCsv. +/// +public sealed record FilaOperadorCsv( + string Id, + string Nombre, + string Telefono, + string Url, + string Descripcion); + +/// +/// Inicializa una nueva instancia de la clase FilaParadaCsv. +/// +public sealed record FilaParadaCsv( + int Id, + string CodigoSms, + string NombrePar, + double LatitudYPar, + double LongitudXPar, + string LocalidadPar, + string ZonaPar, + string DescripcionPar, + string UrlPar, + string TipoPar, + string EstacionPadre); + +/// +/// Inicializa una nueva instancia de la clase FilaPatronCsv. +/// +public sealed record FilaPatronCsv( + string Id, + double PtoLatitudY, + double PtoLongitudX, + int PtoSecuencia, + double DistanciaRecorrida); + +/// +/// Inicializa una nueva instancia de la clase FilaRutaCsv. +/// +public sealed record FilaRutaCsv( + string Id, + string Abreviatura, + string Nombre, + string Descripcion, + string TipoVehiculo, + string Url, + string Color); + +/// +/// Inicializa una nueva instancia de la clase FilaTarifaCsv. +/// +public sealed record FilaTarifaCsv( + string Id, + string IdTarRu, + string IdParadaOrigen, + string IdParadaDestino, + string IdZonaOrigen, + string IdZonaDestino, + decimal? Precio, + string TipoPago, + int? TiempoTransbordo, + int? Transbordos); + +/// +/// Inicializa una nueva instancia de la clase FilaTiempoParadaCsv. +/// +public sealed record FilaTiempoParadaCsv( + string IdExp, + TimeOnly? HoraLlegada, + TimeOnly? HoraSalida, + double DistanciaSiguienteParada, + int IdTiempoPar, + int SecPar, + string TipoSubidaPar, + string TipoBajadaPar, + string NuevaDirDestinoPar); + +/// +/// Inicializa una nueva instancia de la clase FilaTiempoRealCsv. +/// +public sealed record FilaTiempoRealCsv( + string IdRu, + string IdServicio, + string IdTrans, + double LatitudY, + double LongitudX, + string HoraEmision, + string IdAntPar, + string FechaHoraPaso, + TimeOnly? HSalida, + int? MinutosDeDiferencia, + string Teorico); + +/// +/// Inicializa una nueva instancia de la clase FilaTransbordoCsv. +/// +public sealed record FilaTransbordoCsv( + string IdRuta1, + string IdRuta2); + +/// +/// Inicializa una nueva instancia de la clase FilaVehiculoCsv. +/// +public sealed record FilaVehiculoCsv( + string Id, + string IdTipo, + string DescripcionTipo, + int? CapacidadMaxima, + int? Asientos, + int? NumPuertas, + int? AforoActual); diff --git a/RutasDBUS/Modelos/Importacion/ModeloHorarioDbusInfo.cs b/RutasDBUS/Modelos/Importacion/ModeloHorarioDbusInfo.cs new file mode 100644 index 0000000..ebe8a34 --- /dev/null +++ b/RutasDBUS/Modelos/Importacion/ModeloHorarioDbusInfo.cs @@ -0,0 +1,10 @@ +namespace RutasDBUS.Modelos.Importacion; + +public sealed record ModeloHorarioDbusInfo( + string Id, + string NombreArchivo, + string RutaRelativa, + string Origen, + long TamanoBytes, + DateTime FechaUtc, + bool Activo); diff --git a/RutasDBUS/Modelos/Planificacion/AlternativaRuta.cs b/RutasDBUS/Modelos/Planificacion/AlternativaRuta.cs new file mode 100644 index 0000000..40669ef --- /dev/null +++ b/RutasDBUS/Modelos/Planificacion/AlternativaRuta.cs @@ -0,0 +1,38 @@ +using RutasDBUS.Modelos.RedBus; + +namespace RutasDBUS.Modelos.Planificacion; + +public sealed class AlternativaRuta +{ + public bool EsSoloAPie { get; set; } + public string ProveedorRuta { get; set; } = ""; + + public double[,]? LineaSoloAPie { get; set; } + public double DistanciaSoloAPie { get; set; } + public double DuracionSoloAPie { get; set; } + + public Parada OrigenParada { get; set; } = default!; + public Parada DestinoParada { get; set; } = default!; + public RutaBusAlternativa? AlternativaBus { get; set; } + + public double DistanciaPieInicioAprox { get; set; } + public double DistanciaPieTransbordosAprox { get; set; } + public double DistanciaPieFinAprox { get; set; } + public double DistanciaPieTotalAprox => + DistanciaPieInicioAprox + DistanciaPieTransbordosAprox + DistanciaPieFinAprox; + + public double TiempoPieInicio { get; set; } + public double TiempoPieFin { get; set; } + public double TiempoEsperaInicialSegundos { get; set; } + public double TiempoEsperaTransbordosSegundos { get; set; } + public double TiempoEsperaTotalSegundos => + TiempoEsperaInicialSegundos + TiempoEsperaTransbordosSegundos; + public bool UsaHorariosReales { get; set; } + public bool HorarioCompletoDisponible { get; set; } + + public double DuracionTotalAproxSegundos { get; set; } + public string Etiqueta { get; set; } = ""; + public int NumeroTransbordosReales { get; set; } + + public double PuntuacionCoste { get; set; } +} diff --git a/RutasDBUS/Modelos/Planificacion/DetalleTramoRuta.cs b/RutasDBUS/Modelos/Planificacion/DetalleTramoRuta.cs new file mode 100644 index 0000000..5f27a9c --- /dev/null +++ b/RutasDBUS/Modelos/Planificacion/DetalleTramoRuta.cs @@ -0,0 +1,20 @@ +using RutasDBUS.Modelos.RedBus; + +namespace RutasDBUS.Modelos.Planificacion; + +public sealed class DetalleTramoRuta +{ + public string Tipo { get; set; } = ""; + public string Descripcion { get; set; } = ""; + public double DistanciaMetros { get; set; } + public double? DuracionSegundos { get; set; } + public double[,]? Linea { get; set; } + public double? ElevMin { get; set; } + public double? ElevMax { get; set; } + public double? ElevInicio { get; set; } + public double? ElevFin { get; set; } + public List? MuestrasElevacion { get; set; } + public bool ElevacionCargando { get; set; } + public bool ElevacionLista => MuestrasElevacion is not null && MuestrasElevacion.Count > 0; + public InfoVariante? VarianteBus { get; set; } +} diff --git a/RutasDBUS/Modelos/Planificacion/MuestraElevacion.cs b/RutasDBUS/Modelos/Planificacion/MuestraElevacion.cs new file mode 100644 index 0000000..dd9bff1 --- /dev/null +++ b/RutasDBUS/Modelos/Planificacion/MuestraElevacion.cs @@ -0,0 +1,10 @@ +namespace RutasDBUS.Modelos.Planificacion; + +public sealed class MuestraElevacion +{ + public int Indice { get; set; } + public double Latitud { get; set; } + public double Longitud { get; set; } + public double? Elevacion { get; set; } + public double? ElevacionIdee { get; set; } +} diff --git a/RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs b/RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs new file mode 100644 index 0000000..999395a --- /dev/null +++ b/RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs @@ -0,0 +1,74 @@ +using RutasDBUS.Modelos.RedBus; + +namespace RutasDBUS.Modelos.Planificacion; + +public sealed class ParadaCandidata +{ + public Parada Parada { get; set; } = default!; + public double DistanciaEuclidea { get; set; } + public double DistanciaAPie { get; set; } + public double TiempoAPie { get; set; } +} + + + + +public sealed class ParametrosPlanificadorRuta +{ + // velocidades + public double VelocidadPieKmH { get; set; } = 3.6; + public double VelocidadBusKmH { get; set; } = 16.0; + + // penalizaciones + public int MinutosPorTransbordo { get; set; } = 5; + public int MargenTransbordoSegundos { get; set; } = 120; + public int SegundosPorParadaBus { get; set; } = 30; + public double FactorPenalizacionDistanciaPie { get; set; } = 1.6; + public double ParametroPenalizacionDistanciaPieMetros { get; set; } = 100.0; + public double PesoTransbordoRankingMinutos { get; set; } = 4.0; + + // selección candidatas + public int RadioParadasCandidatasMetros { get; set; } = 1200; + public int MaxParadasCandidatasPorLado { get; set; } = 12; + public bool UsarOsrmParaRanking { get; set; } = false; + + // filtro itinerarios + public bool ActivarRangoLineas { get; set; } = false; + public int LineaMin { get; set; } = 1; + public int LineaMax { get; set; } = 999; + public List LineasPermitidas { get; set; } = new(); + public bool ActivarRangoItinerarios { get; set; } = true; + public int ItinerarioMin { get; set; } = 1; + public int ItinerarioMax { get; set; } = 80; + + // filtro de transbordos + public bool ActivarRangoTransbordos { get; set; } = true; + public int MinTransbordosPermitidos { get; set; } = 0; + public int MaxTransbordosPermitidos { get; set; } = 3; + + // límites exploración + public int MaxParesEvaluados { get; set; } = 280; + public int MaxAlternativasBusPorPar { get; set; } = 4; + + // planificación temporal + public bool UsarHorariosReales { get; set; } = true; + public DateOnly? FechaSalida { get; set; } + public TimeOnly? HoraSalida { get; set; } + public bool DescartarAlternativasSinHorario { get; set; } = false; + + // filtros de espera + public int MaxEsperaInicialMinutos { get; set; } = 90; + public int MaxEsperaTransbordoMinutos { get; set; } = 60; + public int MaxEsperaTotalMinutos { get; set; } = 120; + + // preferencia UI (null mantiene el criterio equilibrado por defecto) + public string? PreferenciaEtiquetaRuta { get; set; } = null; +} + +public sealed class ResultadoPlanificadorRuta +{ + public bool Ok { get; set; } + public string Mensaje { get; set; } = ""; + public List Alternativas { get; set; } = new(); + public int IndicePreferido { get; set; } = 0; +} diff --git a/RutasDBUS/Modelos/RedBus/InfoRutaBus.cs b/RutasDBUS/Modelos/RedBus/InfoRutaBus.cs new file mode 100644 index 0000000..f86921d --- /dev/null +++ b/RutasDBUS/Modelos/RedBus/InfoRutaBus.cs @@ -0,0 +1,67 @@ +namespace RutasDBUS.Modelos.RedBus; + +/// +/// Información precalculada de una variante con la secuencia de paradas. +/// +public class InfoVariante +{ + /// Línea a la que pertenece la variante. + public LineaBus Linea { get; set; } = default!; + + /// Sentido dentro del itinerario. + public Sentido Sentido { get; set; } = default!; + + /// Variante concreta de la línea. + public Variante Variante { get; set; } = default!; + + public int CodigoItinerario { get; set; } + + + /// + /// Lista ordenada de códigos de parada que recorre la variante. + /// + public List CodigosParadas { get; set; } = new(); +} + +/// +/// Indica en qué variante y en qué posición aparece una parada. +/// +public class AparicionParada +{ + /// Variante en la que aparece la parada. + public InfoVariante Variante { get; set; } = default!; + + /// Índice de la parada dentro de . + public int IndiceParada { get; set; } +} + + +public class TramoBus +{ + public InfoVariante Variante { get; set; } = default!; + + /// + /// Índice de parada inicial (incluida) dentro de la secuencia de paradas. + /// + public int IndiceInicio { get; set; } + + /// + /// Índice de parada final (incluida) dentro de la secuencia de paradas. + /// OJO: al construir geometría se recorren trayectos: i = IndiceInicio .. IndiceFin-1. + /// + public int IndiceFin { get; set; } + + public string CodigoParadaInicio => + Variante.CodigosParadas[Math.Clamp(IndiceInicio, 0, Variante.CodigosParadas.Count - 1)]; + + public string CodigoParadaFin => + Variante.CodigosParadas[Math.Clamp(IndiceFin, 0, Variante.CodigosParadas.Count - 1)]; + + public bool EsValido => + IndiceInicio >= 0 && + IndiceFin >= 0 && + IndiceInicio < Variante.CodigosParadas.Count && + IndiceFin < Variante.CodigosParadas.Count && + IndiceInicio < IndiceFin; +} + diff --git a/RutasDBUS/Modelos/RedBus/LineaBus.cs b/RutasDBUS/Modelos/RedBus/LineaBus.cs new file mode 100644 index 0000000..4ba63ee --- /dev/null +++ b/RutasDBUS/Modelos/RedBus/LineaBus.cs @@ -0,0 +1,59 @@ +namespace RutasDBUS.Modelos.RedBus; + +/// +/// Representa una línea de autobús con sus itinerarios. +/// +public class LineaBus +{ + /// Identificador original de la ruta en ruta.csv. + public string IdRuta { get; set; } = ""; + + /// Código interno de la línea. + public string Codigo { get; set; } = ""; + + /// Nombre descriptivo de la línea. + public string Nombre { get; set; } = ""; + + /// Color de fondo en formato ARGB (ej. FF0000FF). + public string ColorArgb { get; set; } = "FF0000FF"; + + /// Código del itinerario por defecto. + public int CodigoItinerarioPorDefecto { get; set; } + + /// Lista de itinerarios definidos para esta línea. + public List Itinerarios { get; set; } = new(); +} + +/// Itinerario de una línea (por ejemplo ida / vuelta). +public class Itinerario +{ + /// Código interno del itinerario. + public int Codigo { get; set; } + + /// Sentidos configurados dentro de este itinerario. + public List Sentidos { get; set; } = new(); +} + +/// Sentido de un itinerario (A→B, B→A, etc.). +public class Sentido +{ + /// Código del sentido. + public string Codigo { get; set; } = ""; + + /// Variantes existentes en este sentido. + public List Variantes { get; set; } = new(); +} + +/// +/// Variante de un sentido, definida por la secuencia de trayectos que recorre. +/// +public class Variante +{ + /// Código interno de la variante. + public int Codigo { get; set; } + + /// + /// Identificadores de trayectos que se recorren en esta variante, en orden. + /// + public List IdsTrayectos { get; set; } = new(); +} diff --git a/RutasDBUS/Modelos/RedBus/Parada.cs b/RutasDBUS/Modelos/RedBus/Parada.cs new file mode 100644 index 0000000..6a87326 --- /dev/null +++ b/RutasDBUS/Modelos/RedBus/Parada.cs @@ -0,0 +1,43 @@ +namespace RutasDBUS.Modelos.RedBus; + +/// +/// Representa una parada de autobús con código, nombre y coordenadas. +/// +public class Parada +{ + /// Código interno de la parada (por ejemplo 34.1). + public string Codigo { get; set; } = ""; + + /// Nombre descriptivo completo de la parada. + public string Nombre { get; set; } = ""; + + /// + /// Nombre corto de la parada (campo NombreCorto del fichero de líneas). + /// + public string NombreCorto { get; set; } = ""; + + /// + /// Abreviatura de la parada (campo Abreviatura). + /// + public string Abreviatura { get; set; } = ""; + + /// + /// Altura de la parada en metros (campo Altura). + /// + public double Altura { get; set; } + + /// Latitud en grados decimales. + public double Latitud { get; set; } + + /// Longitud en grados decimales. + public double Longitud { get; set; } + + /// Zona previa de la parada (si se utiliza en el fichero). + public string PreZona { get; set; } = ""; + + /// Zona posterior de la parada (si se utiliza en el fichero). + public string PostZona { get; set; } = ""; + + /// Zona de salida de la parada (si se utiliza en el fichero). + public string ZonaSalida { get; set; } = ""; +} diff --git a/RutasDBUS/Modelos/RedBus/RutaBusAlternativa.cs b/RutasDBUS/Modelos/RedBus/RutaBusAlternativa.cs new file mode 100644 index 0000000..7ccb9a9 --- /dev/null +++ b/RutasDBUS/Modelos/RedBus/RutaBusAlternativa.cs @@ -0,0 +1,20 @@ + using RutasDBUS.Modelos.RedBus; + +/// +/// Representa una posible ruta de bus entre dos paradas, +/// formada por varios tramos de bus consecutivos. +/// +public class RutaBusAlternativa +{ + /// Tramos de bus que componen la ruta. + public List Tramos { get; set; } = new(); + + /// Número de Transbordos (cambios de línea/variante). + public int NumeroTransbordos { get; set; } + + /// Distancia total estimada en bus (metros). + public double DistanciaTotalBus { get; set; } + + /// Código de paradas de Transbordo intermedias. + public List CodigosParadasTransbordo { get; set; } = new(); +} diff --git a/RutasDBUS/Modelos/RedBus/Trayecto.cs b/RutasDBUS/Modelos/RedBus/Trayecto.cs new file mode 100644 index 0000000..9f9be82 --- /dev/null +++ b/RutasDBUS/Modelos/RedBus/Trayecto.cs @@ -0,0 +1,28 @@ +namespace RutasDBUS.Modelos.RedBus; + +/// +/// Representa un tramo de línea entre dos paradas con una lista de puntos intermedios. +/// +public class Trayecto +{ + /// Identificador interno del trayecto. + public int Id { get; set; } + + /// Código de la parada origen de este trayecto. + public string CodigoParadaOrigen { get; set; } = ""; + + /// Código de la parada destino de este trayecto. + public string CodigoParadaDestino { get; set; } = ""; + + /// + /// Lista de coordenadas [latitud, longitud] que describen la geometría del trayecto. + /// + public List Posiciones { get; set; } = new(); +} + + +public class OpcionesRedBus +{ + public string ArchivoDatos { get; set; } = ""; + public double DistanciaMaximaTransbordoMetros { get; set; } = 250; +} diff --git a/RutasDBUS/Program.cs b/RutasDBUS/Program.cs new file mode 100644 index 0000000..d1097e4 --- /dev/null +++ b/RutasDBUS/Program.cs @@ -0,0 +1,79 @@ +// Program.cs +using System.Text; +using RutasDBUS.Components; +using RutasDBUS.Modelos.RedBus; +using RutasDBUS.Servicios.Caminata; +using RutasDBUS.Servicios.Historial; +using RutasDBUS.Servicios.Horarios; +using RutasDBUS.Servicios.Importacion; +using RutasDBUS.Servicios.Geocodificacion; +using RutasDBUS.Servicios.Planificacion; +using RutasDBUS.Servicios.RedBus; + +Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + +var builder = WebApplication.CreateBuilder(args); + +// ===================================================== +// Razor Components (Blazor Server interactivo) +// ===================================================== +builder.Services + .AddRazorComponents() + .AddInteractiveServerComponents(); + +// ===================================================== +// Configuración tipada (appsettings.json) +// ===================================================== +// En appsettings.json tienes: +// "RedBus": { "ArchivoDatos": "...", "DistanciaMaximaTransbordoMetros": 250 } +builder.Services.Configure( + builder.Configuration.GetSection("RedBus")); + +// (Opcional) Si luego quieres opciones de Mapa/Elevación/Planificador, se hace igual: +// builder.Services.Configure(builder.Configuration.GetSection("Mapa")); +// builder.Services.Configure(builder.Configuration.GetSection("Elevacion")); +// builder.Services.Configure(builder.Configuration.GetSection("PlanificadorRutas")); + +// ===================================================== +// Servicios propios +// ===================================================== + +// Servicio de red de autobuses (paradas, líneas, trayectos...) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// HttpClient genérico (lo usas en el Razor: @inject HttpClient Http) +builder.Services.AddHttpClient(); + +// Clientes HTTP tipados +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); + +var app = builder.Build(); + +// ===================================================== +// Pipeline +// ===================================================== +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseAntiforgery(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); + diff --git a/RutasDBUS/Properties/launchSettings.json b/RutasDBUS/Properties/launchSettings.json new file mode 100644 index 0000000..736dac7 --- /dev/null +++ b/RutasDBUS/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:36806", + "sslPort": 44389 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5170", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7131;http://localhost:5170", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/RutasDBUS/RutasDBUS.csproj b/RutasDBUS/RutasDBUS.csproj new file mode 100644 index 0000000..745e9fd --- /dev/null +++ b/RutasDBUS/RutasDBUS.csproj @@ -0,0 +1,37 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + PreserveNewest + PreserveNewest + + + PreserveNewest + PreserveNewest + + + + + + Never + + + PreserveNewest + + + + diff --git a/RutasDBUS/Servicios/Caminata/ClienteOsrm.cs b/RutasDBUS/Servicios/Caminata/ClienteOsrm.cs new file mode 100644 index 0000000..bdc718c --- /dev/null +++ b/RutasDBUS/Servicios/Caminata/ClienteOsrm.cs @@ -0,0 +1,152 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; + +namespace RutasDBUS.Servicios.Caminata; + +/// +/// Implementación del cliente OSRM para calcular rutas a pie. +/// +public class ClienteOsrm : IClienteOsrm +{ + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + + /// + /// Inicializa una nueva instancia de la clase ClienteOsrm. + /// + public ClienteOsrm(HttpClient httpClient, IConfiguration configuration) + { + _httpClient = httpClient; + + _baseUrl = configuration["Caminata:Osrm:BaseUrl"] ?? ""; + _baseUrl = _baseUrl.Trim(); + + if (string.IsNullOrWhiteSpace(_baseUrl)) + throw new InvalidOperationException("Falta la configuración 'Caminata:Osrm:BaseUrl' en appsettings.json."); + + _baseUrl = _baseUrl.TrimEnd('/'); + } + + /// + public async Task<(double[,]? Linea, double DistanciaMetros, double DuracionSegundos)> ObtenerRutaAPieAsync( + double latitudDesde, + double longitudDesde, + double latitudHasta, + double longitudHasta) + { + // OSRM: /route/v1/walking/{lon1},{lat1};{lon2},{lat2}?overview=full&geometries=geojson&alternatives=false + var url = + $"{_baseUrl}/walking/" + + $"{longitudDesde.ToString(CultureInfo.InvariantCulture)}," + + $"{latitudDesde.ToString(CultureInfo.InvariantCulture)};" + + $"{longitudHasta.ToString(CultureInfo.InvariantCulture)}," + + $"{latitudHasta.ToString(CultureInfo.InvariantCulture)}" + + "?overview=full&geometries=geojson&alternatives=false"; + + RespuestaOsrm? respuesta; + try + { + respuesta = await _httpClient.GetFromJsonAsync(url); + } + catch + { + return (null, 0, 0); + } + + var ruta = respuesta?.Rutas?.FirstOrDefault(); + var coords = ruta?.Geometria?.Coordenadas; + + if (ruta is null || coords is null || coords.Count == 0) + return (null, 0, 0); + + var linea = new double[coords.Count, 2]; + + for (int i = 0; i < coords.Count; i++) + { + // GeoJSON: [lon, lat] + double lon = coords[i][0]; + double lat = coords[i][1]; + + linea[i, 0] = lat; + linea[i, 1] = lon; + } + + return ( + AnclarExtremosLinea(linea, latitudDesde, longitudDesde, latitudHasta, longitudHasta), + ruta.Distancia, + ruta.Duracion); + } + + /// + /// Fuerza que la geometria empiece y termine exactamente en los puntos solicitados. + /// + private static double[,] AnclarExtremosLinea( + double[,] linea, + double latitudDesde, + double longitudDesde, + double latitudHasta, + double longitudHasta) + { + var puntos = linea.GetLength(0); + if (puntos <= 0) + { + return new double[,] + { + { latitudDesde, longitudDesde }, + { latitudHasta, longitudHasta } + }; + } + + if (puntos == 1) + { + return new double[,] + { + { latitudDesde, longitudDesde }, + { latitudHasta, longitudHasta } + }; + } + + linea[0, 0] = latitudDesde; + linea[0, 1] = longitudDesde; + linea[puntos - 1, 0] = latitudHasta; + linea[puntos - 1, 1] = longitudHasta; + return linea; + } + + // ========================= + // Modelos de respuesta OSRM + // ========================= + + private sealed class RespuestaOsrm + { + [JsonPropertyName("routes")] + public System.Collections.Generic.List? Rutas { get; set; } + } + + private sealed class RutaOsrm + { + [JsonPropertyName("distance")] + public double Distancia { get; set; } + + [JsonPropertyName("duration")] + public double Duracion { get; set; } + + [JsonPropertyName("geometry")] + public GeometriaOsrm? Geometria { get; set; } + } + + private sealed class GeometriaOsrm + { + [JsonPropertyName("type")] + public string? Tipo { get; set; } + + [JsonPropertyName("coordinates")] + public System.Collections.Generic.List>? Coordenadas { get; set; } + } +} diff --git a/RutasDBUS/Servicios/Caminata/ClienteValhalla.cs b/RutasDBUS/Servicios/Caminata/ClienteValhalla.cs new file mode 100644 index 0000000..5a2ed53 --- /dev/null +++ b/RutasDBUS/Servicios/Caminata/ClienteValhalla.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; + +namespace RutasDBUS.Servicios.Caminata; + +public class ClienteValhalla : IClienteValhalla +{ + private readonly HttpClient _httpClient; + private readonly string _baseUrl; + + /// + /// Inicializa una nueva instancia de la clase ClienteValhalla. + /// + public ClienteValhalla(HttpClient httpClient, IConfiguration configuracion) + { + _httpClient = httpClient; + + _baseUrl = configuracion["Caminata:Valhalla:BaseUrl"] + ?? throw new InvalidOperationException( + "Falta la configuración 'Caminata:Valhalla:BaseUrl' en appsettings.json."); + + _baseUrl = _baseUrl.Trim().TrimEnd('/'); + } + + public async Task<(double[,]? Linea, double DistanciaMetros, double DuracionSegundos)> ObtenerRutaAsync( + double latitudDesde, + double longitudDesde, + double latitudHasta, + double longitudHasta, + string costing = "pedestrian") + { + var cuerpo = new SolicitudValhalla + { + Ubicaciones = new List + { + new() { Latitud = latitudDesde, Longitud = longitudDesde }, + new() { Latitud = latitudHasta, Longitud = longitudHasta } + }, + PerfilCoste = string.IsNullOrWhiteSpace(costing) ? "pedestrian" : costing, + FormatoGeometria = "polyline6", + OpcionesIndicaciones = new OpcionesIndicacionesValhalla + { + Idioma = "es-ES", + Unidades = "kilometers" + } + }; + + HttpResponseMessage respuesta; + try + { + respuesta = await _httpClient.PostAsJsonAsync($"{_baseUrl}/route", cuerpo); + } + catch + { + return (null, 0, 0); + } + + if (!respuesta.IsSuccessStatusCode) + return (null, 0, 0); + + RespuestaRutaValhalla? data; + try + { + data = await respuesta.Content.ReadFromJsonAsync(); + } + catch + { + return (null, 0, 0); + } + + var viaje = data?.Viaje; + if (viaje is null || viaje.Tramos is null || viaje.Tramos.Count == 0) + return (null, 0, 0); + + var forma = viaje.Tramos[0]?.Forma; + if (string.IsNullOrWhiteSpace(forma)) + return (null, 0, 0); + + var coords = DecodificarPolyline6(forma); + if (coords.Count == 0) + return (null, 0, 0); + + var linea = new double[coords.Count, 2]; + for (int i = 0; i < coords.Count; i++) + { + linea[i, 0] = coords[i].Latitud; + linea[i, 1] = coords[i].Longitud; + } + + // summary.length depende de units + double longitud = viaje.Resumen?.Longitud ?? 0.0; + string unidades = (viaje.Unidades ?? "").Trim().ToLowerInvariant(); + + double distanciaMetros = unidades switch + { + "miles" => longitud * 1609.344, + _ => longitud * 1000.0 // "kilometers" o desconocido -> km + }; + + double duracionSegundos = viaje.Resumen?.Tiempo ?? 0.0; + + return ( + AnclarExtremosLinea(linea, latitudDesde, longitudDesde, latitudHasta, longitudHasta), + distanciaMetros, + duracionSegundos); + } + + // ======================= + // Modelos request/response + // ======================= + + private sealed class SolicitudValhalla + { + [JsonPropertyName("locations")] + public List Ubicaciones { get; set; } = new(); + + [JsonPropertyName("costing")] + public string PerfilCoste { get; set; } = "pedestrian"; + + [JsonPropertyName("shape_format")] + public string FormatoGeometria { get; set; } = "polyline6"; + + [JsonPropertyName("directions_options")] + public OpcionesIndicacionesValhalla OpcionesIndicaciones { get; set; } = new(); + } + + private sealed class LocalizacionValhalla + { + [JsonPropertyName("lat")] + public double Latitud { get; set; } + + [JsonPropertyName("lon")] + public double Longitud { get; set; } + } + + private sealed class OpcionesIndicacionesValhalla + { + [JsonPropertyName("language")] + public string Idioma { get; set; } = "es-ES"; + + [JsonPropertyName("units")] + public string Unidades { get; set; } = "kilometers"; + } + + private sealed class RespuestaRutaValhalla + { + [JsonPropertyName("trip")] + public ViajeValhalla? Viaje { get; set; } + } + + private sealed class ViajeValhalla + { + [JsonPropertyName("summary")] + public ResumenValhalla? Resumen { get; set; } + + [JsonPropertyName("legs")] + public List Tramos { get; set; } = new(); + + [JsonPropertyName("units")] + public string? Unidades { get; set; } // "kilometers" / "miles" + } + + private sealed class ResumenValhalla + { + [JsonPropertyName("length")] + public double Longitud { get; set; } // en km si units=kilometers + + [JsonPropertyName("time")] + public double Tiempo { get; set; } // segundos + } + + private sealed class TramoValhalla + { + [JsonPropertyName("shape")] + public string? Forma { get; set; } // polyline6 + } + + /// + /// + /// + private readonly record struct Coordenada(double Latitud, double Longitud); + + /// + /// Decodifica polyline6 (factor 1e6). + /// + private static List DecodificarPolyline6(string encoded) + { + var coords = new List(); + if (string.IsNullOrEmpty(encoded)) return coords; + + int index = 0; + int lat = 0, lon = 0; + + try + { + while (index < encoded.Length) + { + lat += LeerSiguienteValor(encoded, ref index); + lon += LeerSiguienteValor(encoded, ref index); + + coords.Add(new Coordenada(lat / 1e6, lon / 1e6)); + } + } + catch + { + // si llega corrupto o incompleto, devolvemos lo que haya (o vacío) + if (coords.Count == 0) return new List(); + } + + return coords; + } + + /// + /// Lee siguiente valor. + /// + private static int LeerSiguienteValor(string encoded, ref int index) + { + int result = 0; + int shift = 0; + int b; + + do + { + if (index >= encoded.Length) throw new InvalidOperationException("Polyline incompleta."); + b = encoded[index++] - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } + while (b >= 0x20); + + // zigzag decode + return (result & 1) != 0 ? ~(result >> 1) : (result >> 1); + } + + /// + /// Fuerza que la geometria empiece y termine exactamente en los puntos solicitados. + /// + private static double[,] AnclarExtremosLinea( + double[,] linea, + double latitudDesde, + double longitudDesde, + double latitudHasta, + double longitudHasta) + { + var puntos = linea.GetLength(0); + if (puntos <= 0) + { + return new double[,] + { + { latitudDesde, longitudDesde }, + { latitudHasta, longitudHasta } + }; + } + + if (puntos == 1) + { + return new double[,] + { + { latitudDesde, longitudDesde }, + { latitudHasta, longitudHasta } + }; + } + + linea[0, 0] = latitudDesde; + linea[0, 1] = longitudDesde; + linea[puntos - 1, 0] = latitudHasta; + linea[puntos - 1, 1] = longitudHasta; + return linea; + } +} diff --git a/RutasDBUS/Servicios/Caminata/IClienteOsrm.cs b/RutasDBUS/Servicios/Caminata/IClienteOsrm.cs new file mode 100644 index 0000000..1c878b2 --- /dev/null +++ b/RutasDBUS/Servicios/Caminata/IClienteOsrm.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; + +namespace RutasDBUS.Servicios.Caminata; + +/// +/// Cliente para consumir el servicio OSRM y obtener rutas a pie. +/// +public interface IClienteOsrm +{ + /// + /// Obtiene una ruta a pie entre dos coordenadas. + /// + /// Latitud de origen. + /// Longitud de origen. + /// Latitud de destino. + /// Longitud de destino. + /// + /// Una tupla con: + /// - Linea: matriz [n,2] con latitud/longitud de la polilínea. + /// - DistanciaMetros: distancia total en metros. + /// - DuracionSegundos: duración total en segundos. + /// + Task<(double[,]? Linea, double DistanciaMetros, double DuracionSegundos)> ObtenerRutaAPieAsync( + double latitudDesde, + double longitudDesde, + double latitudHasta, + double longitudHasta); +} diff --git a/RutasDBUS/Servicios/Caminata/IClienteValhalla.cs b/RutasDBUS/Servicios/Caminata/IClienteValhalla.cs new file mode 100644 index 0000000..e191092 --- /dev/null +++ b/RutasDBUS/Servicios/Caminata/IClienteValhalla.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; + +namespace RutasDBUS.Servicios.Caminata; + +public interface IClienteValhalla +{ + /// + /// Calcula una ruta entre dos puntos usando Valhalla. + /// Devuelve la línea (lat,lon), la distancia (m) y la duración (s). + /// + Task<(double[,]? Linea, double DistanciaMetros, double DuracionSegundos)> ObtenerRutaAsync( + double latitudDesde, + double longitudDesde, + double latitudHasta, + double longitudHasta, + string costing = "pedestrian"); // "pedestrian", "bicycle", etc. +} diff --git a/RutasDBUS/Servicios/Geocodificacion/GeocodificadorLocalServicio.cs b/RutasDBUS/Servicios/Geocodificacion/GeocodificadorLocalServicio.cs new file mode 100644 index 0000000..4f41b39 --- /dev/null +++ b/RutasDBUS/Servicios/Geocodificacion/GeocodificadorLocalServicio.cs @@ -0,0 +1,447 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using RutasDBUS.Modelos.Geocodificacion; + +namespace RutasDBUS.Servicios.Geocodificacion; + +public sealed class GeocodificadorLocalServicio : IGeocodificadorLocalServicio +{ + private const string RutaCallejeroZipRelativa = "Modelos/Callejero/GFA_DSET_AD_CSV.zip"; + private const string NombreCsvB5m = "GFA_DSET_AD.csv"; + private const int MaxResultadosInternos = 60; + + private readonly IWebHostEnvironment _entorno; + private readonly SemaphoreSlim _semaforoCarga = new(1, 1); + private IReadOnlyList? _indice; + + public GeocodificadorLocalServicio(IWebHostEnvironment entorno) + { + _entorno = entorno; + } + + public async Task> BuscarAsync( + string texto, + int maxResultados = 8, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(texto)) + return Array.Empty(); + + var indice = await ObtenerIndiceAsync(cancellationToken); + if (indice.Count == 0) + return Array.Empty(); + + var consulta = NormalizarParaBusqueda(texto); + var tokens = consulta + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(token => token.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (tokens.Length == 0) + return Array.Empty(); + + maxResultados = Math.Clamp(maxResultados, 1, 30); + + return indice + .Select(entrada => new + { + Entrada = entrada, + Puntuacion = Puntuar(entrada, consulta, tokens) + }) + .Where(x => x.Puntuacion < int.MaxValue) + .OrderBy(x => x.Puntuacion) + .ThenBy(x => x.Entrada.Nombre, StringComparer.CurrentCultureIgnoreCase) + .Take(maxResultados) + .Select(x => x.Entrada.Lugar) + .ToList(); + } + + public async Task ContarLugaresAsync(CancellationToken cancellationToken = default) + { + var indice = await ObtenerIndiceAsync(cancellationToken); + return indice.Count; + } + + private async Task> ObtenerIndiceAsync(CancellationToken cancellationToken) + { + if (_indice is not null) + return _indice; + + await _semaforoCarga.WaitAsync(cancellationToken); + try + { + if (_indice is not null) + return _indice; + + _indice = CargarIndice(cancellationToken); + return _indice; + } + finally + { + _semaforoCarga.Release(); + } + } + + private IReadOnlyList CargarIndice(CancellationToken cancellationToken) + { + var rutaZip = Path.Combine( + _entorno.ContentRootPath, + RutaCallejeroZipRelativa.Replace('/', Path.DirectorySeparatorChar)); + + if (!File.Exists(rutaZip)) + return Array.Empty(); + + using var zip = ZipFile.OpenRead(rutaZip); + var entradaCsv = zip.Entries.FirstOrDefault(entrada => + entrada.FullName.EndsWith(NombreCsvB5m, StringComparison.OrdinalIgnoreCase)); + + if (entradaCsv is null) + return Array.Empty(); + + using var stream = entradaCsv.Open(); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var cabecera = reader.ReadLine(); + if (string.IsNullOrWhiteSpace(cabecera)) + return Array.Empty(); + + var columnas = ParsearCsv(cabecera); + var indices = columnas + .Select((nombre, indice) => new { nombre, indice }) + .ToDictionary(x => x.nombre.Trim(), x => x.indice, StringComparer.OrdinalIgnoreCase); + + var resultado = new List(70000); + var vistos = new HashSet(StringComparer.OrdinalIgnoreCase); + + while (!reader.EndOfStream) + { + cancellationToken.ThrowIfCancellationRequested(); + + var linea = reader.ReadLine(); + if (string.IsNullOrWhiteSpace(linea)) + continue; + + var campos = ParsearCsv(linea); + if (!TryCrearEntradaB5m(campos, indices, vistos, out var entrada)) + continue; + + resultado.Add(entrada); + } + + return resultado; + } + + private static bool TryCrearEntradaB5m( + IReadOnlyList campos, + IReadOnlyDictionary indices, + ISet vistos, + out EntradaGeocodificada entrada) + { + entrada = default!; + + var coordenadas = Valor(campos, indices, "CEN_LONLAT"); + if (!TryParsearLonLat(coordenadas, out var lon, out var lat)) + return false; + + var municipio = PrimerValor( + Valor(campos, indices, "MUNI_ES"), + Valor(campos, indices, "MUNI_EU")); + + var nombre = PrimerValor( + Valor(campos, indices, "NAME_ES"), + Valor(campos, indices, "NAME"), + Valor(campos, indices, "NAME_EU")); + + var calle = PrimerValor( + Valor(campos, indices, "STREET_ES"), + Valor(campos, indices, "STREET_EU")); + + var portal = PrimerValor( + Valor(campos, indices, "HOUSE_NUMBER"), + Valor(campos, indices, "BIS")); + + var edificio = PrimerValor( + Valor(campos, indices, "NAME_BUILDING_ES"), + Valor(campos, indices, "NAME_BUILDING_EU")); + + if (string.IsNullOrWhiteSpace(nombre) && + string.IsNullOrWhiteSpace(calle) && + string.IsNullOrWhiteSpace(edificio)) + { + return false; + } + + var id = Valor(campos, indices, "B5MCODE"); + if (string.IsNullOrWhiteSpace(id)) + id = $"{lat:0.000000}|{lon:0.000000}|{nombre}|{edificio}"; + + if (!vistos.Add(id)) + return false; + + var etiqueta = ConstruirEtiqueta(nombre, calle, portal, edificio, municipio); + if (string.IsNullOrWhiteSpace(etiqueta)) + return false; + + var subtitulo = ConstruirSubtitulo(calle, portal, edificio, municipio); + var tipo = string.IsNullOrWhiteSpace(edificio) + ? "Direccion" + : "Lugar"; + + var lugar = new LugarGeocodificado + { + Id = "b5m:" + id, + Nombre = etiqueta, + Subtitulo = subtitulo, + Tipo = tipo, + Fuente = "B5M", + Municipio = municipio, + Latitud = lat, + Longitud = lon + }; + + var textoBusqueda = string.Join(' ', new[] + { + etiqueta, + subtitulo, + nombre, + calle, + portal, + edificio, + municipio + }.Where(valor => !string.IsNullOrWhiteSpace(valor))); + + entrada = new EntradaGeocodificada( + lugar, + etiqueta, + municipio, + NormalizarParaBusqueda(textoBusqueda), + NormalizarParaBusqueda(etiqueta), + NormalizarParaBusqueda(calle), + NormalizarParaBusqueda(edificio), + NormalizarParaBusqueda(portal)); + + return true; + } + + private static int Puntuar(EntradaGeocodificada entrada, string consulta, IReadOnlyList tokens) + { + foreach (var token in tokens) + { + if (!entrada.TextoBusqueda.Contains(token, StringComparison.Ordinal)) + return int.MaxValue; + } + + var puntuacion = 500; + + if (entrada.EtiquetaNormalizada.Equals(consulta, StringComparison.Ordinal)) + puntuacion -= 260; + else if (entrada.EtiquetaNormalizada.StartsWith(consulta, StringComparison.Ordinal)) + puntuacion -= 220; + else if (!string.IsNullOrWhiteSpace(entrada.EdificioNormalizado) && + entrada.EdificioNormalizado.StartsWith(consulta, StringComparison.Ordinal)) + puntuacion -= 210; + else if (!string.IsNullOrWhiteSpace(entrada.CalleNormalizada) && + entrada.CalleNormalizada.StartsWith(consulta, StringComparison.Ordinal)) + puntuacion -= 190; + else if (entrada.TextoBusqueda.StartsWith(consulta, StringComparison.Ordinal)) + puntuacion -= 150; + + if (tokens.Count > 1 && tokens.All(token => + entrada.EtiquetaNormalizada.Contains(token, StringComparison.Ordinal))) + { + puntuacion -= 45; + } + + if (!string.IsNullOrWhiteSpace(entrada.PortalNormalizado) && + tokens.Any(token => token.Equals(entrada.PortalNormalizado, StringComparison.Ordinal))) + { + puntuacion -= 30; + } + + if (EsDonostia(entrada.Municipio)) + puntuacion -= 55; + + puntuacion += Math.Min(60, entrada.EtiquetaNormalizada.Length / 4); + return Math.Max(0, puntuacion); + } + + private static bool EsDonostia(string municipio) + { + var normalizado = NormalizarParaBusqueda(municipio); + return normalizado.Contains("san sebastian", StringComparison.Ordinal) || + normalizado.Contains("donostia", StringComparison.Ordinal); + } + + private static string ConstruirEtiqueta(string nombre, string calle, string portal, string edificio, string municipio) + { + if (!string.IsNullOrWhiteSpace(edificio)) + { + var direccion = ConstruirDireccionCorta(calle, portal); + return string.IsNullOrWhiteSpace(direccion) + ? edificio.Trim() + : $"{edificio.Trim()} - {direccion}"; + } + + if (!string.IsNullOrWhiteSpace(nombre)) + return QuitarMunicipioInicial(nombre, municipio).Trim(); + + return ConstruirDireccionCorta(calle, portal); + } + + private static string ConstruirSubtitulo(string calle, string portal, string edificio, string municipio) + { + var partes = new List(); + var direccion = ConstruirDireccionCorta(calle, portal); + + if (!string.IsNullOrWhiteSpace(edificio) && + !string.IsNullOrWhiteSpace(direccion)) + { + partes.Add(direccion); + } + + if (!string.IsNullOrWhiteSpace(municipio)) + partes.Add(municipio.Trim()); + + return string.Join(" · ", partes); + } + + private static string ConstruirDireccionCorta(string calle, string portal) + { + if (string.IsNullOrWhiteSpace(calle)) + return portal.Trim(); + + calle = calle.Trim(); + portal = portal.Trim(); + + return string.IsNullOrWhiteSpace(portal) + ? calle + : $"{calle} {portal}"; + } + + private static string QuitarMunicipioInicial(string nombre, string municipio) + { + if (string.IsNullOrWhiteSpace(nombre) || string.IsNullOrWhiteSpace(municipio)) + return nombre; + + nombre = nombre.Trim(); + municipio = municipio.Trim(); + + return nombre.StartsWith(municipio + " ", StringComparison.OrdinalIgnoreCase) + ? nombre[municipio.Length..].Trim() + : nombre; + } + + private static bool TryParsearLonLat(string texto, out double longitud, out double latitud) + { + longitud = 0; + latitud = 0; + + var partes = (texto ?? "").Split(',', StringSplitOptions.TrimEntries); + if (partes.Length < 2) + return false; + + return double.TryParse(partes[0], NumberStyles.Float, CultureInfo.InvariantCulture, out longitud) && + double.TryParse(partes[1], NumberStyles.Float, CultureInfo.InvariantCulture, out latitud) && + double.IsFinite(longitud) && + double.IsFinite(latitud); + } + + private static string Valor(IReadOnlyList campos, IReadOnlyDictionary indices, string columna) + { + if (!indices.TryGetValue(columna, out var indice) || + indice < 0 || + indice >= campos.Count) + { + return ""; + } + + return campos[indice].Trim(); + } + + private static string PrimerValor(params string[] valores) + => valores.FirstOrDefault(valor => !string.IsNullOrWhiteSpace(valor))?.Trim() ?? ""; + + private static List ParsearCsv(string linea) + { + var campos = new List(); + var actual = new StringBuilder(linea.Length); + var entreComillas = false; + + for (var i = 0; i < linea.Length; i++) + { + var ch = linea[i]; + if (ch == '"') + { + if (entreComillas && i + 1 < linea.Length && linea[i + 1] == '"') + { + actual.Append('"'); + i++; + } + else + { + entreComillas = !entreComillas; + } + + continue; + } + + if (ch == ',' && !entreComillas) + { + campos.Add(actual.ToString()); + actual.Clear(); + continue; + } + + actual.Append(ch); + } + + campos.Add(actual.ToString()); + return campos; + } + + private static string NormalizarParaBusqueda(string texto) + { + if (string.IsNullOrWhiteSpace(texto)) + return ""; + + texto = texto.Trim().ToLowerInvariant(); + const char marcadorEnye = '\uE000'; + texto = texto.Replace('\u00f1', marcadorEnye); + + var formD = texto.Normalize(NormalizationForm.FormD); + var sb = new StringBuilder(formD.Length); + foreach (var ch in formD) + { + var uc = CharUnicodeInfo.GetUnicodeCategory(ch); + if (uc == UnicodeCategory.NonSpacingMark) + continue; + + if (char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch)) + { + sb.Append(ch); + } + else + { + sb.Append(' '); + } + } + + return string.Join(' ', sb + .ToString() + .Normalize(NormalizationForm.FormC) + .Replace(marcadorEnye, '\u00f1') + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + + private sealed record EntradaGeocodificada( + LugarGeocodificado Lugar, + string Nombre, + string Municipio, + string TextoBusqueda, + string EtiquetaNormalizada, + string CalleNormalizada, + string EdificioNormalizado, + string PortalNormalizado); +} diff --git a/RutasDBUS/Servicios/Geocodificacion/IGeocodificadorLocalServicio.cs b/RutasDBUS/Servicios/Geocodificacion/IGeocodificadorLocalServicio.cs new file mode 100644 index 0000000..6e6e09d --- /dev/null +++ b/RutasDBUS/Servicios/Geocodificacion/IGeocodificadorLocalServicio.cs @@ -0,0 +1,13 @@ +using RutasDBUS.Modelos.Geocodificacion; + +namespace RutasDBUS.Servicios.Geocodificacion; + +public interface IGeocodificadorLocalServicio +{ + Task> BuscarAsync( + string texto, + int maxResultados = 8, + CancellationToken cancellationToken = default); + + Task ContarLugaresAsync(CancellationToken cancellationToken = default); +} diff --git a/RutasDBUS/Servicios/Historial/IHistorialRutasCompartidoService.cs b/RutasDBUS/Servicios/Historial/IHistorialRutasCompartidoService.cs new file mode 100644 index 0000000..b4959d9 --- /dev/null +++ b/RutasDBUS/Servicios/Historial/IHistorialRutasCompartidoService.cs @@ -0,0 +1,10 @@ +using RutasDBUS.Modelos.Historial; + +namespace RutasDBUS.Servicios.Historial; + +public interface IHistorialRutasCompartidoService +{ + Task> ObtenerAsync(CancellationToken ct = default); + Task GuardarAsync(RegistroHistorialRuta registro, CancellationToken ct = default); + Task EliminarAsync(string id, CancellationToken ct = default); +} diff --git a/RutasDBUS/Servicios/Historial/ServicioHistorialRutasCompartido.cs b/RutasDBUS/Servicios/Historial/ServicioHistorialRutasCompartido.cs new file mode 100644 index 0000000..0dd685a --- /dev/null +++ b/RutasDBUS/Servicios/Historial/ServicioHistorialRutasCompartido.cs @@ -0,0 +1,343 @@ +using System.Globalization; +using System.Text.Json; +using RutasDBUS.Modelos.Historial; + +namespace RutasDBUS.Servicios.Historial; + +/// +/// Mantiene un historial compartido de escenarios de origen/destino para toda la app. +/// +public sealed class ServicioHistorialRutasCompartido : IHistorialRutasCompartidoService +{ + private const int MaxItemsCompartidos = 100; + private const int IntentosAccesoFichero = 5; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + private readonly SemaphoreSlim _mutex = new(1, 1); + private readonly string _rutaFichero; + + public ServicioHistorialRutasCompartido(IWebHostEnvironment env) + { + _rutaFichero = ResolverRutaFichero(env); + } + + public async Task> ObtenerAsync(CancellationToken ct = default) + { + await _mutex.WaitAsync(ct); + try + { + var items = await LeerInternoAsync(ct); + return items + .OrderByDescending(x => x.FechaActualizacionUtc) + .Select(Copiar) + .ToList(); + } + finally + { + _mutex.Release(); + } + } + + public async Task GuardarAsync(RegistroHistorialRuta registro, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(registro); + + await _mutex.WaitAsync(ct); + try + { + var items = await LeerInternoAsync(ct); + var clave = CalcularClaveEscenario(registro); + var ahora = DateTime.UtcNow; + var fechaCreacionEntrada = registro.FechaCreacionUtc != default + ? registro.FechaCreacionUtc + : ahora; + var fechaActualizacionEntrada = registro.FechaActualizacionUtc != default + ? registro.FechaActualizacionUtc + : fechaCreacionEntrada; + + var existente = items.FirstOrDefault(x => + string.Equals(CalcularClaveEscenario(x), clave, StringComparison.OrdinalIgnoreCase)); + + if (existente is null) + { + existente = Copiar(registro); + existente.Id = string.IsNullOrWhiteSpace(existente.Id) + ? Guid.NewGuid().ToString("N") + : existente.Id; + existente.FechaCreacionUtc = fechaCreacionEntrada; + existente.FechaActualizacionUtc = fechaActualizacionEntrada; + items.Insert(0, existente); + } + else + { + existente.OrigenTexto = registro.OrigenTexto; + existente.DestinoTexto = registro.DestinoTexto; + existente.OrigenLatitud = registro.OrigenLatitud; + existente.OrigenLongitud = registro.OrigenLongitud; + existente.DestinoLatitud = registro.DestinoLatitud; + existente.DestinoLongitud = registro.DestinoLongitud; + existente.UsarHorariosTeoricos = registro.UsarHorariosTeoricos; + existente.UsarHoraSimulada = registro.UsarHoraSimulada; + existente.FechaSimulada = registro.FechaSimulada; + existente.HoraSimulada = registro.HoraSimulada; + existente.CriterioPrioritarioRuta = registro.CriterioPrioritarioRuta; + existente.PreferenciaEtiquetaRuta = registro.PreferenciaEtiquetaRuta; + existente.ActivarFiltroLineas = registro.ActivarFiltroLineas; + existente.LineasPermitidas = NormalizarLineas(registro.LineasPermitidas).ToList(); + + if (registro.FechaCreacionUtc != default) + existente.FechaCreacionUtc = registro.FechaCreacionUtc; + if (registro.FechaActualizacionUtc != default) + existente.FechaActualizacionUtc = registro.FechaActualizacionUtc; + } + + var finales = items + .OrderByDescending(x => x.FechaActualizacionUtc) + .Take(MaxItemsCompartidos) + .ToList(); + + await EscribirInternoAsync(finales, ct); + return Copiar(existente); + } + finally + { + _mutex.Release(); + } + } + + public async Task EliminarAsync(string id, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(id)) + return false; + + await _mutex.WaitAsync(ct); + try + { + var items = await LeerInternoAsync(ct); + var eliminados = items.RemoveAll(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); + if (eliminados == 0) + return false; + + await EscribirInternoAsync(items, ct); + return true; + } + finally + { + _mutex.Release(); + } + } + + private async Task> LeerInternoAsync(CancellationToken ct) + { + if (!File.Exists(_rutaFichero)) + return new List(); + + return await EjecutarConReintentosAsync(async () => + { + await using var stream = new FileStream( + _rutaFichero, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + 4096, + useAsync: true); + + var items = await JsonSerializer.DeserializeAsync>(stream, JsonOptions, ct); + return items ?? new List(); + }, ct); + } + + private async Task EscribirInternoAsync(List items, CancellationToken ct) + { + var carpeta = Path.GetDirectoryName(_rutaFichero); + if (!string.IsNullOrWhiteSpace(carpeta)) + Directory.CreateDirectory(carpeta); + + await EjecutarConReintentosAsync(async () => + { + var temporal = Path.Combine( + carpeta ?? Path.GetTempPath(), + $"{Path.GetFileName(_rutaFichero)}.{Guid.NewGuid():N}.tmp"); + + try + { + await using (var stream = new FileStream( + temporal, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + useAsync: true)) + { + await JsonSerializer.SerializeAsync(stream, items, JsonOptions, ct); + } + + File.Move(temporal, _rutaFichero, overwrite: true); + } + finally + { + TryDelete(temporal); + } + }, ct); + } + + private static async Task EjecutarConReintentosAsync(Func> accion, CancellationToken ct) + { + for (var intento = 1; ; intento++) + { + try + { + return await accion(); + } + catch (IOException) when (intento < IntentosAccesoFichero) + { + await Task.Delay(40 * intento, ct); + } + catch (UnauthorizedAccessException) when (intento < IntentosAccesoFichero) + { + await Task.Delay(40 * intento, ct); + } + } + } + + private static async Task EjecutarConReintentosAsync(Func accion, CancellationToken ct) + { + await EjecutarConReintentosAsync(async () => + { + await accion(); + return true; + }, ct); + } + + private static void TryDelete(string ruta) + { + try + { + if (File.Exists(ruta)) + File.Delete(ruta); + } + catch + { + } + } + + private static string ResolverRutaFichero(IWebHostEnvironment env) + { + foreach (var carpeta in EnumerarCarpetasCandidatas(env)) + { + if (TryEnsureWritableDirectory(carpeta)) + return Path.Combine(carpeta, "historial-rutas-compartido.json"); + } + + return Path.Combine(env.ContentRootPath, "Data", "historial-rutas-compartido.json"); + } + + private static IEnumerable EnumerarCarpetasCandidatas(IWebHostEnvironment env) + { + yield return Path.Combine(env.ContentRootPath, "Data"); + + var commonAppData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + if (!string.IsNullOrWhiteSpace(commonAppData)) + yield return Path.Combine(commonAppData, "RutasDBUS"); + + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrWhiteSpace(localAppData)) + yield return Path.Combine(localAppData, "RutasDBUS"); + + yield return Path.Combine(Path.GetTempPath(), "RutasDBUS"); + } + + private static bool TryEnsureWritableDirectory(string carpeta) + { + try + { + Directory.CreateDirectory(carpeta); + var rutaPrueba = Path.Combine(carpeta, $".write-test-{Guid.NewGuid():N}.tmp"); + + using (var stream = new FileStream( + rutaPrueba, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 256, + FileOptions.DeleteOnClose)) + { + stream.WriteByte(1); + } + + if (File.Exists(rutaPrueba)) + File.Delete(rutaPrueba); + + return true; + } + catch + { + return false; + } + } + + private static RegistroHistorialRuta Copiar(RegistroHistorialRuta item) + => new() + { + Id = item.Id, + OrigenTexto = item.OrigenTexto, + DestinoTexto = item.DestinoTexto, + OrigenLatitud = item.OrigenLatitud, + OrigenLongitud = item.OrigenLongitud, + DestinoLatitud = item.DestinoLatitud, + DestinoLongitud = item.DestinoLongitud, + UsarHorariosTeoricos = item.UsarHorariosTeoricos, + UsarHoraSimulada = item.UsarHoraSimulada, + FechaSimulada = item.FechaSimulada, + HoraSimulada = item.HoraSimulada, + CriterioPrioritarioRuta = item.CriterioPrioritarioRuta, + PreferenciaEtiquetaRuta = item.PreferenciaEtiquetaRuta, + ActivarFiltroLineas = item.ActivarFiltroLineas, + LineasPermitidas = NormalizarLineas(item.LineasPermitidas).ToList(), + FechaCreacionUtc = item.FechaCreacionUtc, + FechaActualizacionUtc = item.FechaActualizacionUtc + }; + + private static string CalcularClaveEscenario(RegistroHistorialRuta item) + { + var origenLat = Math.Round(item.OrigenLatitud, 6).ToString("0.000000", CultureInfo.InvariantCulture); + var origenLon = Math.Round(item.OrigenLongitud, 6).ToString("0.000000", CultureInfo.InvariantCulture); + var destinoLat = Math.Round(item.DestinoLatitud, 6).ToString("0.000000", CultureInfo.InvariantCulture); + var destinoLon = Math.Round(item.DestinoLongitud, 6).ToString("0.000000", CultureInfo.InvariantCulture); + var fecha = item.UsarHoraSimulada ? item.FechaSimulada?.Trim() ?? string.Empty : string.Empty; + var hora = item.UsarHoraSimulada ? item.HoraSimulada?.Trim() ?? string.Empty : string.Empty; + var criterio = NormalizarTextoClave(item.CriterioPrioritarioRuta); + if (string.IsNullOrWhiteSpace(criterio)) + criterio = NormalizarTextoClave(item.PreferenciaEtiquetaRuta); + var lineas = item.ActivarFiltroLineas + ? string.Join(",", NormalizarLineas(item.LineasPermitidas)) + : string.Empty; + + return string.Join("|", + origenLat, + origenLon, + destinoLat, + destinoLon, + item.UsarHorariosTeoricos ? "1" : "0", + item.UsarHoraSimulada ? "1" : "0", + fecha, + hora, + criterio, + item.ActivarFiltroLineas ? "1" : "0", + lineas); + } + + private static IReadOnlyList NormalizarLineas(IEnumerable? lineas) + => (lineas ?? Array.Empty()) + .Where(linea => linea > 0) + .Distinct() + .OrderBy(linea => linea) + .ToList(); + + private static string NormalizarTextoClave(string? texto) + => texto?.Trim().ToUpperInvariant() ?? string.Empty; +} diff --git a/RutasDBUS/Servicios/Horarios/ServicioHorariosDbus.cs b/RutasDBUS/Servicios/Horarios/ServicioHorariosDbus.cs new file mode 100644 index 0000000..17da525 --- /dev/null +++ b/RutasDBUS/Servicios/Horarios/ServicioHorariosDbus.cs @@ -0,0 +1,1792 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Text.RegularExpressions; +using RutasDBUS.Modelos.Importacion; +using RutasDBUS.Servicios.Importacion; + +namespace RutasDBUS.Servicios.Horarios +{ + /// + /// Lee el ZIP de importación de dBUS (CSV + opcional gtfs.zip) y expone: + /// - Calendarios activos para una fecha + /// - Próximas salidas desde una parada + /// + /// Coloca el zip en: Modelos/horarios/ + /// Ejemplo: Modelos/horarios/zip_Importacion_20260213~0900.zip + /// + public sealed class ServicioHorariosDbus + { + private readonly IServicioArchivoImportacionDbus _archiveService; + + /// + /// Inicializa una nueva instancia de la clase ServicioHorariosDbus. + /// + public ServicioHorariosDbus(IServicioArchivoImportacionDbus archiveService) + { + _archiveService = archiveService; + } + + // ========================= + // Config + // ========================= + private readonly SemaphoreSlim _semCarga = new(1, 1); + private bool _loaded; + private ArchivoImportacionDbus? _archivoCargado; + + /// + /// Compatibilidad con la API anterior. La resolución real del ZIP la hace la capa de importación compartida. + /// + public string CarpetaRelativa { get; set; } = Path.Combine("Modelos", "horarios"); + + /// + /// Compatibilidad con la API anterior. La selección real del ZIP la hace la capa de importación compartida. + /// + public string? NombreArchivoZip { get; set; } = null; + + // ========================= + // Modelos mínimos (CSV dBUS) + // ========================= + /// + /// Inicializa una nueva instancia de la clase FilaCalendario. + /// + public sealed record FilaCalendario(string Id, int Lun, int Mar, int Mie, int Jue, int Vie, int Sab, int Dom, + DateOnly Inicio, DateOnly Fin); + + /// + /// Inicializa una nueva instancia de la clase FilaCalendarioExcepcion. + /// + public sealed record FilaCalendarioExcepcion(string Id, DateOnly Fecha, int Tipo); // 1=activar, 2=desactivar + + /// + /// Inicializa una nueva instancia de la clase FilaExpedicion. + /// + public sealed record FilaExpedicion( + string IdExp, + string IdRuta, + int Direccion, + string DirDestino, + string IdPatronExp, + TimeOnly? HSalida, + string IdCal + ); + + /// + /// Inicializa una nueva instancia de la clase FilaTiempoParada. + /// + public sealed record FilaTiempoParada(string IdExp, int StopId, int Sec, TimeOnly Llegada, TimeOnly Salida); + + /// + /// Inicializa una nueva instancia de la clase FilaParada. + /// + public sealed record FilaParada(int Id, string? CodigoSms, string Nombre); + + // ========================= + // Índices + // ========================= + private readonly Dictionary _cal = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _calExByDate = new(); + + private readonly Dictionary _expById = new(StringComparer.OrdinalIgnoreCase); + + // stopTimes por exp + private readonly Dictionary> _stopTimesByExp = new(StringComparer.OrdinalIgnoreCase); + + // Salidas por parada: stopId -> (expId, dep) + private readonly Dictionary> _departuresByStop = new(); + + // Paradas + private readonly Dictionary _stopById = new(); + // Mapas útiles para tu app: "34" -> stopId + private readonly Dictionary _stopIdByCodigo = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _rutaCodigoVisibleById = new(StringComparer.OrdinalIgnoreCase); + + // ========================= + // API pública + // ========================= + + /// + /// Carga (una vez) desde el ZIP. Llama al inicio de la app o al primer uso. + /// contentRootPath: normalmente env.ContentRootPath + /// + public async Task AsegurarCargaAsync(string contentRootPath, CancellationToken ct = default) + { + var archive = await _archiveService.ObtenerAsync(ct); + if (_loaded && ReferenceEquals(_archivoCargado, archive)) return; + + await _semCarga.WaitAsync(ct); + try + { + if (_loaded && ReferenceEquals(_archivoCargado, archive)) return; + + CargarDesdeArchivo(archive); + _archivoCargado = archive; + _loaded = true; + } + finally + { + _semCarga.Release(); + } + } + + /// + /// Carga from el archivo. + /// + private void CargarDesdeArchivo(ArchivoImportacionDbus archive) + { + _cal.Clear(); + _calExByDate.Clear(); + _expById.Clear(); + _stopTimesByExp.Clear(); + _departuresByStop.Clear(); + _stopById.Clear(); + _stopIdByCodigo.Clear(); + _rutaCodigoVisibleById.Clear(); + _tripMetaByTripId.Clear(); + + foreach (var row in archive.Calendarios) + { + _cal[row.Id] = new FilaCalendario( + row.Id, + row.Lunes, + row.Martes, + row.Miercoles, + row.Jueves, + row.Viernes, + row.Sabado, + row.Domingo, + row.FechaInicio, + row.FechaFin); + } + + foreach (var row in archive.CalendarioExcepciones) + { + var calEx = new FilaCalendarioExcepcion(row.Id, row.FechaEx, row.TipoExcepcion); + if (!_calExByDate.TryGetValue(row.FechaEx, out var list)) + _calExByDate[row.FechaEx] = list = new List(); + + list.Add(calEx); + } + + foreach (var row in archive.Expediciones) + { + _expById[row.Id] = new FilaExpedicion( + row.Id, + row.IdRuta, + row.Direccion, + row.DirDestino, + row.IdPatronExp, + row.HSalida, + row.IdCal); + } + + foreach (var row in archive.Paradas) + { + _stopById[row.Id] = new FilaParada(row.Id, row.CodigoSms, row.NombrePar); + + if (!string.IsNullOrWhiteSpace(row.CodigoSms)) + _stopIdByCodigo[NormalizarCodigoParada(row.CodigoSms)] = row.Id; + + _stopIdByCodigo[row.Id.ToString(CultureInfo.InvariantCulture)] = row.Id; + } + + foreach (var row in archive.Rutas) + { + var codigoVisible = !string.IsNullOrWhiteSpace(row.Abreviatura) + ? NormalizarLinea(row.Abreviatura) + : NormalizarLinea(row.Id); + + if (!string.IsNullOrWhiteSpace(row.Id)) + _rutaCodigoVisibleById[row.Id] = codigoVisible; + } + + foreach (var group in archive.TiemposParadaPorExpedicion) + { + _stopTimesByExp[group.Key] = group.Value + .OrderBy(x => x.SecPar) + .Select(x => new FilaTiempoParada( + x.IdExp, + x.IdTiempoPar, + x.SecPar, + x.HoraLlegada ?? TimeOnly.MinValue, + x.HoraSalida ?? x.HoraLlegada ?? TimeOnly.MinValue)) + .ToList(); + } + + ConstruirIndiceSalidas(); + } + + /// + /// Garantiza que los horarios estén cargados antes de consultar índices en memoria. + /// + private void EnsureLoadedOrThrow() + { + if (_loaded) + return; + + throw new InvalidOperationException( + "Los horarios de dBUS no están cargados. Llama antes a AsegurarCargaAsync(...)."); + } + + /// + /// Devuelve el stopId interno a partir del "codigo" que usa tu app (ej: "34" o "P-34"). + /// + public int? IntentarResolverIdParada(string? codigoParada) + { + if (string.IsNullOrWhiteSpace(codigoParada)) return null; + var c = NormalizarCodigoParada(codigoParada); + + if (_stopIdByCodigo.TryGetValue(c, out var id)) + return id; + + // fallback: si el código era numérico y coincide con Id + if (int.TryParse(c, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) && _stopById.ContainsKey(n)) + return n; + + return null; + } + + /// + /// Intenta obtener get parada. + /// + public FilaParada? TryGetStop(int stopId) + => _stopById.TryGetValue(stopId, out var s) ? s : null; + + /// + /// Calendarios activos en fecha d (aplicando calendario_excepciones_puntuales). + /// + public HashSet GetActiveCalendars(DateOnly d) + { + EnsureLoadedOrThrow(); + + var active = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var c in _cal.Values) + { + if (d < c.Inicio || d > c.Fin) continue; + + int flag = d.DayOfWeek switch + { + DayOfWeek.Monday => c.Lun, + DayOfWeek.Tuesday => c.Mar, + DayOfWeek.Wednesday => c.Mie, + DayOfWeek.Thursday => c.Jue, + DayOfWeek.Friday => c.Vie, + DayOfWeek.Saturday => c.Sab, + DayOfWeek.Sunday => c.Dom, + _ => 1 + }; + + if (flag == 2) active.Add(c.Id); + } + + if (_calExByDate.TryGetValue(d, out var exs)) + { + foreach (var ex in exs) + { + if (ex.Tipo == 1) active.Add(ex.Id); + else if (ex.Tipo == 2) active.Remove(ex.Id); + } + } + + return active; + } + + /// + /// Próximas salidas desde una parada (por stopId interno). + /// Devuelve: (horaSalidaEnParada, expId, rutaId) + /// + public List<(TimeOnly dep, string expId, string rutaId)> GetNextDepartures( + int stopId, DateOnly date, TimeOnly now, int? take = 8, bool includePast = false) + { + EnsureLoadedOrThrow(); + + var activeCals = GetActiveCalendars(date); + + if (!_departuresByStop.TryGetValue(stopId, out var deps)) + return new(); + + var res = new List<(TimeOnly, string, string)>(); + + foreach (var (expId, dep) in deps) + { + if (!includePast && dep < now) continue; + + if (!_expById.TryGetValue(expId, out var exp)) continue; + if (!activeCals.Contains(exp.IdCal)) continue; + + res.Add((dep, expId, exp.IdRuta)); + + if (take is not null && res.Count >= take.Value) + break; + } + + return res; + } + + /// + /// Próximas salidas desde una parada (por tu código: "34" o "P-34"). + /// + public List<(TimeOnly dep, string expId, string rutaId)> GetNextDeparturesByCodigo( + string codigoParada, DateOnly date, TimeOnly now, int? take = 8, bool includePast = false) + { + var id = IntentarResolverIdParada(codigoParada); + if (id is null) return new(); + return GetNextDepartures(id.Value, date, now, take, includePast); + } + // ========================= + // Carga desde ZIP + // ========================= + + /// + /// Carga from zip. + /// + private async Task LoadFromZipAsync(string zipAbsPath, CancellationToken ct) + { + // limpiar + _cal.Clear(); + _calExByDate.Clear(); + _expById.Clear(); + _stopTimesByExp.Clear(); + _departuresByStop.Clear(); + _stopById.Clear(); + _stopIdByCodigo.Clear(); + _tripMetaByTripId.Clear(); + + using var fs = File.OpenRead(zipAbsPath); + using var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: false); + + // localizar entradas (por si están en subcarpetas) + ZipArchiveEntry? eCalendario = FindEntry(zip, "calendario.csv"); + ZipArchiveEntry? eCalEx = FindEntry(zip, "calendario_excepciones_puntuales.csv"); + ZipArchiveEntry? eExp = FindEntry(zip, "expedicion.csv"); + ZipArchiveEntry? eStopTime = FindEntry(zip, "tiempo_parada.csv"); + ZipArchiveEntry? eStop = FindEntry(zip, "parada.csv"); + + // Si no están, intentar en gtfs.zip + ZipArchiveEntry? eGtfsZip = FindEntry(zip, "gtfs.zip"); + + // 1) Paradas (para mapping) + if (eStop is not null) + { + await LoadParadasFromDbusAsync(eStop, ct); + } + + // 2) Calendarios + if (eCalendario is not null) + await LoadCalendariosDbusAsync(eCalendario, ct); + + // 3) Excepciones + if (eCalEx is not null) + await LoadCalExDbusAsync(eCalEx, ct); + + // 4) Expediciones + if (eExp is not null) + await LoadExpedicionesDbusAsync(eExp, ct); + + // 5) Tiempo parada + if (eStopTime is not null) + await LoadTiempoParadaDbusAsync(eStopTime, ct); + + // Si falta algo esencial, intentamos GTFS + bool needGtfs = + _cal.Count == 0 || + _expById.Count == 0 || + _stopTimesByExp.Count == 0 || + _stopById.Count == 0; + + if (needGtfs && eGtfsZip is not null) + { + await LoadFromGtfsZipInsideAsync(eGtfsZip, ct); + } + + // construir índices de salidas + ConstruirIndiceSalidas(); + } + + /// + /// Carga from gtfs zip inside. + /// + private async Task LoadFromGtfsZipInsideAsync(ZipArchiveEntry gtfsZipEntry, CancellationToken ct) + { + using var ms = new MemoryStream(); + await using (var s = gtfsZipEntry.Open()) + await s.CopyToAsync(ms, ct); + + ms.Position = 0; + using var gtfs = new ZipArchive(ms, ZipArchiveMode.Read, leaveOpen: false); + + // GTFS estándar + var stops = FindEntry(gtfs, "stops.txt"); + var cal = FindEntry(gtfs, "calendar.txt"); + var calDates = FindEntry(gtfs, "calendar_dates.txt"); + var trips = FindEntry(gtfs, "trips.txt"); + var stopTimes = FindEntry(gtfs, "stop_times.txt"); + + if (stops is not null && _stopById.Count == 0) + await LoadStopsGtfsAsync(stops, ct); + + if (cal is not null && _cal.Count == 0) + await LoadCalendarGtfsAsync(cal, ct); + + if (calDates is not null && _calExByDate.Count == 0) + await LoadCalendarDatesGtfsAsync(calDates, ct); + + if (trips is not null && _expById.Count == 0) + await LoadTripsGtfsAsync(trips, ct); + + if (stopTimes is not null && _stopTimesByExp.Count == 0) + await LoadStopTimesGtfsAsync(stopTimes, ct); + } + + /// + /// Busca entrada. + /// + private static ZipArchiveEntry? FindEntry(ZipArchive zip, string fileName) + { + // busca por "termina en /fileName" o exacto + return zip.Entries.FirstOrDefault(e => + e.FullName.EndsWith("/" + fileName, StringComparison.OrdinalIgnoreCase) || + e.FullName.Equals(fileName, StringComparison.OrdinalIgnoreCase)); + } + + // ========================= + // Parse CSV (con comillas) + // ========================= + /// + /// Lee csv all. + /// + private static async Task> ReadCsvAllAsync(ZipArchiveEntry entry, CancellationToken ct) + { + using var stream = entry.Open(); + using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var rows = new List(8192); + string? line; + + // header + line = await sr.ReadLineAsync(); + if (line is null) return rows; + + while ((line = await sr.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (line.Length == 0) continue; + + var fields = SplitCsvLine(line); + rows.Add(fields); + } + + return rows; + } + + /// + /// Gestiona csv linea. + /// + private static string[] SplitCsvLine(string line) + { + // separador coma, con soporte básico de comillas dobles + var res = new List(32); + var sb = new StringBuilder(line.Length); + + bool inQuotes = false; + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + + if (c == '"') + { + // doble comilla escapada + if (inQuotes && i + 1 < line.Length && line[i + 1] == '"') + { + sb.Append('"'); + i++; + continue; + } + inQuotes = !inQuotes; + continue; + } + + if (c == ',' && !inQuotes) + { + res.Add(sb.ToString()); + sb.Clear(); + continue; + } + + sb.Append(c); + } + + res.Add(sb.ToString()); + return res.Select(x => x.Trim()).ToArray(); + } + + /// + /// Analiza date yyyy mm dd. + /// + private static DateOnly ParseDate_YYYY_MM_DD(string s) + => DateOnly.ParseExact(s.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture); + + /// + /// Analiza date yyyymmdd. + /// + private static DateOnly ParseDate_YYYYMMDD(string s) + => DateOnly.ParseExact(s.Trim(), "yyyyMMdd", CultureInfo.InvariantCulture); + + /// + /// Analiza tiempo hh mm ss. + /// + private static TimeOnly ParseTime_HH_MM_SS(string s) + => TimeOnly.ParseExact(s.Trim(), "HH:mm:ss", CultureInfo.InvariantCulture); + + /// + /// Analiza tiempo nullable hh mm ss. + /// + private static TimeOnly? ParseTimeNullable_HH_MM_SS(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return null; + return ParseTime_HH_MM_SS(s); + } + + // ========================= + // Loaders dBUS CSV + // ========================= + /// + /// Carga paradas from dbus. + /// + private async Task LoadParadasFromDbusAsync(ZipArchiveEntry paradaCsv, CancellationToken ct) + { + // parada.csv: id,codigo_sms,nombre_par,latitud_y_par,longitud_x_par,... + // Nos quedamos con id, codigo_sms, nombre_par + using var stream = paradaCsv.Open(); + using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var header = await sr.ReadLineAsync(); + if (header is null) return; + + var cols = SplitCsvLine(header); + int iId = IndexOf(cols, "id"); + int iSms = IndexOf(cols, "codigo_sms"); + int iNombre = IndexOf(cols, "nombre_par"); + + string? line; + while ((line = await sr.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (line.Length == 0) continue; + var f = SplitCsvLine(line); + + if (iId < 0 || iId >= f.Length) continue; + if (!int.TryParse(f[iId], NumberStyles.Integer, CultureInfo.InvariantCulture, out var id)) continue; + + var sms = (iSms >= 0 && iSms < f.Length) ? f[iSms] : null; + var nombre = (iNombre >= 0 && iNombre < f.Length) ? f[iNombre] : ""; + + var row = new FilaParada(id, string.IsNullOrWhiteSpace(sms) ? null : sms, nombre); + _stopById[id] = row; + + // Map por código SMS normalizado + if (!string.IsNullOrWhiteSpace(sms)) + { + var cod = NormalizarCodigoParada(sms); + _stopIdByCodigo[cod] = id; + } + + // Map también por id "como string" + _stopIdByCodigo[id.ToString(CultureInfo.InvariantCulture)] = id; + } + } + + /// + /// Carga calendarios dbus. + /// + private async Task LoadCalendariosDbusAsync(ZipArchiveEntry calendarioCsv, CancellationToken ct) + { + using var stream = calendarioCsv.Open(); + using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var header = await sr.ReadLineAsync(); + if (header is null) return; + + var cols = SplitCsvLine(header); + int iId = IndexOf(cols, "id"); + int iL = IndexOf(cols, "lunes"); + int iMa = IndexOf(cols, "martes"); + int iMi = IndexOf(cols, "miercoles"); + int iJ = IndexOf(cols, "jueves"); + int iV = IndexOf(cols, "viernes"); + int iS = IndexOf(cols, "sabado"); + int iD = IndexOf(cols, "domingo"); + int iIni = IndexOf(cols, "fecha_inicio"); + int iFin = IndexOf(cols, "fecha_fin"); + + string? line; + while ((line = await sr.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (line.Length == 0) continue; + var f = SplitCsvLine(line); + + if (iId < 0 || iId >= f.Length) continue; + var id = f[iId]; + if (string.IsNullOrWhiteSpace(id)) continue; + + int GetInt(int idx, int def = 1) + { + if (idx < 0 || idx >= f.Length) return def; + return int.TryParse(f[idx], NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : def; + } + + if (iIni < 0 || iIni >= f.Length) continue; + if (iFin < 0 || iFin >= f.Length) continue; + + var inicio = ParseDate_YYYY_MM_DD(f[iIni]); + var fin = ParseDate_YYYY_MM_DD(f[iFin]); + + var row = new FilaCalendario( + id, + GetInt(iL), GetInt(iMa), GetInt(iMi), GetInt(iJ), GetInt(iV), GetInt(iS), GetInt(iD), + inicio, fin + ); + + _cal[id] = row; + } + } + + /// + /// Carga cal ex dbus. + /// + private async Task LoadCalExDbusAsync(ZipArchiveEntry calExCsv, CancellationToken ct) + { + // calendario_excepciones_puntuales.csv: id,fecha_ex,tipo_excepcion + using var stream = calExCsv.Open(); + using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var header = await sr.ReadLineAsync(); + if (header is null) return; + + var cols = SplitCsvLine(header); + int iId = IndexOf(cols, "id"); + int iFecha = IndexOf(cols, "fecha_ex"); + int iTipo = IndexOf(cols, "tipo_excepcion"); + + string? line; + while ((line = await sr.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (line.Length == 0) continue; + var f = SplitCsvLine(line); + + if (iId < 0 || iId >= f.Length) continue; + if (iFecha < 0 || iFecha >= f.Length) continue; + if (iTipo < 0 || iTipo >= f.Length) continue; + + var id = f[iId]; + if (string.IsNullOrWhiteSpace(id)) continue; + + var fecha = ParseDate_YYYY_MM_DD(f[iFecha]); + + if (!int.TryParse(f[iTipo], NumberStyles.Integer, CultureInfo.InvariantCulture, out var tipo)) + continue; + + var row = new FilaCalendarioExcepcion(id, fecha, tipo); + + if (!_calExByDate.TryGetValue(fecha, out var list)) + _calExByDate[fecha] = list = new List(); + list.Add(row); + } + } + + /// + /// Carga expediciones dbus. + /// + private async Task LoadExpedicionesDbusAsync(ZipArchiveEntry expCsv, CancellationToken ct) + { + // expedicion.csv: + // id,id_ruta,dir_destino,direccion,cambio_exp,id_patron_exp,h_salida,id_cal + using var stream = expCsv.Open(); + using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var header = await sr.ReadLineAsync(); + if (header is null) return; + + var cols = SplitCsvLine(header); + + int iId = IndexOf(cols, "id"); + int iRuta = IndexOf(cols, "id_ruta"); + int iDir = IndexOf(cols, "Direccion"); + if (iDir < 0) iDir = IndexOf(cols, "direccion"); + + int iDirDestino = IndexOf(cols, "dir_destino"); + int iPatron = IndexOf(cols, "id_patron_exp"); + int iH = IndexOf(cols, "h_salida"); + int iCal = IndexOf(cols, "id_cal"); + + string? line; + while ((line = await sr.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (line.Length == 0) continue; + + var f = SplitCsvLine(line); + + if (iId < 0 || iId >= f.Length) continue; + if (iRuta < 0 || iRuta >= f.Length) continue; + if (iCal < 0 || iCal >= f.Length) continue; + + var id = f[iId]; + var ruta = f[iRuta]; + var idCal = f[iCal]; + + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(ruta) || string.IsNullOrWhiteSpace(idCal)) + continue; + + int direccion = 0; + if (iDir >= 0 && iDir < f.Length) + int.TryParse(f[iDir], NumberStyles.Integer, CultureInfo.InvariantCulture, out direccion); + + var dirDestino = (iDirDestino >= 0 && iDirDestino < f.Length) ? f[iDirDestino] : ""; + var idPatronExp = (iPatron >= 0 && iPatron < f.Length) ? f[iPatron] : ""; + + TimeOnly? h = null; + if (iH >= 0 && iH < f.Length) + h = ParseTimeNullable_HH_MM_SS(f[iH]); + + var row = new FilaExpedicion( + id, + ruta, + direccion, + dirDestino, + idPatronExp, + h, + idCal + ); + + _expById[id] = row; + } + } + + /// + /// Normaliza linea. + /// + private static string NormalizarLinea(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return "?"; + + s = s.Trim(); + var i = s.IndexOf('.'); + if (i > 0) s = s[..i]; + + return s.Trim(); + } + + /// + /// Resuelve el codigo visible de una linea usando la abreviatura del ZIP cuando exista. + /// + private string ResolverCodigoLineaVisible(string? idRuta, string? fallbackRutaId = null) + { + if (!string.IsNullOrWhiteSpace(idRuta) && + _rutaCodigoVisibleById.TryGetValue(idRuta, out var codigoRuta) && + !string.IsNullOrWhiteSpace(codigoRuta)) + { + return codigoRuta; + } + + if (!string.IsNullOrWhiteSpace(fallbackRutaId) && + _rutaCodigoVisibleById.TryGetValue(fallbackRutaId, out var codigoFallback) && + !string.IsNullOrWhiteSpace(codigoFallback)) + { + return codigoFallback; + } + + if (!string.IsNullOrWhiteSpace(idRuta)) + return NormalizarLinea(idRuta); + + if (!string.IsNullOrWhiteSpace(fallbackRutaId)) + return NormalizarLinea(fallbackRutaId); + + return "?"; + } + + /// + /// Comprueba si un destino textual es util para mostrarlo tal cual. + /// + private static bool DestinoTextoValido(string? destino) + { + if (string.IsNullOrWhiteSpace(destino)) + return false; + + var texto = destino.Trim(); + return !texto.Equals("N.A.", StringComparison.OrdinalIgnoreCase) && + !texto.Equals("N/A", StringComparison.OrdinalIgnoreCase) && + !texto.Equals("NA", StringComparison.OrdinalIgnoreCase) && + !texto.Equals("-", StringComparison.OrdinalIgnoreCase) && + !texto.Equals("?", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Resuelve el destino visible de una expedicion usando la ultima parada si el CSV no trae uno util. + /// + private string ResolverDestinoExpedicion(FilaExpedicion exp) + { + if (DestinoTextoValido(exp.DirDestino)) + return exp.DirDestino.Trim(); + + if (_stopTimesByExp.TryGetValue(exp.IdExp, out var pasos) && + pasos.Count > 0 && + _stopById.TryGetValue(pasos[^1].StopId, out var ultimaParada) && + !string.IsNullOrWhiteSpace(ultimaParada.Nombre)) + { + return ultimaParada.Nombre.Trim(); + } + + return ""; + } + + /// + /// Gestiona to sentido. + /// + private static string DireccionToSentido(int direccion) + { + return direccion switch + { + 1 => "I", + 2 => "V", + _ => "?" + }; + } + + /// + /// Gestiona itinerario desde patron. + /// + private static string ExtraerItinerarioDesdePatron(string? idRuta, string? idPatronExp) + { + if (string.IsNullOrWhiteSpace(idPatronExp)) + return "?"; + + var patron = idPatronExp.Trim(); + var ruta = NormalizarLinea(idRuta); + + if (!string.IsNullOrWhiteSpace(ruta) && + patron.StartsWith(ruta, StringComparison.OrdinalIgnoreCase)) + { + patron = patron[ruta.Length..]; + } + + patron = patron.Trim(); + + // quitar posible sufijo final de dirección/variante si queda algo tipo XX01 / XX02 + if (patron.Length >= 3) + { + var ult2 = patron[^2..]; + if (ult2 == "01" || ult2 == "02") + patron = patron[..^2]; + } + + patron = patron.TrimStart('0'); + + return string.IsNullOrWhiteSpace(patron) ? "0" : patron; + } + + /// + /// Carga tiempo parada dbus. + /// + private async Task LoadTiempoParadaDbusAsync(ZipArchiveEntry tiempoParadaCsv, CancellationToken ct) + { + // tiempo_parada.csv: id_exp,hora_llegada,hora_salida,distancia_siguiente_parada,id_tiempo_par,sec_par,... + using var stream = tiempoParadaCsv.Open(); + using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var header = await sr.ReadLineAsync(); + if (header is null) return; + + var cols = SplitCsvLine(header); + + int iExp = IndexOf(cols, "id_exp"); + int iLleg = IndexOf(cols, "hora_llegada"); + int iSal = IndexOf(cols, "hora_salida"); + int iStop = IndexOf(cols, "id_tiempo_par"); + int iSec = IndexOf(cols, "sec_par"); + + string? line; + while ((line = await sr.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (line.Length == 0) continue; + var f = SplitCsvLine(line); + + if (iExp < 0 || iExp >= f.Length) continue; + if (iStop < 0 || iStop >= f.Length) continue; + if (iSec < 0 || iSec >= f.Length) continue; + if (iLleg < 0 || iLleg >= f.Length) continue; + if (iSal < 0 || iSal >= f.Length) continue; + + var expId = f[iExp]; + if (string.IsNullOrWhiteSpace(expId)) continue; + + if (!int.TryParse(f[iStop], NumberStyles.Integer, CultureInfo.InvariantCulture, out var stopId)) + continue; + + if (!int.TryParse(f[iSec], NumberStyles.Integer, CultureInfo.InvariantCulture, out var sec)) + continue; + + // En calendario 0 puede venir 00:00:00 + var lleg = ParseTime_HH_MM_SS(f[iLleg]); + var sal = ParseTime_HH_MM_SS(f[iSal]); + + var row = new FilaTiempoParada(expId, stopId, sec, lleg, sal); + + if (!_stopTimesByExp.TryGetValue(expId, out var list)) + _stopTimesByExp[expId] = list = new List(64); + list.Add(row); + } + + // ordenar por secuencia + foreach (var kv in _stopTimesByExp) + kv.Value.Sort((a, b) => a.Sec.CompareTo(b.Sec)); + } + + /// + /// Obtiene linea itinerario sentido nombre texto. + /// + public string GetLineaItinerarioSentidoNombreText(string? expId, string? fallbackRutaId = null) + { + EnsureLoadedOrThrow(); + + if (string.IsNullOrWhiteSpace(expId)) + { + var l0 = ResolverCodigoLineaVisible(null, fallbackRutaId); + return $"L\u00ednea {l0}"; + } + + if (!_expById.TryGetValue(expId, out var exp)) + { + var l0 = ResolverCodigoLineaVisible(null, fallbackRutaId); + return $"L\u00ednea {l0}"; + } + + var linea = ResolverCodigoLineaVisible(exp.IdRuta, fallbackRutaId); + var iti = ExtraerItinerarioDesdePatron(exp.IdRuta, exp.IdPatronExp); + var sentido = DireccionToSentido(exp.Direccion); + var destino = ResolverDestinoExpedicion(exp); + + var baseTxt = $"{linea}.{iti}-{sentido}"; + + return string.IsNullOrWhiteSpace(destino) + ? baseTxt + : $"{baseTxt} - {destino}"; + } + + /// + /// Obtiene textos alternativos de expediciones para buscar un itinerario sin cambiar su texto visible. + /// + public IReadOnlyList GetAliasBusquedaItinerario( + string? idRuta, + string? lineaCodigo, + int codigoItinerario, + string? sentido) + { + EnsureLoadedOrThrow(); + + var idRutaPedido = NormalizarLinea(idRuta); + var lineaPedida = NormalizarLinea(lineaCodigo); + var itiPedido = codigoItinerario.ToString(CultureInfo.InvariantCulture); + var sentidoPedido = DireccionToSentidoBusqueda(sentido); + + var alias = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var exp in _expById.Values) + { + if (!CoincideLineaBusqueda(exp, idRutaPedido, lineaPedida)) + continue; + + var itiExp = ExtraerItinerarioDesdePatron(exp.IdRuta, exp.IdPatronExp); + if (!string.Equals(itiExp, itiPedido, StringComparison.OrdinalIgnoreCase)) + continue; + + var sentidoExp = DireccionToSentido(exp.Direccion); + if (sentidoPedido != "?" && + !string.Equals(sentidoExp, sentidoPedido, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var destino = ResolverDestinoExpedicion(exp); + if (!string.IsNullOrWhiteSpace(destino)) + alias.Add(destino.Trim()); + + if (DestinoTextoValido(exp.DirDestino)) + alias.Add(exp.DirDestino.Trim()); + + var lineaVisible = ResolverCodigoLineaVisible(exp.IdRuta, idRuta); + var baseTxt = $"{lineaVisible}.{itiExp}-{sentidoExp}"; + alias.Add(string.IsNullOrWhiteSpace(destino) + ? baseTxt + : $"{baseTxt} - {destino}"); + } + + return alias + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// Comprueba equivalencias entre id_ruta y codigo visible de linea para busqueda. + /// + private bool CoincideLineaBusqueda(FilaExpedicion exp, string idRutaPedido, string lineaPedida) + { + var idRutaExp = NormalizarLinea(exp.IdRuta); + var codigoVisibleExp = NormalizarLinea(ResolverCodigoLineaVisible(exp.IdRuta)); + + return (!string.IsNullOrWhiteSpace(idRutaPedido) && + !string.Equals(idRutaPedido, "?", StringComparison.Ordinal) && + (string.Equals(idRutaExp, idRutaPedido, StringComparison.OrdinalIgnoreCase) || + string.Equals(codigoVisibleExp, idRutaPedido, StringComparison.OrdinalIgnoreCase))) || + (!string.IsNullOrWhiteSpace(lineaPedida) && + !string.Equals(lineaPedida, "?", StringComparison.Ordinal) && + (string.Equals(idRutaExp, lineaPedida, StringComparison.OrdinalIgnoreCase) || + string.Equals(codigoVisibleExp, lineaPedida, StringComparison.OrdinalIgnoreCase))); + } + + /// + /// Normaliza el sentido usado por el selector de itinerarios. + /// + private static string DireccionToSentidoBusqueda(string? sentido) + { + if (string.IsNullOrWhiteSpace(sentido)) + return "?"; + + var texto = sentido.Trim(); + if (texto.Equals("I", StringComparison.OrdinalIgnoreCase) || + texto.Equals("Ida", StringComparison.OrdinalIgnoreCase)) + { + return "I"; + } + + if (texto.Equals("V", StringComparison.OrdinalIgnoreCase) || + texto.Equals("Vuelta", StringComparison.OrdinalIgnoreCase)) + { + return "V"; + } + + return "?"; + } + + // ========================= + // Loaders GTFS (fallback) + // ========================= + + /// + /// Carga paradas gtfs. + /// + private async Task LoadStopsGtfsAsync(ZipArchiveEntry stopsTxt, CancellationToken ct) + { + // stops.txt: stop_id, stop_name, stop_lat, stop_lon, ... + using var stream = stopsTxt.Open(); + using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var header = await sr.ReadLineAsync(); + if (header is null) return; + + var cols = SplitCsvLine(header); + int iId = IndexOf(cols, "stop_id"); + int iName = IndexOf(cols, "stop_name"); + + string? line; + while ((line = await sr.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (line.Length == 0) continue; + var f = SplitCsvLine(line); + + if (iId < 0 || iId >= f.Length) continue; + var idStr = f[iId]; + if (!int.TryParse(idStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id)) continue; + + var name = (iName >= 0 && iName < f.Length) ? f[iName] : ""; + var row = new FilaParada(id, idStr, name); + _stopById[id] = row; + + _stopIdByCodigo[NormalizarCodigoParada(idStr)] = id; + _stopIdByCodigo[id.ToString(CultureInfo.InvariantCulture)] = id; + } + } + + /// + /// Carga calendario gtfs. + /// + private async Task LoadCalendarGtfsAsync(ZipArchiveEntry calendarTxt, CancellationToken ct) + { + // calendar.txt: service_id,monday,...,sunday,start_date,end_date (0/1) + using var stream = calendarTxt.Open(); + using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var header = await sr.ReadLineAsync(); + if (header is null) return; + + var cols = SplitCsvLine(header); + + int iId = IndexOf(cols, "service_id"); + int iMon = IndexOf(cols, "monday"); + int iTue = IndexOf(cols, "tuesday"); + int iWed = IndexOf(cols, "wednesday"); + int iThu = IndexOf(cols, "thursday"); + int iFri = IndexOf(cols, "friday"); + int iSat = IndexOf(cols, "saturday"); + int iSun = IndexOf(cols, "sunday"); + int iIni = IndexOf(cols, "start_date"); + int iFin = IndexOf(cols, "end_date"); + + string? line; + while ((line = await sr.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (line.Length == 0) continue; + var f = SplitCsvLine(line); + + if (iId < 0 || iId >= f.Length) continue; + var id = f[iId]; + if (string.IsNullOrWhiteSpace(id)) continue; + + int Flag(int idx) + { + // GTFS: 0/1 -> convertimos a tu convención: 1=no, 2=sí + if (idx < 0 || idx >= f.Length) return 1; + return (f[idx].Trim() == "1") ? 2 : 1; + } + + var ini = ParseDate_YYYYMMDD(f[iIni]); + var fin = ParseDate_YYYYMMDD(f[iFin]); + + _cal[id] = new FilaCalendario(id, Flag(iMon), Flag(iTue), Flag(iWed), Flag(iThu), Flag(iFri), Flag(iSat), Flag(iSun), ini, fin); + } + } + + /// + /// Carga calendario dates gtfs. + /// + private async Task LoadCalendarDatesGtfsAsync(ZipArchiveEntry calendarDatesTxt, CancellationToken ct) + { + // calendar_dates.txt: service_id,date,exception_type (1=add, 2=remove) + using var stream = calendarDatesTxt.Open(); + using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var header = await sr.ReadLineAsync(); + if (header is null) return; + + var cols = SplitCsvLine(header); + + int iId = IndexOf(cols, "service_id"); + int iDate = IndexOf(cols, "date"); + int iType = IndexOf(cols, "exception_type"); + + string? line; + while ((line = await sr.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (line.Length == 0) continue; + var f = SplitCsvLine(line); + + if (iId < 0 || iId >= f.Length) continue; + if (iDate < 0 || iDate >= f.Length) continue; + if (iType < 0 || iType >= f.Length) continue; + + var id = f[iId]; + var fecha = ParseDate_YYYYMMDD(f[iDate]); + + if (!int.TryParse(f[iType], NumberStyles.Integer, CultureInfo.InvariantCulture, out var tipo)) + continue; + + var row = new FilaCalendarioExcepcion(id, fecha, tipo); + if (!_calExByDate.TryGetValue(fecha, out var list)) + _calExByDate[fecha] = list = new List(); + list.Add(row); + } + } + + /// + /// Carga viajes gtfs. + /// + private async Task LoadTripsGtfsAsync(ZipArchiveEntry tripsTxt, CancellationToken ct) + { + using var stream = tripsTxt.Open(); + using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var header = await sr.ReadLineAsync(); + if (header is null) return; + + var cols = SplitCsvLine(header); + int iTrip = IndexOf(cols, "trip_id"); + int iRoute = IndexOf(cols, "route_id"); + int iService = IndexOf(cols, "service_id"); + int iHead = IndexOf(cols, "trip_headsign"); + int iDir = IndexOf(cols, "direction_id"); + int iShape = IndexOf(cols, "shape_id"); + + string? line; + while ((line = await sr.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (line.Length == 0) continue; + var f = SplitCsvLine(line); + + if (iTrip < 0 || iTrip >= f.Length) continue; + if (iRoute < 0 || iRoute >= f.Length) continue; + if (iService < 0 || iService >= f.Length) continue; + + var tripId = f[iTrip]; + var routeId = f[iRoute]; + var serviceId = f[iService]; + var head = (iHead >= 0 && iHead < f.Length) ? f[iHead] : ""; + var shapeId = (iShape >= 0 && iShape < f.Length) ? f[iShape] : ""; + + int dir = 0; + if (iDir >= 0 && iDir < f.Length) + int.TryParse(f[iDir], NumberStyles.Integer, CultureInfo.InvariantCulture, out dir); + + _expById[tripId] = new FilaExpedicion( + tripId, + routeId, + dir, + head, + shapeId, + null, + serviceId + ); + } + } + /// + /// Carga parada tiempos gtfs. + /// + private async Task LoadStopTimesGtfsAsync(ZipArchiveEntry stopTimesTxt, CancellationToken ct) + { + // stop_times.txt: trip_id,arrival_time,departure_time,stop_id,stop_sequence + using var stream = stopTimesTxt.Open(); + using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var header = await sr.ReadLineAsync(); + if (header is null) return; + + var cols = SplitCsvLine(header); + + int iTrip = IndexOf(cols, "trip_id"); + int iArr = IndexOf(cols, "arrival_time"); + int iDep = IndexOf(cols, "departure_time"); + int iStop = IndexOf(cols, "stop_id"); + int iSeq = IndexOf(cols, "stop_sequence"); + + string? line; + while ((line = await sr.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (line.Length == 0) continue; + var f = SplitCsvLine(line); + + if (iTrip < 0 || iTrip >= f.Length) continue; + if (iStop < 0 || iStop >= f.Length) continue; + if (iSeq < 0 || iSeq >= f.Length) continue; + if (iArr < 0 || iArr >= f.Length) continue; + if (iDep < 0 || iDep >= f.Length) continue; + + var tripId = f[iTrip]; + + if (!int.TryParse(f[iStop], NumberStyles.Integer, CultureInfo.InvariantCulture, out var stopId)) + continue; + + if (!int.TryParse(f[iSeq], NumberStyles.Integer, CultureInfo.InvariantCulture, out var sec)) + continue; + + // OJO: GTFS permite >24:00:00. Aquí lo normal es <24:00:00. + // Si te llega 25:10:00, lo recortamos mod 24 para "próximas salidas" del mismo día. + var arr = ParseGtfsTimeSafe(f[iArr]); + var dep = ParseGtfsTimeSafe(f[iDep]); + + var row = new FilaTiempoParada(tripId, stopId, sec, arr, dep); + + if (!_stopTimesByExp.TryGetValue(tripId, out var list)) + _stopTimesByExp[tripId] = list = new List(64); + list.Add(row); + } + + foreach (var kv in _stopTimesByExp) + kv.Value.Sort((a, b) => a.Sec.CompareTo(b.Sec)); + } + + /// + /// Analiza gtfs tiempo safe. + /// + private static TimeOnly ParseGtfsTimeSafe(string s) + { + // "HH:MM:SS" y puede ser HH>=24 + var t = s.Trim(); + var parts = t.Split(':'); + if (parts.Length != 3) return TimeOnly.MinValue; + + if (!int.TryParse(parts[0], out var hh)) hh = 0; + if (!int.TryParse(parts[1], out var mm)) mm = 0; + if (!int.TryParse(parts[2], out var ss)) ss = 0; + + hh %= 24; + mm = Math.Clamp(mm, 0, 59); + ss = Math.Clamp(ss, 0, 59); + return new TimeOnly(hh, mm, ss); + } + + // ========================= + // Índices derivados + // ========================= + /// + /// Construye salidas indice. + /// + private void ConstruirIndiceSalidas() + { + _departuresByStop.Clear(); + + foreach (var kv in _stopTimesByExp) + { + var expId = kv.Key; + var list = kv.Value; + + foreach (var st in list) + { + if (!_departuresByStop.TryGetValue(st.StopId, out var deps)) + _departuresByStop[st.StopId] = deps = new List<(string, TimeOnly)>(64); + + deps.Add((expId, st.Salida)); + } + } + + foreach (var kv in _departuresByStop) + kv.Value.Sort((a, b) => a.dep.CompareTo(b.dep)); + } + + /// + /// Obtiene linea destino texto. + /// + public string GetLineaDestinoText(string? expId, string? fallbackRutaId = null) + { + EnsureLoadedOrThrow(); + + if (string.IsNullOrWhiteSpace(expId)) + return !string.IsNullOrWhiteSpace(fallbackRutaId) + ? $"L\u00ednea {ResolverCodigoLineaVisible(null, fallbackRutaId)}" + : $"L\u00ednea ?"; + + if (_expById.TryGetValue(expId, out var exp)) + { + var linea = ResolverCodigoLineaVisible(exp.IdRuta, fallbackRutaId); + var destino = ResolverDestinoExpedicion(exp); + + return string.IsNullOrWhiteSpace(destino) + ? $"L\u00ednea {linea}" + : $"L\u00ednea {linea} - {destino}"; + } + + // fallback si no encontramos la expedición + return !string.IsNullOrWhiteSpace(fallbackRutaId) + ? $"L\u00ednea {ResolverCodigoLineaVisible(null, fallbackRutaId)}" + : $"L\u00ednea ?"; + } + + /// + /// Gestiona of. + /// + private static int IndexOf(string[] cols, string name) + { + for (int i = 0; i < cols.Length; i++) + if (cols[i].Equals(name, StringComparison.OrdinalIgnoreCase)) + return i; + return -1; + } + + /// + /// Normaliza codigo parada. + /// + private static string NormalizarCodigoParada(string s) + { + if (string.IsNullOrWhiteSpace(s)) return ""; + s = s.Trim(); + s = s.Replace("P-", "", StringComparison.OrdinalIgnoreCase); + var i = s.IndexOf('.'); + if (i > 0) s = s[..i]; + return s.Trim(); + } + + /// + /// Intenta obtener get siguientes bus segmento. + /// + public bool TryGetNextBusSegment( + string codigoParadaIni, + string codigoParadaFin, + string lineaCodigo, + int codigoItinerario, + string sentido, + DateOnly fecha, + TimeOnly horaMin, + out DateTime dep, + out DateTime arr, + out string expId) + { + EnsureLoadedOrThrow(); + + dep = default; + arr = default; + expId = ""; + + var stopIniId = IntentarResolverIdParada(codigoParadaIni); + var stopFinId = IntentarResolverIdParada(codigoParadaFin); + if (stopIniId is null || stopFinId is null) + return false; + + var activeCals = GetActiveCalendars(fecha); + + if (!_departuresByStop.TryGetValue(stopIniId.Value, out var deps) || deps.Count == 0) + return false; + + int startIdx = LowerBoundDepartures(deps, horaMin); + int maxScan = Math.Min(deps.Count, startIdx + 400); + var minDt = fecha.ToDateTime(horaMin); + + var lineaPedida = NormalizarLinea(lineaCodigo); + var sentidoPedido = string.IsNullOrWhiteSpace(sentido) ? "?" : sentido.Trim().ToUpperInvariant(); + var itiPedido = codigoItinerario.ToString(CultureInfo.InvariantCulture); + + // ============================ + // 1ª pasada: exacta + // línea + itinerario + sentido + // ============================ + if (TryFindBusSegment( + deps, startIdx, maxScan, + stopIniId.Value, stopFinId.Value, + activeCals, minDt, horaMin, + lineaPedida, itiPedido, sentidoPedido, + exigirItinerario: true, + out dep, out arr, out expId)) + { + return true; + } + + // ============================ + // 2ª pasada: relajada + // línea + sentido, sin exigir itinerario + // ============================ + if (TryFindBusSegment( + deps, startIdx, maxScan, + stopIniId.Value, stopFinId.Value, + activeCals, minDt, horaMin, + lineaPedida, itiPedido, sentidoPedido, + exigirItinerario: false, + out dep, out arr, out expId)) + { + return true; + } + + return false; + } + + /// + /// Gestiona bound salidas. + /// + private static int LowerBoundDepartures(List<(string expId, TimeOnly dep)> deps, TimeOnly t) + { + int lo = 0, hi = deps.Count; + while (lo < hi) + { + int mid = lo + ((hi - lo) / 2); + if (deps[mid].dep < t) lo = mid + 1; + else hi = mid; + } + return lo; + } + + /// + /// Intenta obtener find bus segmento. + /// + private bool TryFindBusSegment( + List<(string expId, TimeOnly dep)> deps, + int startIdx, + int maxScan, + int stopIniId, + int stopFinId, + HashSet activeCals, + DateTime minDt, + TimeOnly horaMin, + string lineaPedida, + string itiPedido, + string sentidoPedido, + bool exigirItinerario, + out DateTime dep, + out DateTime arr, + out string expId) + { + dep = default; + arr = default; + expId = ""; + + for (int i = startIdx; i < maxScan; i++) + { + var (candExpId, depTimeAtStopIni) = deps[i]; + if (depTimeAtStopIni < horaMin) + continue; + + if (!_expById.TryGetValue(candExpId, out var exp)) + continue; + + if (!activeCals.Contains(exp.IdCal)) + continue; + + var lineaExp = NormalizarLinea(exp.IdRuta); + if (!string.Equals(lineaExp, lineaPedida, StringComparison.OrdinalIgnoreCase)) + continue; + + var sentidoExp = DireccionToSentido(exp.Direccion); + if (sentidoPedido != "?" && + !string.Equals(sentidoExp, sentidoPedido, StringComparison.OrdinalIgnoreCase)) + continue; + + if (exigirItinerario) + { + var itiExp = ExtraerItinerarioDesdePatron(exp.IdRuta, exp.IdPatronExp); + if (!string.Equals(itiExp, itiPedido, StringComparison.OrdinalIgnoreCase)) + continue; + } + + if (!_stopTimesByExp.TryGetValue(candExpId, out var stList) || stList.Count == 0) + continue; + + FilaTiempoParada? stIni = null; + FilaTiempoParada? stFin = null; + + for (int k = 0; k < stList.Count; k++) + { + var st = stList[k]; + + if (st.StopId == stopIniId && stIni is null) + stIni = st; + + if (st.StopId == stopFinId) + stFin = st; + } + + if (stIni is null || stFin is null) + continue; + + if (stFin.Sec <= stIni.Sec) + continue; + + var depDt = minDt.Date + stIni.Salida.ToTimeSpan(); + var arrDt = minDt.Date + stFin.Llegada.ToTimeSpan(); + + if (arrDt < depDt) + arrDt = arrDt.AddDays(1); + + if (depDt < minDt) + continue; + + dep = depDt; + arr = arrDt; + expId = candExpId; + return true; + } + + return false; + } + + /// + /// Normaliza sentido. + /// + private static string NormalizarSentido(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return "?"; + s = s.Trim(); + + if (s.Equals("I", StringComparison.OrdinalIgnoreCase)) return "I"; + if (s.Equals("V", StringComparison.OrdinalIgnoreCase)) return "V"; + if (s.Equals("Ida", StringComparison.OrdinalIgnoreCase)) return "I"; + if (s.Equals("Vuelta", StringComparison.OrdinalIgnoreCase)) return "V"; + + var c = char.ToUpperInvariant(s[0]); + if (c == 'I') return "I"; + if (c == 'V') return "V"; + return "?"; + } + + /// + /// Gestiona to direccion. + /// + private static int? SentidoToDireccion(string sentidoNorm) + { + // dBUS/GTFS suele usar 0/1 + return sentidoNorm switch + { + "I" => 0, + "V" => 1, + _ => null + }; + } + + /// + /// Gestiona matches. + /// + private static bool RutaMatches(string rutaIdRaw, string lineaCodigo, string itinStr, string sentidoNorm) + { + if (string.IsNullOrWhiteSpace(rutaIdRaw)) return false; + + // Normalizamos + var r = rutaIdRaw.Trim().ToLowerInvariant(); + var linea = (lineaCodigo ?? "").Trim().ToLowerInvariant(); + if (linea.Length == 0) return false; + + // quitar posibles decimales en línea "26.0" -> "26" + var iDot = linea.IndexOf('.'); + if (iDot > 0) linea = linea[..iDot]; + + // patrones típicos que suelen aparecer en id_ruta + // - "26.12", "26_12", "26-12", "26 12" + bool hasLinea = ContainsToken(r, linea); + bool hasItin = ContainsToken(r, itinStr); + + if (!hasLinea || !hasItin) + { + // intentamos combo "26.12" + var combo1 = $"{linea}.{itinStr}"; + var combo2 = $"{linea}_{itinStr}"; + var combo3 = $"{linea}-{itinStr}"; + if (!(r.Contains(combo1) || r.Contains(combo2) || r.Contains(combo3))) + return false; + } + + // si rutaId incluye algo tipo sentido/dirección, lo aprovechamos (opcional) + // (si no, no bloqueamos) + if (sentidoNorm == "I") + { + if (r.Contains("vuelta")) return false; // muy defensivo + } + else if (sentidoNorm == "V") + { + if (r.Contains("ida")) return false; + } + + return true; + } + + /// + /// Gestiona token. + /// + private static bool ContainsToken(string haystack, string token) + { + if (string.IsNullOrWhiteSpace(haystack) || string.IsNullOrWhiteSpace(token)) return false; + + // match “token” con bordes razonables (no letra/dígito a los lados) + // ej: token "26" no debe casar con "1267" + for (int i = 0; i <= haystack.Length - token.Length; i++) + { + if (!haystack.AsSpan(i, token.Length).Equals(token.AsSpan(), StringComparison.OrdinalIgnoreCase)) + continue; + + bool leftOk = (i == 0) || !char.IsLetterOrDigit(haystack[i - 1]); + bool rightOk = (i + token.Length >= haystack.Length) || !char.IsLetterOrDigit(haystack[i + token.Length]); + + if (leftOk && rightOk) return true; + } + + return false; + } + + + /// + /// Obtiene linea itinerario sentido texto. + /// + public string GetLineaItinerarioSentidoText(string? expId, string? fallbackRutaId = null) + { + // expId = trip_id + if (string.IsNullOrWhiteSpace(expId)) + return !string.IsNullOrWhiteSpace(fallbackRutaId) ? $"{fallbackRutaId}.?-?" : "?.?-?"; + + if (!_tripMetaByTripId.TryGetValue(expId, out var meta)) + { + // si no tengo meta, al menos intento routeId fallback + var l = !string.IsNullOrWhiteSpace(fallbackRutaId) ? fallbackRutaId : "?"; + return $"{l}.?-?"; + } + + // Línea = route_id (normalmente "16") + var linea = string.IsNullOrWhiteSpace(meta.RouteId) ? (fallbackRutaId ?? "?") : meta.RouteId; + + // Sentido: GTFS direction_id suele ser 0/1 + // Map típico: 0=I, 1=V (si en tu ZIP viene al revés, se cambia aquí y listo) + var sentido = meta.DirectionId switch + { + 0 => "I", + 1 => "V", + _ => "?" + }; + + // Itinerario/patrón: normalmente está en shape_id (depende del ZIP) + // Intentamos sacar un número “tipo 12” de shape_id. + var itin = ExtraerItinerarioDeShape(meta.ShapeId); + + return $"{linea}.{itin}-{sentido}"; + } + + /// + /// Gestiona itinerario de shape. + /// + private static string ExtraerItinerarioDeShape(string? shapeId) + { + if (string.IsNullOrWhiteSpace(shapeId)) + return "?"; + + // Intentos típicos: + // - "..._12_..." -> 12 + // - "...-12-..." -> 12 + // - ".../12/..." -> 12 + // - "...12..." -> (último recurso) + var s = shapeId.Trim(); + + // 1) _12_ o -12- o .12. etc + var m = System.Text.RegularExpressions.Regex.Match(s, @"(?:^|[_\-.\/])(?\d{1,3})(?:[_\-.\/]|$)"); + if (m.Success) + return m.Groups["n"].Value; + + // 2) último bloque numérico + m = System.Text.RegularExpressions.Regex.Match(s, @"(?\d{1,3})(?!.*\d)"); + if (m.Success) + return m.Groups["n"].Value; + + return "?"; + } + + /// + /// Inicializa una nueva instancia de la clase MetadatosViaje. + /// + private sealed record MetadatosViaje(string RouteId, int? DirectionId, string? ShapeId, string? Headsign); + + // trip_id -> meta + private readonly Dictionary _tripMetaByTripId = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Formatea linea from ruta id. + /// + public string FormatLineaFromRutaId(string? rutaId) + { + if (string.IsNullOrWhiteSpace(rutaId)) + return "Ruta ?"; + + var s = rutaId.Trim(); + + // Busca algo tipo 26.12-I o 26.12-V + var m = Regex.Match(s, @"(?i)\b(\d{1,3})\.(\d{1,3})-([IV])\b"); + if (m.Success) + return $"{m.Groups[1].Value}.{m.Groups[2].Value}-{m.Groups[3].Value.ToUpperInvariant()}"; + + // fallback + return "Ruta " + (s.Length > 18 ? s.Substring(0, 18) + "…" : s); + } + + + } + + + +} diff --git a/RutasDBUS/Servicios/Importacion/IServicioArchivoImportacionDbus.cs b/RutasDBUS/Servicios/Importacion/IServicioArchivoImportacionDbus.cs new file mode 100644 index 0000000..7d7ac32 --- /dev/null +++ b/RutasDBUS/Servicios/Importacion/IServicioArchivoImportacionDbus.cs @@ -0,0 +1,19 @@ +using RutasDBUS.Modelos.Importacion; + +namespace RutasDBUS.Servicios.Importacion; + +public interface IServicioArchivoImportacionDbus +{ + Task ObtenerAsync(CancellationToken cancellationToken = default); + + Task> ListarModelosAsync(CancellationToken cancellationToken = default); + + Task ObtenerModeloActivoAsync(CancellationToken cancellationToken = default); + + Task SeleccionarModeloAsync(string idModelo, CancellationToken cancellationToken = default); + + Task GuardarModeloSubidoAsync( + string nombreArchivo, + Stream contenido, + CancellationToken cancellationToken = default); +} diff --git a/RutasDBUS/Servicios/Importacion/ServicioArchivoImportacionDbus.cs b/RutasDBUS/Servicios/Importacion/ServicioArchivoImportacionDbus.cs new file mode 100644 index 0000000..6e2de4e --- /dev/null +++ b/RutasDBUS/Servicios/Importacion/ServicioArchivoImportacionDbus.cs @@ -0,0 +1,728 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Options; +using RutasDBUS.Modelos.Importacion; +using RutasDBUS.Modelos.RedBus; + +namespace RutasDBUS.Servicios.Importacion; + +public sealed class ServicioArchivoImportacionDbus +{ + private readonly IWebHostEnvironment _entorno; + private readonly OpcionesRedBus _opciones; + private readonly SemaphoreSlim _semCarga = new(1, 1); + private ArchivoImportacionDbus? _archive; + + /// + /// Inicializa una nueva instancia de la clase ServicioArchivoImportacionDbus. + /// + public ServicioArchivoImportacionDbus( + IWebHostEnvironment entorno, + IOptions opciones) + { + _entorno = entorno; + _opciones = opciones.Value ?? new OpcionesRedBus(); + } + + /// + /// + /// + public async Task ObtenerAsync(CancellationToken cancellationToken = default) + { + if (_archive is not null) + return _archive; + + await _semCarga.WaitAsync(cancellationToken); + try + { + if (_archive is not null) + return _archive; + + var zipPath = ResolverRutaZip(); + if (zipPath is null || !File.Exists(zipPath)) + throw new FileNotFoundException("No se ha encontrado el ZIP de importación de dBUS."); + + _archive = await CargarArchivoAsync(zipPath, cancellationToken); + return _archive; + } + finally + { + _semCarga.Release(); + } + } + + public Task ObtenerDesdeRutaAsync( + string zipPath, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(zipPath) || !File.Exists(zipPath)) + throw new FileNotFoundException("No se ha encontrado el ZIP de importacion de dBUS.", zipPath); + + return CargarArchivoAsync(zipPath, cancellationToken); + } + + /// + /// Resuelve zip camino. + /// + private string? ResolverRutaZip() + { + if (!string.IsNullOrWhiteSpace(_opciones.ArchivoDatos)) + { + var configured = Path.Combine( + _entorno.ContentRootPath, + _opciones.ArchivoDatos.Replace('/', Path.DirectorySeparatorChar)); + + if (File.Exists(configured)) + return configured; + } + + var folder = Path.Combine(_entorno.ContentRootPath, "Modelos", "horarios"); + if (!Directory.Exists(folder)) + return null; + + return Directory.GetFiles(folder, "*.zip", SearchOption.TopDirectoryOnly) + .Select(path => new FileInfo(path)) + .OrderByDescending(info => info.LastWriteTimeUtc) + .FirstOrDefault() + ?.FullName; + } + + /// + /// Carga el archivo. + /// + private async Task CargarArchivoAsync(string zipPath, CancellationToken ct) + { + using var fs = File.OpenRead(zipPath); + using var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: false); + + var calendarios = await ReadCalendariosAsync(FindEntry(zip, "calendario.csv"), ct); + var calendarioEx = await ReadCalendarioExcepcionesAsync(FindEntry(zip, "calendario_excepciones_puntuales.csv"), ct); + var expediciones = await ReadExpedicionesAsync(FindEntry(zip, "expedicion.csv"), ct); + var operadores = await ReadOperadoresAsync(FindEntry(zip, "operador.csv"), ct); + var paradas = await ReadParadasAsync(FindEntry(zip, "parada.csv"), ct); + var patrones = await ReadPatronesAsync(FindEntry(zip, "patron.csv"), ct); + var rutas = await ReadRutasAsync(FindEntry(zip, "ruta.csv"), ct); + var tarifas = await ReadTarifasAsync(FindEntry(zip, "tarifa.csv"), ct); + var tiemposParada = await ReadTiemposParadaAsync(FindEntry(zip, "tiempo_parada.csv"), ct); + var tiemposReales = await ReadTiemposRealesAsync(FindEntry(zip, "tiempo_real.csv"), ct); + var transbordos = await ReadTransbordosAsync(FindEntry(zip, "transbordos.csv"), ct); + var vehiculos = await ReadVehiculosAsync(FindEntry(zip, "vehiculos.csv"), ct); + + return new ArchivoImportacionDbus + { + RutaZip = zipPath, + FechaArchivoUtc = File.GetLastWriteTimeUtc(zipPath), + Calendarios = calendarios, + CalendarioExcepciones = calendarioEx, + Expediciones = expediciones, + Operadores = operadores, + Paradas = paradas, + Patrones = patrones, + Rutas = rutas, + Tarifas = tarifas, + TiemposParada = tiemposParada, + TiemposReales = tiemposReales, + Transbordos = transbordos, + Vehiculos = vehiculos, + ParadasPorId = paradas.ToDictionary(x => x.Id), + RutasPorId = rutas.ToDictionary(x => x.Id, StringComparer.OrdinalIgnoreCase), + ExpedicionesPorId = expediciones.ToDictionary(x => x.Id, StringComparer.OrdinalIgnoreCase), + TiemposParadaPorExpedicion = tiemposParada + .GroupBy(x => x.IdExp, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => g.OrderBy(x => x.SecPar).ToList(), + StringComparer.OrdinalIgnoreCase), + PatronesPorId = patrones + .GroupBy(x => x.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => g.OrderBy(x => x.PtoSecuencia).ToList(), + StringComparer.OrdinalIgnoreCase), + TransbordosPorRuta = transbordos + .SelectMany(x => new[] + { + new KeyValuePair(x.IdRuta1, x), + new KeyValuePair(x.IdRuta2, x) + }) + .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => g.Select(x => x.Value).ToList(), + StringComparer.OrdinalIgnoreCase) + }; + } + + /// + /// Busca entrada. + /// + private static ZipArchiveEntry? FindEntry(ZipArchive zip, string fileName) + => zip.Entries.FirstOrDefault(entry => + entry.FullName.EndsWith("/" + fileName, StringComparison.OrdinalIgnoreCase) || + entry.FullName.Equals(fileName, StringComparison.OrdinalIgnoreCase)); + + /// + /// Lee csv rows. + /// + private static async Task> ReadCsvRowsAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = new List(); + if (entry is null) + return rows; + + var contenidoCsv = await ReadCsvTextAsync(entry, ct); + using var reader = new StringReader(contenidoCsv); + + string? line; + while ((line = await reader.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(line)) + continue; + + rows.Add(SplitCsvLine(line)); + } + + return rows; + } + + /// + /// Lee el contenido de un CSV intentando UTF-8 y cayendo a Windows-1252 cuando el archivo viene en esa codificación. + /// + private static async Task ReadCsvTextAsync(ZipArchiveEntry entry, CancellationToken ct) + { + using var stream = entry.Open(); + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory, ct); + + var bytes = memory.ToArray(); + var utf8Strict = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + try + { + return utf8Strict.GetString(bytes); + } + catch (DecoderFallbackException) + { + return Encoding.GetEncoding(1252).GetString(bytes); + } + } + + /// + /// Gestiona csv linea. + /// + private static string[] SplitCsvLine(string line) + { + var values = new List(16); + var sb = new StringBuilder(line.Length); + var inQuotes = false; + + for (var i = 0; i < line.Length; i++) + { + var c = line[i]; + + if (c == '"') + { + if (inQuotes && i + 1 < line.Length && line[i + 1] == '"') + { + sb.Append('"'); + i++; + } + else + { + inQuotes = !inQuotes; + } + + continue; + } + + if (c == ',' && !inQuotes) + { + values.Add(sb.ToString().Trim()); + sb.Clear(); + continue; + } + + sb.Append(c); + } + + values.Add(sb.ToString().Trim()); + return values.ToArray(); + } + + /// + /// Gestiona of. + /// + private static int IndexOf(string[] header, string column) + { + for (var i = 0; i < header.Length; i++) + { + if (header[i].Equals(column, StringComparison.OrdinalIgnoreCase)) + return i; + } + + return -1; + } + + /// + /// + /// + private static string Get(string[] row, int index) + => index >= 0 && index < row.Length ? row[index] : ""; + + /// + /// Analiza int. + /// + private static int ParseInt(string[] row, int index) + => int.TryParse(Get(row, index), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : 0; + + /// + /// Analiza nullable int. + /// + private static int? ParseNullableInt(string[] row, int index) + => int.TryParse(Get(row, index), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null; + + /// + /// Analiza double. + /// + private static double ParseDouble(string[] row, int index) + => double.TryParse(Get(row, index), NumberStyles.Any, CultureInfo.InvariantCulture, out var value) ? value : 0; + + /// + /// Analiza nullable decimal. + /// + private static decimal? ParseNullableDecimal(string[] row, int index) + => decimal.TryParse(Get(row, index), NumberStyles.Any, CultureInfo.InvariantCulture, out var value) ? value : null; + + /// + /// Analiza date. + /// + private static DateOnly ParseDate(string value) + => DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) + ? date + : default; + + /// + /// Analiza tiempo. + /// + private static TimeOnly? ParseTime(string value) + => TimeOnly.TryParseExact(value, "HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var time) + ? time + : null; + + /// + /// Lee calendarios. + /// + private static async Task> ReadCalendariosAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = await ReadCsvRowsAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + var res = new List(Math.Max(0, rows.Count - 1)); + + int iId = IndexOf(h, "ID"); + int iLun = IndexOf(h, "LUNES"); + int iMar = IndexOf(h, "MARTES"); + int iMie = IndexOf(h, "MIERCOLES"); + int iJue = IndexOf(h, "JUEVES"); + int iVie = IndexOf(h, "VIERNES"); + int iSab = IndexOf(h, "SABADO"); + int iDom = IndexOf(h, "DOMINGO"); + int iIni = IndexOf(h, "FECHA_INICIO"); + int iFin = IndexOf(h, "FECHA_FIN"); + + foreach (var row in rows.Skip(1)) + { + res.Add(new FilaCalendarioCsv( + Get(row, iId), + ParseInt(row, iLun), + ParseInt(row, iMar), + ParseInt(row, iMie), + ParseInt(row, iJue), + ParseInt(row, iVie), + ParseInt(row, iSab), + ParseInt(row, iDom), + ParseDate(Get(row, iIni)), + ParseDate(Get(row, iFin)))); + } + + return res; + } + + /// + /// Lee calendario excepciones. + /// + private static async Task> ReadCalendarioExcepcionesAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = await ReadCsvRowsAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + var res = new List(Math.Max(0, rows.Count - 1)); + + int iId = IndexOf(h, "ID"); + int iFecha = IndexOf(h, "FECHA_EX"); + int iTipo = IndexOf(h, "TIPO_EXCEPCION"); + + foreach (var row in rows.Skip(1)) + { + res.Add(new FilaCalendarioExcepcionCsv( + Get(row, iId), + ParseDate(Get(row, iFecha)), + ParseInt(row, iTipo))); + } + + return res; + } + + /// + /// Lee expediciones. + /// + private static async Task> ReadExpedicionesAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = await ReadCsvRowsAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + var res = new List(Math.Max(0, rows.Count - 1)); + + int iId = IndexOf(h, "ID"); + int iRuta = IndexOf(h, "ID_RUTA"); + int iDirDestino = IndexOf(h, "DIR_DESTINO"); + int iDireccion = IndexOf(h, "DIRECCION"); + int iCambio = IndexOf(h, "CAMBIO_EXP"); + int iPatron = IndexOf(h, "ID_PATRON_EXP"); + int iSalida = IndexOf(h, "H_SALIDA"); + int iCal = IndexOf(h, "ID_CAL"); + + foreach (var row in rows.Skip(1)) + { + res.Add(new FilaExpedicionCsv( + Get(row, iId), + Get(row, iRuta), + Get(row, iDirDestino), + ParseInt(row, iDireccion), + Get(row, iCambio), + Get(row, iPatron), + ParseTime(Get(row, iSalida)), + Get(row, iCal))); + } + + return res; + } + + /// + /// Lee operadores. + /// + private static async Task> ReadOperadoresAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = await ReadCsvRowsAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + var res = new List(Math.Max(0, rows.Count - 1)); + + int iId = IndexOf(h, "ID"); + int iNombre = IndexOf(h, "NOMBRE"); + int iTelefono = IndexOf(h, "TELEFONO"); + int iUrl = IndexOf(h, "URL"); + int iDescripcion = IndexOf(h, "DESCRIPCION"); + + foreach (var row in rows.Skip(1)) + { + res.Add(new FilaOperadorCsv( + Get(row, iId), + Get(row, iNombre), + Get(row, iTelefono), + Get(row, iUrl), + Get(row, iDescripcion))); + } + + return res; + } + + /// + /// Lee paradas. + /// + private static async Task> ReadParadasAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = await ReadCsvRowsAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + var res = new List(Math.Max(0, rows.Count - 1)); + + int iId = IndexOf(h, "ID"); + int iCodigo = IndexOf(h, "CODIGO_SMS"); + int iNombre = IndexOf(h, "NOMBRE_PAR"); + int iLat = IndexOf(h, "LATITUD_Y_PAR"); + int iLon = IndexOf(h, "LONGITUD_X_PAR"); + int iLocalidad = IndexOf(h, "LOCALIDAD_PAR"); + int iZona = IndexOf(h, "ZONA_PAR"); + int iDescripcion = IndexOf(h, "DESCRIPCION_PAR"); + int iUrl = IndexOf(h, "URL_PAR"); + int iTipo = IndexOf(h, "TIPO_PAR"); + int iPadre = IndexOf(h, "ESTACION_PADRE"); + + foreach (var row in rows.Skip(1)) + { + res.Add(new FilaParadaCsv( + ParseInt(row, iId), + Get(row, iCodigo), + Get(row, iNombre), + ParseDouble(row, iLat), + ParseDouble(row, iLon), + Get(row, iLocalidad), + Get(row, iZona), + Get(row, iDescripcion), + Get(row, iUrl), + Get(row, iTipo), + Get(row, iPadre))); + } + + return res; + } + + /// + /// Lee patrones. + /// + private static async Task> ReadPatronesAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = await ReadCsvRowsAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + var res = new List(Math.Max(0, rows.Count - 1)); + + int iId = IndexOf(h, "ID"); + int iLat = IndexOf(h, "PTO_LATITUD_Y"); + int iLon = IndexOf(h, "PTO_LONGITUD_X"); + int iSec = IndexOf(h, "PTO_SECUENCIA"); + int iDist = IndexOf(h, "DISTANCIA_RECORRIDA"); + + foreach (var row in rows.Skip(1)) + { + res.Add(new FilaPatronCsv( + Get(row, iId), + ParseDouble(row, iLat), + ParseDouble(row, iLon), + ParseInt(row, iSec), + ParseDouble(row, iDist))); + } + + return res; + } + + /// + /// Lee rutas. + /// + private static async Task> ReadRutasAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = await ReadCsvRowsAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + var res = new List(Math.Max(0, rows.Count - 1)); + + int iId = IndexOf(h, "ID"); + int iAbreviatura = IndexOf(h, "ABREVIATURA"); + int iNombre = IndexOf(h, "NOMBRE"); + int iDescripcion = IndexOf(h, "DESCRIPCION"); + int iTipoVehiculo = IndexOf(h, "TIPO_VEHICULO"); + int iUrl = IndexOf(h, "URL"); + int iColor = IndexOf(h, "COLOR"); + + foreach (var row in rows.Skip(1)) + { + res.Add(new FilaRutaCsv( + Get(row, iId), + Get(row, iAbreviatura), + Get(row, iNombre), + Get(row, iDescripcion), + Get(row, iTipoVehiculo), + Get(row, iUrl), + Get(row, iColor))); + } + + return res; + } + + /// + /// Lee tarifas. + /// + private static async Task> ReadTarifasAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = await ReadCsvRowsAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + var res = new List(Math.Max(0, rows.Count - 1)); + + int iId = IndexOf(h, "ID"); + int iTarRu = IndexOf(h, "ID_TAR_RU"); + int iParadaOrigen = IndexOf(h, "ID_PARADA_ORIGEN"); + int iParadaDestino = IndexOf(h, "ID_PARADA_DESTINO"); + int iZonaOrigen = IndexOf(h, "ID_ZONA_ORIGEN"); + int iZonaDestino = IndexOf(h, "ID_ZONA_DESTINO"); + int iPrecio = IndexOf(h, "PRECIO"); + int iTipoPago = IndexOf(h, "TIPO_PAGO"); + int iTiempoTransbordo = IndexOf(h, "TIEMPO_TRANSBORDO"); + int iTransbordos = IndexOf(h, "TRANSBORDOS"); + + foreach (var row in rows.Skip(1)) + { + res.Add(new FilaTarifaCsv( + Get(row, iId), + Get(row, iTarRu), + Get(row, iParadaOrigen), + Get(row, iParadaDestino), + Get(row, iZonaOrigen), + Get(row, iZonaDestino), + ParseNullableDecimal(row, iPrecio), + Get(row, iTipoPago), + ParseNullableInt(row, iTiempoTransbordo), + ParseNullableInt(row, iTransbordos))); + } + + return res; + } + + /// + /// Lee tiempos parada. + /// + private static async Task> ReadTiemposParadaAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = await ReadCsvRowsAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + var res = new List(Math.Max(0, rows.Count - 1)); + + int iExp = IndexOf(h, "ID_EXP"); + int iLlegada = IndexOf(h, "HORA_LLEGADA"); + int iSalida = IndexOf(h, "HORA_SALIDA"); + int iDistancia = IndexOf(h, "DISTANCIA_SIGUIENTE_PARADA"); + int iParada = IndexOf(h, "ID_TIEMPO_PAR"); + int iSec = IndexOf(h, "SEC_PAR"); + int iSubida = IndexOf(h, "TIPO_SUBIDA_PAR"); + int iBajada = IndexOf(h, "TIPO_BAJADA_PAR"); + int iDestino = IndexOf(h, "NUEVA_DIR_DESTINO_PAR"); + + foreach (var row in rows.Skip(1)) + { + res.Add(new FilaTiempoParadaCsv( + Get(row, iExp), + ParseTime(Get(row, iLlegada)), + ParseTime(Get(row, iSalida)), + ParseDouble(row, iDistancia), + ParseInt(row, iParada), + ParseInt(row, iSec), + Get(row, iSubida), + Get(row, iBajada), + Get(row, iDestino))); + } + + return res; + } + + /// + /// Lee tiempos reales. + /// + private static async Task> ReadTiemposRealesAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = await ReadCsvRowsAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + var res = new List(Math.Max(0, rows.Count - 1)); + + int iIdRu = IndexOf(h, "ID_RU"); + int iServicio = IndexOf(h, "ID_SERVICIO"); + int iTrans = IndexOf(h, "ID_TRANS"); + int iLat = IndexOf(h, "LATITUD_Y"); + int iLon = IndexOf(h, "LONGITUD_X"); + int iHoraEmision = IndexOf(h, "HORA_EMISION"); + int iAntPar = IndexOf(h, "ID_ANT_PAR"); + int iFechaPaso = IndexOf(h, "FECHA_HORA_PASO"); + int iSalida = IndexOf(h, "H_SALIDA"); + int iDiff = IndexOf(h, "MINUTOS_DE_DIFERENCIA"); + int iTeorico = IndexOf(h, "TEORICO"); + + foreach (var row in rows.Skip(1)) + { + res.Add(new FilaTiempoRealCsv( + Get(row, iIdRu), + Get(row, iServicio), + Get(row, iTrans), + ParseDouble(row, iLat), + ParseDouble(row, iLon), + Get(row, iHoraEmision), + Get(row, iAntPar), + Get(row, iFechaPaso), + ParseTime(Get(row, iSalida)), + ParseNullableInt(row, iDiff), + Get(row, iTeorico))); + } + + return res; + } + + /// + /// Lee transbordos. + /// + private static async Task> ReadTransbordosAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = await ReadCsvRowsAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + var res = new List(Math.Max(0, rows.Count - 1)); + + int iRuta1 = IndexOf(h, "ID_RUTA_1"); + int iRuta2 = IndexOf(h, "ID_RUTA_2"); + + foreach (var row in rows.Skip(1)) + { + res.Add(new FilaTransbordoCsv(Get(row, iRuta1), Get(row, iRuta2))); + } + + return res; + } + + /// + /// Lee vehiculos. + /// + private static async Task> ReadVehiculosAsync(ZipArchiveEntry? entry, CancellationToken ct) + { + var rows = await ReadCsvRowsAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + var res = new List(Math.Max(0, rows.Count - 1)); + + int iId = IndexOf(h, "ID"); + int iTipo = IndexOf(h, "ID_TIPO"); + int iDescripcion = IndexOf(h, "DESCRIPCION_TIPO"); + int iCapacidad = IndexOf(h, "CAPACIDAD_MAXIMA"); + int iAsientos = IndexOf(h, "ASIENTOS"); + int iPuertas = IndexOf(h, "NUM_PUERTAS"); + int iAforo = IndexOf(h, "AFORO_ACTUAL"); + + foreach (var row in rows.Skip(1)) + { + res.Add(new FilaVehiculoCsv( + Get(row, iId), + Get(row, iTipo), + Get(row, iDescripcion), + ParseNullableInt(row, iCapacidad), + ParseNullableInt(row, iAsientos), + ParseNullableInt(row, iPuertas), + ParseNullableInt(row, iAforo))); + } + + return res; + } +} diff --git a/RutasDBUS/Servicios/Importacion/ServicioArchivoImportacionDbusMonitorizado.cs b/RutasDBUS/Servicios/Importacion/ServicioArchivoImportacionDbusMonitorizado.cs new file mode 100644 index 0000000..58f34c6 --- /dev/null +++ b/RutasDBUS/Servicios/Importacion/ServicioArchivoImportacionDbusMonitorizado.cs @@ -0,0 +1,429 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Options; +using RutasDBUS.Modelos.Importacion; +using RutasDBUS.Modelos.RedBus; + +namespace RutasDBUS.Servicios.Importacion; + +public sealed class ServicioArchivoImportacionDbusMonitorizado : IServicioArchivoImportacionDbus +{ + private const long MaxModeloSubidoBytes = 250L * 1024 * 1024; + + private readonly IWebHostEnvironment _entorno; + private readonly IOptions _opciones; + private readonly SemaphoreSlim _semaforo = new(1, 1); + + private ArchivoImportacionDbus? _archivo; + private string? _rutaZipCargada; + private DateTime _fechaZipCargadaUtc; + private string? _carpetaDatos; + + public ServicioArchivoImportacionDbusMonitorizado( + IWebHostEnvironment entorno, + IOptions opciones) + { + _entorno = entorno; + _opciones = opciones; + } + + public async Task ObtenerAsync(CancellationToken cancellationToken = default) + { + var rutaZip = ResolverRutaZip(); + if (rutaZip is null || !File.Exists(rutaZip)) + throw new FileNotFoundException("No se ha encontrado el ZIP de importacion de dBUS."); + + var infoZip = new FileInfo(rutaZip); + if (_archivo is not null && + string.Equals(_rutaZipCargada, infoZip.FullName, StringComparison.OrdinalIgnoreCase) && + _fechaZipCargadaUtc == infoZip.LastWriteTimeUtc) + { + return _archivo; + } + + await _semaforo.WaitAsync(cancellationToken); + try + { + if (_archivo is not null && + string.Equals(_rutaZipCargada, infoZip.FullName, StringComparison.OrdinalIgnoreCase) && + _fechaZipCargadaUtc == infoZip.LastWriteTimeUtc) + { + return _archivo; + } + + var importador = new ServicioArchivoImportacionDbus(_entorno, _opciones); + _archivo = await importador.ObtenerDesdeRutaAsync(infoZip.FullName, cancellationToken); + _rutaZipCargada = infoZip.FullName; + _fechaZipCargadaUtc = infoZip.LastWriteTimeUtc; + return _archivo; + } + finally + { + _semaforo.Release(); + } + } + + public Task> ListarModelosAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var rutaActiva = ResolverRutaZip(); + var modelos = EnumerarRutasModelos() + .Select(modelo => CrearInfoModelo(modelo.Ruta, modelo.Origen, rutaActiva)) + .OrderByDescending(modelo => modelo.Activo) + .ThenByDescending(modelo => modelo.FechaUtc) + .ThenBy(modelo => modelo.NombreArchivo, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return Task.FromResult>(modelos); + } + + public async Task ObtenerModeloActivoAsync( + CancellationToken cancellationToken = default) + { + var modelos = await ListarModelosAsync(cancellationToken); + return modelos.FirstOrDefault(modelo => modelo.Activo); + } + + public async Task SeleccionarModeloAsync( + string idModelo, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(idModelo)) + throw new ArgumentException("No se ha indicado el modelo de horarios.", nameof(idModelo)); + + var modelo = EnumerarRutasModelos() + .FirstOrDefault(x => string.Equals(CrearIdModelo(x.Ruta), idModelo, StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrWhiteSpace(modelo.Ruta) || !File.Exists(modelo.Ruta)) + throw new FileNotFoundException("No se ha encontrado el modelo de horarios seleccionado."); + + await ValidarModeloAsync(modelo.Ruta, cancellationToken); + await SeleccionarRutaModeloAsync(modelo.Ruta, cancellationToken); + } + + public async Task GuardarModeloSubidoAsync( + string nombreArchivo, + Stream contenido, + CancellationToken cancellationToken = default) + { + if (contenido is null) + throw new ArgumentNullException(nameof(contenido)); + + var nombreSeguro = CrearNombreArchivoSeguro(nombreArchivo); + var carpetaDestino = ResolverCarpetaModelosSubidos(crear: true); + var rutaDestino = ResolverRutaDestinoUnica(carpetaDestino, nombreSeguro); + + await CopiarStreamLimitadoAsync(contenido, rutaDestino, MaxModeloSubidoBytes, cancellationToken); + + try + { + await ValidarModeloAsync(rutaDestino, cancellationToken); + } + catch + { + try + { + if (File.Exists(rutaDestino)) + File.Delete(rutaDestino); + } + catch + { + } + + throw; + } + + await SeleccionarRutaModeloAsync(rutaDestino, cancellationToken); + + return CrearInfoModelo(rutaDestino, "Subido", rutaDestino); + } + + private async Task ValidarModeloAsync(string rutaZip, CancellationToken cancellationToken) + { + var importador = new ServicioArchivoImportacionDbus(_entorno, _opciones); + await importador.ObtenerDesdeRutaAsync(rutaZip, cancellationToken); + } + + private async Task SeleccionarRutaModeloAsync(string rutaZip, CancellationToken cancellationToken) + { + var rutaCompleta = Path.GetFullPath(rutaZip); + if (!File.Exists(rutaCompleta)) + throw new FileNotFoundException("No se ha encontrado el modelo de horarios seleccionado.", rutaCompleta); + + await _semaforo.WaitAsync(cancellationToken); + try + { + GuardarRutaActiva(rutaCompleta); + InvalidarCacheArchivo(); + } + finally + { + _semaforo.Release(); + } + } + + private void InvalidarCacheArchivo() + { + _archivo = null; + _rutaZipCargada = null; + _fechaZipCargadaUtc = default; + } + + private string? ResolverRutaZip() + { + var rutaActiva = LeerRutaActiva(); + if (!string.IsNullOrWhiteSpace(rutaActiva) && File.Exists(rutaActiva)) + return Path.GetFullPath(rutaActiva); + + var opciones = _opciones.Value ?? new OpcionesRedBus(); + if (!string.IsNullOrWhiteSpace(opciones.ArchivoDatos)) + { + var configurado = Path.Combine( + _entorno.ContentRootPath, + opciones.ArchivoDatos.Replace('/', Path.DirectorySeparatorChar)); + + if (File.Exists(configurado)) + return Path.GetFullPath(configurado); + } + + var carpeta = Path.Combine(_entorno.ContentRootPath, "Modelos", "Horarios"); + if (!Directory.Exists(carpeta)) + return null; + + return Directory.GetFiles(carpeta, "*.zip", SearchOption.TopDirectoryOnly) + .Select(ruta => new FileInfo(ruta)) + .OrderByDescending(info => info.LastWriteTimeUtc) + .FirstOrDefault() + ?.FullName; + } + + private IEnumerable<(string Ruta, string Origen)> EnumerarRutasModelos() + { + var vistos = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var ruta in EnumerarZips(Path.Combine(_entorno.ContentRootPath, "Modelos", "Horarios"))) + { + if (vistos.Add(ruta)) + yield return (ruta, "Incluido"); + } + + foreach (var ruta in EnumerarZips(ResolverCarpetaModelosSubidos(crear: false))) + { + if (vistos.Add(ruta)) + yield return (ruta, "Subido"); + } + } + + private static IEnumerable EnumerarZips(string carpeta) + { + if (string.IsNullOrWhiteSpace(carpeta) || !Directory.Exists(carpeta)) + yield break; + + foreach (var ruta in Directory.GetFiles(carpeta, "*.zip", SearchOption.TopDirectoryOnly)) + yield return Path.GetFullPath(ruta); + } + + private ModeloHorarioDbusInfo CrearInfoModelo(string ruta, string origen, string? rutaActiva) + { + var info = new FileInfo(ruta); + return new ModeloHorarioDbusInfo( + CrearIdModelo(info.FullName), + info.Name, + CrearRutaMostrable(info.FullName), + origen, + info.Length, + info.LastWriteTimeUtc, + !string.IsNullOrWhiteSpace(rutaActiva) && + string.Equals(Path.GetFullPath(rutaActiva), info.FullName, StringComparison.OrdinalIgnoreCase)); + } + + private static string CrearIdModelo(string ruta) + { + var rutaNormalizada = Path.GetFullPath(ruta).ToUpperInvariant(); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(rutaNormalizada)); + return Convert.ToHexString(hash); + } + + private string CrearRutaMostrable(string ruta) + { + try + { + var relativa = Path.GetRelativePath(_entorno.ContentRootPath, ruta); + return relativa.StartsWith("..", StringComparison.Ordinal) + ? ruta + : relativa.Replace('\\', '/'); + } + catch + { + return ruta; + } + } + + private string ResolverCarpetaDatos() + { + if (!string.IsNullOrWhiteSpace(_carpetaDatos)) + return _carpetaDatos; + + var candidatas = new[] + { + Path.Combine(_entorno.ContentRootPath, "App_Data"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "RutasDBUS"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "RutasDBUS"), + Path.Combine(Path.GetTempPath(), "RutasDBUS") + }; + + foreach (var candidata in candidatas.Where(x => !string.IsNullOrWhiteSpace(x))) + { + try + { + Directory.CreateDirectory(candidata); + var prueba = Path.Combine(candidata, $".write-test-{Guid.NewGuid():N}.tmp"); + File.WriteAllText(prueba, "ok", Encoding.UTF8); + File.Delete(prueba); + _carpetaDatos = candidata; + return _carpetaDatos; + } + catch + { + } + } + + throw new IOException("No se ha encontrado una carpeta escribible para guardar modelos de horarios."); + } + + private string ResolverCarpetaModelosSubidos(bool crear) + { + var carpeta = Path.Combine(ResolverCarpetaDatos(), "ModelosHorarios"); + if (crear) + Directory.CreateDirectory(carpeta); + return carpeta; + } + + private string RutaEstadoActivo => Path.Combine(ResolverCarpetaDatos(), "modelo-horarios-activo.json"); + + private string? LeerRutaActiva() + { + try + { + if (!File.Exists(RutaEstadoActivo)) + return null; + + var json = File.ReadAllText(RutaEstadoActivo, Encoding.UTF8); + var estado = JsonSerializer.Deserialize(json); + return estado?.Ruta; + } + catch + { + return null; + } + } + + private void GuardarRutaActiva(string ruta) + { + var estado = new EstadoModeloHorario { Ruta = Path.GetFullPath(ruta) }; + var json = JsonSerializer.Serialize(estado, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(RutaEstadoActivo, json, Encoding.UTF8); + } + + private static string CrearNombreArchivoSeguro(string nombreArchivo) + { + var nombre = Path.GetFileName(nombreArchivo); + if (string.IsNullOrWhiteSpace(nombre)) + nombre = "modelo_dbus.zip"; + + if (!string.Equals(Path.GetExtension(nombre), ".zip", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Solo se pueden subir archivos .zip."); + + var baseNombre = Path.GetFileNameWithoutExtension(nombre); + if (string.IsNullOrWhiteSpace(baseNombre)) + baseNombre = "modelo_dbus"; + + var invalidos = Path.GetInvalidFileNameChars(); + var limpio = new string(baseNombre + .Select(ch => invalidos.Contains(ch) ? '_' : ch) + .ToArray()) + .Trim(); + + if (string.IsNullOrWhiteSpace(limpio)) + limpio = "modelo_dbus"; + + return limpio + ".zip"; + } + + private static string ResolverRutaDestinoUnica(string carpeta, string nombreArchivo) + { + var ruta = Path.Combine(carpeta, nombreArchivo); + if (!File.Exists(ruta)) + return ruta; + + var baseNombre = Path.GetFileNameWithoutExtension(nombreArchivo); + var extension = Path.GetExtension(nombreArchivo); + var marca = DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture); + + for (var i = 1; i <= 999; i++) + { + var candidato = Path.Combine(carpeta, $"{baseNombre}_{marca}_{i}{extension}"); + if (!File.Exists(candidato)) + return candidato; + } + + return Path.Combine(carpeta, $"{baseNombre}_{Guid.NewGuid():N}{extension}"); + } + + private static async Task CopiarStreamLimitadoAsync( + Stream origen, + string rutaDestino, + long maxBytes, + CancellationToken cancellationToken) + { + var total = 0L; + + try + { + await using var destino = new FileStream( + rutaDestino, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 81920, + useAsync: true); + + var buffer = new byte[81920]; + while (true) + { + var leidos = await origen.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + if (leidos == 0) + break; + + total += leidos; + if (total > maxBytes) + throw new InvalidOperationException("El ZIP supera el tamano maximo permitido."); + + await destino.WriteAsync(buffer.AsMemory(0, leidos), cancellationToken); + } + } + catch + { + try + { + if (File.Exists(rutaDestino)) + File.Delete(rutaDestino); + } + catch + { + } + + throw; + } + } + + private sealed class EstadoModeloHorario + { + public string? Ruta { get; set; } + } +} diff --git a/RutasDBUS/Servicios/Planificacion/BuscadorRutasTemporalesDbus.cs b/RutasDBUS/Servicios/Planificacion/BuscadorRutasTemporalesDbus.cs new file mode 100644 index 0000000..bcff0b5 --- /dev/null +++ b/RutasDBUS/Servicios/Planificacion/BuscadorRutasTemporalesDbus.cs @@ -0,0 +1,1313 @@ +using RutasDBUS.Modelos.Planificacion; +using RutasDBUS.Modelos.RedBus; + +namespace RutasDBUS.Servicios.Planificacion; + +/// +/// Busca rutas de bus mediante rondas sobre patrones y expediciones. +/// +public sealed class BuscadorRutasTemporalesDbus +{ + private const int MaxEstadosPorParada = 4; + private const int MaxContinuidadesCircularesExtra = 2; + + /// + /// Busca alternativas de bus entre grupos de paradas candidatas. + /// + public IReadOnlyList BuscarAlternativas( + IndiceTransitoDbus indice, + IReadOnlyList candidatasOrigen, + IReadOnlyList candidatasDestino, + DateTime instanteSalida, + ParametrosPlanificadorRuta parametros, + IReadOnlySet? calendariosActivos, + CancellationToken cancellationToken = default) + { + var origenPorCodigo = candidatasOrigen + .GroupBy(candidata => candidata.Parada.Codigo, StringComparer.OrdinalIgnoreCase) + .ToDictionary(grupo => grupo.Key, grupo => grupo.OrderBy(x => x.TiempoAPie).First(), StringComparer.OrdinalIgnoreCase); + var destinoPorCodigo = candidatasDestino + .GroupBy(candidata => candidata.Parada.Codigo, StringComparer.OrdinalIgnoreCase) + .ToDictionary(grupo => grupo.Key, grupo => grupo.OrderBy(x => x.TiempoAPie).First(), StringComparer.OrdinalIgnoreCase); + + var rondas = new List>(); + var rondaInicial = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var candidata in candidatasOrigen.OrderBy(x => x.TiempoAPie)) + { + var estado = new EstadoBusquedaParadaDbus + { + CodigoParada = candidata.Parada.Codigo, + Llegada = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, candidata.TiempoAPie), + DistanciaCaminadaTotal = candidata.DistanciaAPie, + DistanciaPieTransbordos = 0, + TiempoEsperaInicialSegundos = 0, + TiempoEsperaTransbordosSegundos = 0, + TransbordosReales = 0, + BusesTomados = 0, + Movimiento = new MovimientoAccesoDbus + { + DistanciaMetros = candidata.DistanciaAPie, + DuracionSegundos = candidata.TiempoAPie + } + }; + + IntentarActualizarEstado(rondaInicial, estado); + } + + rondas.Add(rondaInicial); + var marcadas = new HashSet(rondaInicial.Values.Select(estado => estado.CodigoParada), StringComparer.OrdinalIgnoreCase); + var maximoBuses = ResolverMaximoBuses(parametros); + var codigosParadaRelevantes = CrearFiltroParadasRelevantes(indice, candidatasDestino, parametros); + + for (var ronda = 1; ronda <= maximoBuses && marcadas.Count > 0; ronda++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var estadosActuales = new Dictionary(StringComparer.OrdinalIgnoreCase); + var estadosPreviosPorParada = IndexarEstadosPorParada(rondas[ronda - 1].Values); + var patronesMarcados = new HashSet(); + + foreach (var codigoParada in marcadas) + { + if (!indice.PatronesPorParada.TryGetValue(codigoParada, out var referencias)) + continue; + + foreach (var referencia in referencias) + { + if (PatronPermitido(referencia.Patron, parametros)) + patronesMarcados.Add(referencia.Patron); + } + } + + var bloqueoEstados = new object(); + Parallel.ForEach(patronesMarcados, new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = Math.Clamp(Environment.ProcessorCount, 1, 4) + }, patron => + { + var estadosLocales = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (parametros.UsarHorariosReales) + { + var cacheExpediciones = new Dictionary(StringComparer.Ordinal); + EscanearPatronConHorarios( + patron, + estadosPreviosPorParada, + estadosLocales, + instanteSalida.Date, + parametros, + calendariosActivos, + cacheExpediciones, + codigosParadaRelevantes, + cancellationToken); + } + else + { + EscanearPatronAproximado( + patron, + estadosPreviosPorParada, + estadosLocales, + parametros, + codigosParadaRelevantes, + cancellationToken); + } + + if (estadosLocales.Count == 0) + return; + + lock (bloqueoEstados) + { + foreach (var estado in estadosLocales.Values) + IntentarActualizarEstado(estadosActuales, estado); + } + }); + + RelajarTransferencias(indice, estadosActuales, parametros, codigosParadaRelevantes, cancellationToken); + PodarEstadosPorParada(estadosActuales); + rondas.Add(estadosActuales); + marcadas = new HashSet(estadosActuales.Values.Select(estado => estado.CodigoParada), StringComparer.OrdinalIgnoreCase); + } + + return ConstruirResultados(rondas, origenPorCodigo, destinoPorCodigo, instanteSalida, parametros); + } + + /// + /// Calcula el maximo de buses a considerar. + /// + private static int ResolverMaximoBuses(ParametrosPlanificadorRuta parametros) + => Math.Max(2, Math.Max(0, parametros.MaxTransbordosPermitidos) + 1 + MaxContinuidadesCircularesExtra); + + /// + /// Recorre un patron en modo horario real. + /// + private static void EscanearPatronConHorarios( + PatronTransitoDbus patron, + IReadOnlyDictionary> estadosPrevios, + IDictionary estadosActuales, + DateTime fechaBase, + ParametrosPlanificadorRuta parametros, + IReadOnlySet? calendariosActivos, + IDictionary cacheExpediciones, + IReadOnlySet? codigosParadaRelevantes, + CancellationToken cancellationToken) + { + if (calendariosActivos is null || calendariosActivos.Count == 0) + return; + + for (var indiceSubida = 0; indiceSubida < patron.CodigosParada.Count - 1; indiceSubida++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var codigoParada = patron.CodigosParada[indiceSubida]; + foreach (var estadoPrevio in ObtenerEstadosEnParada(estadosPrevios, codigoParada)) + { + var expedicion = EncontrarPrimeraExpedicionValida( + patron, + indiceSubida, + estadoPrevio, + fechaBase, + parametros, + calendariosActivos, + cacheExpediciones, + out var esTransbordoReal, + out var salida); + + if (expedicion is null || salida is null) + continue; + + var transbordosReales = estadoPrevio.TransbordosReales + (esTransbordoReal ? 1 : 0); + if (!TransbordosEnRangoParcial(transbordosReales, parametros)) + continue; + + var esperaSegundos = Math.Max(0, (salida.Value - estadoPrevio.Llegada).TotalSeconds); + var esperaInicial = estadoPrevio.TiempoEsperaInicialSegundos + (!esTransbordoReal ? esperaSegundos : 0); + var esperaTransbordos = estadoPrevio.TiempoEsperaTransbordosSegundos + (esTransbordoReal ? esperaSegundos : 0); + + for (var indiceBajada = indiceSubida + 1; indiceBajada < patron.CodigosParada.Count; indiceBajada++) + { + if (!ParadaEsRelevante(patron.CodigosParada[indiceBajada], codigosParadaRelevantes)) + continue; + + var llegada = ObtenerFechaDesdeSegundos(fechaBase, expedicion.LlegadasSegundos[indiceBajada]); + var nuevoEstado = new EstadoBusquedaParadaDbus + { + CodigoParada = patron.CodigosParada[indiceBajada], + Llegada = llegada, + DistanciaCaminadaTotal = estadoPrevio.DistanciaCaminadaTotal, + DistanciaPieTransbordos = estadoPrevio.DistanciaPieTransbordos, + TiempoEsperaInicialSegundos = esperaInicial, + TiempoEsperaTransbordosSegundos = esperaTransbordos, + TransbordosReales = transbordosReales, + BusesTomados = estadoPrevio.BusesTomados + 1, + EstadoAnterior = estadoPrevio, + Movimiento = new MovimientoBusDbus + { + Patron = patron, + IndiceSubida = indiceSubida, + IndiceBajada = indiceBajada, + Salida = salida.Value, + Llegada = llegada + } + }; + + IntentarActualizarEstado(estadosActuales, nuevoEstado); + } + } + } + } + + /// + /// Recorre un patron en modo aproximado sin horarios. + /// + private static void EscanearPatronAproximado( + PatronTransitoDbus patron, + IReadOnlyDictionary> estadosPrevios, + IDictionary estadosActuales, + ParametrosPlanificadorRuta parametros, + IReadOnlySet? codigosParadaRelevantes, + CancellationToken cancellationToken) + { + for (var indiceSubida = 0; indiceSubida < patron.CodigosParada.Count - 1; indiceSubida++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var codigoParada = patron.CodigosParada[indiceSubida]; + foreach (var estadoPrevio in ObtenerEstadosEnParada(estadosPrevios, codigoParada)) + { + if (!PuedeTomarPatronDesdeEstado(estadoPrevio, patron, out var esTransbordoReal)) + continue; + + var salida = esTransbordoReal + ? estadoPrevio.Llegada.AddMinutes(parametros.MinutosPorTransbordo) + : estadoPrevio.Llegada; + var transbordosReales = estadoPrevio.TransbordosReales + (esTransbordoReal ? 1 : 0); + if (!TransbordosEnRangoParcial(transbordosReales, parametros)) + continue; + + for (var indiceBajada = indiceSubida + 1; indiceBajada < patron.CodigosParada.Count; indiceBajada++) + { + if (!ParadaEsRelevante(patron.CodigosParada[indiceBajada], codigosParadaRelevantes)) + continue; + + var distanciaBus = patron.DistanciaAcumuladaPorParadaMetros[indiceBajada] - patron.DistanciaAcumuladaPorParadaMetros[indiceSubida]; + var duracionBus = UtilidadesTransitoDbus.EstimarDuracionBusSegundos(distanciaBus, parametros) + + ((indiceBajada - indiceSubida) * Math.Max(0, parametros.SegundosPorParadaBus)); + var llegada = UtilidadesTransitoDbus.AddSecondsSafe(salida, duracionBus); + var nuevoEstado = new EstadoBusquedaParadaDbus + { + CodigoParada = patron.CodigosParada[indiceBajada], + Llegada = llegada, + DistanciaCaminadaTotal = estadoPrevio.DistanciaCaminadaTotal, + DistanciaPieTransbordos = estadoPrevio.DistanciaPieTransbordos, + TiempoEsperaInicialSegundos = estadoPrevio.TiempoEsperaInicialSegundos, + TiempoEsperaTransbordosSegundos = estadoPrevio.TiempoEsperaTransbordosSegundos + (esTransbordoReal ? parametros.MinutosPorTransbordo * 60.0 : 0), + TransbordosReales = transbordosReales, + BusesTomados = estadoPrevio.BusesTomados + 1, + EstadoAnterior = estadoPrevio, + Movimiento = new MovimientoBusDbus + { + Patron = patron, + IndiceSubida = indiceSubida, + IndiceBajada = indiceBajada, + Salida = salida, + Llegada = llegada + } + }; + + IntentarActualizarEstado(estadosActuales, nuevoEstado); + } + } + } + } + + /// + /// Propaga transferencias peatonales dentro de la misma ronda. + /// + private static void RelajarTransferencias( + IndiceTransitoDbus indice, + IDictionary estadosActuales, + ParametrosPlanificadorRuta parametros, + IReadOnlySet? codigosParadaRelevantes, + CancellationToken cancellationToken) + { + var cola = new Queue(estadosActuales.Values); + while (cola.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + var estado = cola.Dequeue(); + if (!indice.TransferenciasPorParada.TryGetValue(estado.CodigoParada, out var transferencias)) + continue; + + foreach (var transferencia in transferencias) + { + if (!ParadaEsRelevante(transferencia.CodigoParadaDestino, codigosParadaRelevantes)) + continue; + + var duracion = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(transferencia.DistanciaMetros, parametros); + var nuevoEstado = new EstadoBusquedaParadaDbus + { + CodigoParada = transferencia.CodigoParadaDestino, + Llegada = UtilidadesTransitoDbus.AddSecondsSafe(estado.Llegada, duracion), + DistanciaCaminadaTotal = estado.DistanciaCaminadaTotal + transferencia.DistanciaMetros, + DistanciaPieTransbordos = estado.DistanciaPieTransbordos + transferencia.DistanciaMetros, + TiempoEsperaInicialSegundos = estado.TiempoEsperaInicialSegundos, + TiempoEsperaTransbordosSegundos = estado.TiempoEsperaTransbordosSegundos, + TransbordosReales = estado.TransbordosReales, + BusesTomados = estado.BusesTomados, + EstadoAnterior = estado, + Movimiento = new MovimientoPieTransbordoDbus + { + DistanciaMetros = transferencia.DistanciaMetros, + DuracionSegundos = duracion + } + }; + + if (IntentarActualizarEstado(estadosActuales, nuevoEstado)) + cola.Enqueue(nuevoEstado); + } + } + } + + /// + /// Encuentra la primera expedicion utilizable desde una parada del patron. + /// + private static ExpedicionTransitoDbus? EncontrarPrimeraExpedicionValida( + PatronTransitoDbus patron, + int indiceSubida, + EstadoBusquedaParadaDbus estadoPrevio, + DateTime fechaBase, + ParametrosPlanificadorRuta parametros, + IReadOnlySet calendariosActivos, + IDictionary cacheExpediciones, + out bool esTransbordoReal, + out DateTime? salida) + { + salida = null; + esTransbordoReal = false; + + if (!PuedeTomarPatronDesdeEstado(estadoPrevio, patron, out esTransbordoReal)) + return null; + + var margenTransbordo = esTransbordoReal + ? Math.Clamp(parametros.MargenTransbordoSegundos, 0, 1800) + : 0; + var primeraSalidaAceptable = estadoPrevio.Llegada.AddSeconds(margenTransbordo); + var claveCache = CrearClaveExpedicion(patron, indiceSubida, fechaBase, primeraSalidaAceptable); + + if (!cacheExpediciones.TryGetValue(claveCache, out var cacheado)) + { + cacheado = EncontrarPrimeraExpedicionDesde( + patron, + indiceSubida, + fechaBase, + calendariosActivos, + primeraSalidaAceptable); + cacheExpediciones[claveCache] = cacheado; + } + + if (cacheado.expedicion is null || cacheado.salida is null) + return null; + + var esperaCacheadaSegundos = Math.Max(0, (cacheado.salida.Value - estadoPrevio.Llegada).TotalSeconds); + if (!EsEsperaValida(estadoPrevio, esperaCacheadaSegundos, esTransbordoReal, parametros)) + return null; + + salida = cacheado.salida; + return cacheado.expedicion; + } + + private static (ExpedicionTransitoDbus? expedicion, DateTime? salida) EncontrarPrimeraExpedicionDesde( + PatronTransitoDbus patron, + int indiceSubida, + DateTime fechaBase, + IReadOnlySet calendariosActivos, + DateTime primeraSalidaAceptable) + { + var expediciones = patron.Expediciones; + if (expediciones.Count == 0) + return (null, null); + + var segundosObjetivo = (int)Math.Ceiling((primeraSalidaAceptable - fechaBase.Date).TotalSeconds); + var indiceInicial = BuscarPrimerIndiceSalidaDesde(expediciones, indiceSubida, segundosObjetivo); + if (indiceInicial < 0) + return (null, null); + + for (var indiceExpedicion = indiceInicial; indiceExpedicion < expediciones.Count; indiceExpedicion++) + { + var expedicion = expediciones[indiceExpedicion]; + if (!calendariosActivos.Contains(expedicion.IdCalendario)) + continue; + + var salidaExpedicion = ObtenerFechaDesdeSegundos(fechaBase, expedicion.SalidasSegundos[indiceSubida]); + if (salidaExpedicion < primeraSalidaAceptable) + continue; + + return (expedicion, salidaExpedicion); + } + + return (null, null); + } + + private static int BuscarPrimerIndiceSalidaDesde( + IReadOnlyList expediciones, + int indiceSubida, + int segundosObjetivo) + { + var izquierda = 0; + var derecha = expediciones.Count - 1; + var encontrado = -1; + + while (izquierda <= derecha) + { + var medio = izquierda + ((derecha - izquierda) / 2); + var salida = expediciones[medio].SalidasSegundos[indiceSubida]; + if (salida >= segundosObjetivo) + { + encontrado = medio; + derecha = medio - 1; + } + else + { + izquierda = medio + 1; + } + } + + return encontrado; + } + + private static string CrearClaveExpedicion( + PatronTransitoDbus patron, + int indiceSubida, + DateTime fechaBase, + DateTime primeraSalidaAceptable) + { + var ticksSegundo = primeraSalidaAceptable.Ticks - primeraSalidaAceptable.Ticks % TimeSpan.TicksPerSecond; + return $"{patron.Id}|{indiceSubida}|{fechaBase:yyyyMMdd}|{ticksSegundo}"; + } + + /// + /// Comprueba si una espera respeta las restricciones configuradas. + /// + private static bool EsEsperaValida( + EstadoBusquedaParadaDbus estadoPrevio, + double esperaSegundos, + bool esTransbordoReal, + ParametrosPlanificadorRuta parametros) + { + if (estadoPrevio.BusesTomados == 0 && + parametros.MaxEsperaInicialMinutos > 0 && + esperaSegundos > parametros.MaxEsperaInicialMinutos * 60.0) + { + return false; + } + + if (esTransbordoReal && + parametros.MaxEsperaTransbordoMinutos > 0 && + esperaSegundos > parametros.MaxEsperaTransbordoMinutos * 60.0) + { + return false; + } + + var esperaTotal = estadoPrevio.TiempoEsperaInicialSegundos + estadoPrevio.TiempoEsperaTransbordosSegundos + esperaSegundos; + if (parametros.MaxEsperaTotalMinutos > 0 && + esperaTotal > parametros.MaxEsperaTotalMinutos * 60.0) + { + return false; + } + + return true; + } + + private static bool TransbordosEnRangoParcial(int transbordosReales, ParametrosPlanificadorRuta parametros) + { + if (!parametros.ActivarRangoTransbordos) + return true; + + var maximo = Math.Max(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos); + return transbordosReales <= maximo; + } + + private static HashSet? CrearFiltroParadasRelevantes( + IndiceTransitoDbus indice, + IReadOnlyList candidatasDestino, + ParametrosPlanificadorRuta parametros) + { + if (!parametros.ActivarRangoTransbordos) + return null; + + var maximoTransbordos = Math.Max(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos); + if (maximoTransbordos > 1) + return null; + + var relevantes = new HashSet(StringComparer.OrdinalIgnoreCase); + var subidasHaciaDestino = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var destino in candidatasDestino) + { + relevantes.Add(destino.Parada.Codigo); + if (!indice.PatronesPorParada.TryGetValue(destino.Parada.Codigo, out var referenciasDestino)) + continue; + + foreach (var referencia in referenciasDestino) + { + var patron = referencia.Patron; + if (!PatronPermitido(patron, parametros)) + continue; + + var indiceDestino = referencia.IndiceParada; + if (indiceDestino <= 0 || indiceDestino >= patron.CodigosParada.Count) + continue; + + for (var indiceSubida = 0; indiceSubida < indiceDestino; indiceSubida++) + subidasHaciaDestino.Add(patron.CodigosParada[indiceSubida]); + } + } + + foreach (var codigoSubida in subidasHaciaDestino) + relevantes.Add(codigoSubida); + + foreach (var transferenciaOrigen in indice.TransferenciasPorParada) + { + if (transferenciaOrigen.Value.Any(transferencia => + subidasHaciaDestino.Contains(transferencia.CodigoParadaDestino))) + { + relevantes.Add(transferenciaOrigen.Key); + } + } + + foreach (var referenciaPorParada in indice.PatronesPorParada) + { + if (EsPivoteCircular(referenciaPorParada.Value)) + relevantes.Add(referenciaPorParada.Key); + } + + return relevantes; + } + + private static bool ParadaEsRelevante(string codigoParada, IReadOnlySet? codigosParadaRelevantes) + => codigosParadaRelevantes is null || codigosParadaRelevantes.Contains(codigoParada); + + private static bool EsPivoteCircular(IReadOnlyList referencias) + { + for (var i = 0; i < referencias.Count; i++) + { + var izquierda = referencias[i].Patron; + for (var j = i + 1; j < referencias.Count; j++) + { + if (EsContinuidadCircular(izquierda, referencias[j].Patron)) + return true; + } + } + + return false; + } + + private static bool EsContinuidadCircular(PatronTransitoDbus primero, PatronTransitoDbus segundo) + { + return string.Equals(primero.CodigoLinea, segundo.CodigoLinea, StringComparison.OrdinalIgnoreCase) && + primero.CodigoItinerario == segundo.CodigoItinerario && + !string.Equals( + UtilidadesTransitoDbus.NormalizarSentidoCodigo(primero.Sentido), + UtilidadesTransitoDbus.NormalizarSentidoCodigo(segundo.Sentido), + StringComparison.OrdinalIgnoreCase); + } + + private static bool EsTransbordoRealDesdeEstado(EstadoBusquedaParadaDbus estadoPrevio, PatronTransitoDbus patron) + { + if (estadoPrevio.BusesTomados == 0) + return false; + + if (HayTransbordoPeatonalDesdeUltimoBus(estadoPrevio)) + return true; + + var movimientoBusAnterior = BuscarMovimientoBusAnterior(estadoPrevio); + return movimientoBusAnterior is null || + UtilidadesTransitoDbus.EsTransbordoReal(movimientoBusAnterior.Patron.Variante, patron.Variante); + } + + private static bool PuedeTomarPatronDesdeEstado( + EstadoBusquedaParadaDbus estadoPrevio, + PatronTransitoDbus patron, + out bool esTransbordoReal) + { + esTransbordoReal = EsTransbordoRealDesdeEstado(estadoPrevio, patron); + if (estadoPrevio.BusesTomados == 0 || esTransbordoReal) + return true; + + return ContarContinuidadesNoRealesConsecutivas(estadoPrevio) < MaxContinuidadesCircularesExtra; + } + + private static bool HayTransbordoPeatonalDesdeUltimoBus(EstadoBusquedaParadaDbus estado) + { + var cursor = estado; + while (cursor is not null) + { + if (cursor.Movimiento is MovimientoBusDbus) + return false; + + if (cursor.Movimiento is MovimientoPieTransbordoDbus) + return true; + + cursor = cursor.EstadoAnterior; + } + + return false; + } + + private static int ContarContinuidadesNoRealesConsecutivas(EstadoBusquedaParadaDbus estado) + { + var continuidades = 0; + MovimientoBusDbus? busPosterior = null; + var cursor = estado; + + while (cursor is not null) + { + if (cursor.Movimiento is MovimientoPieTransbordoDbus) + return continuidades; + + if (cursor.Movimiento is MovimientoBusDbus bus) + { + if (busPosterior is not null) + { + if (UtilidadesTransitoDbus.EsTransbordoReal(bus.Patron.Variante, busPosterior.Patron.Variante)) + return continuidades; + + continuidades++; + } + + busPosterior = bus; + } + + cursor = cursor.EstadoAnterior; + } + + return continuidades; + } + + private static MovimientoBusDbus? BuscarMovimientoBusAnterior(EstadoBusquedaParadaDbus? estado) + { + var cursor = estado; + while (cursor is not null) + { + if (cursor.Movimiento is MovimientoBusDbus bus) + return bus; + + cursor = cursor.EstadoAnterior; + } + + return null; + } + + /// + /// Convierte rondas exactas en rutas candidatas deduplicadas. + /// + private static IReadOnlyList ConstruirResultados( + IReadOnlyList> rondas, + IReadOnlyDictionary origenPorCodigo, + IReadOnlyDictionary destinoPorCodigo, + DateTime instanteSalida, + ParametrosPlanificadorRuta parametros) + { + var mejores = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var estadosRonda in rondas.Skip(1)) + { + var estadosPorParada = IndexarEstadosPorParada(estadosRonda.Values); + foreach (var destino in destinoPorCodigo.Values) + { + foreach (var estadoDestino in ObtenerEstadosEnParada(estadosPorParada, destino.Parada.Codigo)) + { + var tramosBus = NormalizarTramosBus(ConstruirTramosBus(estadoDestino)); + if (tramosBus.Count == 0) + continue; + + if (UtilidadesTransitoDbus.TieneContinuidadesNoRealesConsecutivasExcesivas( + tramosBus, + MaxContinuidadesCircularesExtra)) + { + continue; + } + + var numeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(tramosBus); + if (parametros.ActivarRangoTransbordos) + { + var minimo = Math.Min(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos); + var maximo = Math.Max(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos); + if (numeroTransbordos < minimo || numeroTransbordos > maximo) + continue; + } + + var primeraParada = tramosBus[0].CodigoParadaInicio; + if (!origenPorCodigo.TryGetValue(primeraParada, out var origen)) + continue; + + var ruta = new RutaBusAlternativa + { + Tramos = tramosBus, + NumeroTransbordos = numeroTransbordos, + CodigosParadasTransbordo = ConstruirCodigosTransbordo(tramosBus) + }; + + var duracionEstimada = Math.Max(0, (estadoDestino.Llegada - instanteSalida).TotalSeconds) + destino.TiempoAPie; + var puntuacionCoste = UtilidadesTransitoDbus.CalcularCosteConPenalizacionesRankingSegundos( + duracionEstimada, + parametros, + numeroTransbordos, + origen.DistanciaAPie, + estadoDestino.DistanciaPieTransbordos, + destino.DistanciaAPie); + + var resultado = new ResultadoBusquedaRutaBusDbus + { + ParadaOrigen = origen.Parada, + ParadaDestino = destino.Parada, + DistanciaPieInicio = origen.DistanciaAPie, + TiempoPieInicio = origen.TiempoAPie, + DistanciaPieFin = destino.DistanciaAPie, + TiempoPieFin = destino.TiempoAPie, + DistanciaPieTransbordos = estadoDestino.DistanciaPieTransbordos, + TiempoEsperaInicialSegundos = estadoDestino.TiempoEsperaInicialSegundos, + TiempoEsperaTransbordosSegundos = estadoDestino.TiempoEsperaTransbordosSegundos, + DuracionEstimadaSegundos = duracionEstimada, + PuntuacionCoste = puntuacionCoste, + RutaBus = ruta + }; + + var firma = UtilidadesTransitoDbus.FirmaRutaBus(ruta); + if (!mejores.TryGetValue(firma, out var actual) || + ResultadoEsMejor(resultado, actual)) + { + mejores[firma] = resultado; + } + } + } + } + + return SeleccionarPoolBusqueda( + mejores.Values, + Math.Max(12, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 8)); + } + + /// + /// Conserva candidatas buenas por coste y un cupo por menor caminata. + /// + private static List SeleccionarPoolBusqueda( + IEnumerable candidatas, + int maxPorCoste) + { + var lista = candidatas.ToList(); + if (lista.Count == 0) + return new(); + + var seleccionadas = new List(); + var firmas = new HashSet(StringComparer.OrdinalIgnoreCase); + var limiteCoste = Math.Max(1, maxPorCoste); + var limiteAndar = Math.Max(8, limiteCoste / 2); + + Agregar(lista + .OrderBy(x => x.PuntuacionCoste) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.RutaBus.NumeroTransbordos) + .ThenBy(x => x.DistanciaPieTotal) + .Take(limiteCoste)); + + Agregar(lista + .OrderBy(x => x.DistanciaPieTotal) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.PuntuacionCoste) + .ThenBy(x => x.RutaBus.NumeroTransbordos) + .Take(limiteAndar)); + + return seleccionadas + .OrderBy(x => x.PuntuacionCoste) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.RutaBus.NumeroTransbordos) + .ThenBy(x => x.DistanciaPieTotal) + .ToList(); + + void Agregar(IEnumerable origen) + { + foreach (var candidata in origen) + { + var firma = UtilidadesTransitoDbus.FirmaRutaBus(candidata.RutaBus); + if (firmas.Add(firma)) + seleccionadas.Add(candidata); + } + } + } + + /// + /// Reconstruye los tramos de bus de una ruta. + /// + private static List ConstruirTramosBus(EstadoBusquedaParadaDbus estadoDestino) + { + var movimientos = new Stack(); + var cursor = estadoDestino; + + while (cursor is not null) + { + if (cursor.Movimiento is MovimientoBusDbus movimientoBus) + movimientos.Push(movimientoBus); + + cursor = cursor.EstadoAnterior; + } + + var tramos = new List(movimientos.Count); + while (movimientos.Count > 0) + { + var movimiento = movimientos.Pop(); + tramos.Add(new TramoBus + { + Variante = movimiento.Patron.Variante, + IndiceInicio = movimiento.IndiceSubida, + IndiceFin = movimiento.IndiceBajada + }); + } + + return tramos; + } + + /// + /// Fusiona tramos consecutivos que realmente pertenecen a la misma variante. + /// + private static List NormalizarTramosBus(List tramosOriginales) + { + if (tramosOriginales.Count <= 1) + return tramosOriginales; + + var resultado = new List(tramosOriginales.Count); + foreach (var tramo in tramosOriginales) + { + if (!tramo.EsValido) + continue; + + if (resultado.Count == 0) + { + resultado.Add(tramo); + continue; + } + + var ultimo = resultado[^1]; + if (SonMismaVariante(ultimo, tramo) && tramo.IndiceInicio <= ultimo.IndiceFin) + { + ultimo.IndiceFin = Math.Max(ultimo.IndiceFin, tramo.IndiceFin); + continue; + } + + if (SonMismaVariante(ultimo, tramo) && tramo.IndiceInicio >= ultimo.IndiceFin) + { + ultimo.IndiceFin = Math.Max(ultimo.IndiceFin, tramo.IndiceFin); + continue; + } + + resultado.Add(tramo); + } + + return resultado; + } + + /// + /// Indica si dos tramos recorren la misma variante operativa. + /// + private static bool SonMismaVariante(TramoBus izquierda, TramoBus derecha) + { + return string.Equals( + UtilidadesTransitoDbus.LineaCodigoStr(izquierda.Variante), + UtilidadesTransitoDbus.LineaCodigoStr(derecha.Variante), + StringComparison.OrdinalIgnoreCase) && + izquierda.Variante.CodigoItinerario == derecha.Variante.CodigoItinerario && + string.Equals( + UtilidadesTransitoDbus.NormalizarSentidoCodigo(izquierda.Variante.Sentido?.Codigo), + UtilidadesTransitoDbus.NormalizarSentidoCodigo(derecha.Variante.Sentido?.Codigo), + StringComparison.OrdinalIgnoreCase) && + izquierda.Variante.Variante.Codigo == derecha.Variante.Variante.Codigo; + } + + /// + /// Reconstruye las paradas de transbordo visibles. + /// + private static List ConstruirCodigosTransbordo(IReadOnlyList tramos) + { + var codigos = new List(); + for (var indice = 1; indice < tramos.Count; indice++) + { + if (!UtilidadesTransitoDbus.EsTransbordoReal(tramos[indice - 1], tramos[indice])) + continue; + + var codigo = tramos[indice].CodigoParadaInicio; + if (!codigos.Contains(codigo, StringComparer.OrdinalIgnoreCase)) + codigos.Add(codigo); + } + + return codigos; + } + + /// + /// Decide si un patron entra en los filtros del usuario. + /// + private static bool PatronPermitido(PatronTransitoDbus patron, ParametrosPlanificadorRuta parametros) + { + if (parametros.ActivarRangoLineas) + { + if (patron.NumeroLinea is null) + return false; + + if (parametros.LineasPermitidas.Count == 0 || + !parametros.LineasPermitidas.Contains(patron.NumeroLinea.Value)) + { + return false; + } + } + + if (parametros.ActivarRangoItinerarios) + { + var minimoItinerario = Math.Min(parametros.ItinerarioMin, parametros.ItinerarioMax); + var maximoItinerario = Math.Max(parametros.ItinerarioMin, parametros.ItinerarioMax); + if (patron.CodigoItinerario < minimoItinerario || patron.CodigoItinerario > maximoItinerario) + return false; + } + + return true; + } + + /// + /// Intenta almacenar un estado si mejora al existente. + /// + private static bool IntentarActualizarEstado( + IDictionary estados, + EstadoBusquedaParadaDbus nuevoEstado) + { + var clave = CrearClaveEstado(nuevoEstado); + if (!estados.TryGetValue(clave, out var actual) || + EstadoEsMejor(nuevoEstado, actual)) + { + estados[clave] = nuevoEstado; + return true; + } + + return false; + } + + private static void PodarEstadosPorParada(IDictionary estados) + { + var conservar = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var grupo in estados.GroupBy(par => par.Value.CodigoParada, StringComparer.OrdinalIgnoreCase)) + { + var seleccionados = new List>(MaxEstadosPorParada); + var lista = grupo.ToList(); + + Agregar(lista + .OrderBy(par => par.Value.Llegada) + .ThenBy(par => par.Value.TiempoEsperaTotalSegundos) + .ThenBy(par => par.Value.DistanciaCaminadaTotal) + .First()); + + Agregar(lista + .OrderBy(par => par.Value.DistanciaCaminadaTotal) + .ThenBy(par => par.Value.Llegada) + .ThenBy(par => par.Value.TiempoEsperaTotalSegundos) + .First()); + + foreach (var representanteContexto in lista + .GroupBy(par => CrearContextoBusEstado(par.Value), StringComparer.OrdinalIgnoreCase) + .Select(contexto => contexto + .OrderBy(par => par.Value.Llegada) + .ThenBy(par => par.Value.TiempoEsperaTotalSegundos) + .ThenBy(par => par.Value.DistanciaCaminadaTotal) + .First()) + .OrderBy(par => par.Value.Llegada) + .ThenBy(par => par.Value.DistanciaCaminadaTotal)) + { + Agregar(representanteContexto); + if (seleccionados.Count >= MaxEstadosPorParada) + break; + } + + foreach (var candidato in lista + .OrderBy(par => par.Value.Llegada) + .ThenBy(par => par.Value.TiempoEsperaTotalSegundos) + .ThenBy(par => par.Value.DistanciaCaminadaTotal)) + { + Agregar(candidato); + if (seleccionados.Count >= MaxEstadosPorParada) + break; + } + + foreach (var seleccionado in seleccionados) + conservar.Add(seleccionado.Key); + + void Agregar(KeyValuePair candidato) + { + if (seleccionados.Count >= MaxEstadosPorParada) + return; + + if (seleccionados.Any(par => string.Equals(par.Key, candidato.Key, StringComparison.OrdinalIgnoreCase))) + return; + + seleccionados.Add(candidato); + } + } + + var eliminar = estados.Keys + .Where(clave => !conservar.Contains(clave)) + .ToList(); + + foreach (var clave in eliminar) + estados.Remove(clave); + } + + private static IEnumerable ObtenerEstadosEnParada( + IReadOnlyDictionary> estados, + string codigoParada) + { + return estados.TryGetValue(codigoParada, out var lista) + ? lista + : Enumerable.Empty(); + } + + private static Dictionary> IndexarEstadosPorParada( + IEnumerable estados) + { + var indice = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var estado in estados) + { + if (!indice.TryGetValue(estado.CodigoParada, out var lista)) + { + lista = new List(); + indice[estado.CodigoParada] = lista; + } + + lista.Add(estado); + } + + return indice; + } + + private static string CrearClaveEstado(EstadoBusquedaParadaDbus estado) + { + return $"{estado.CodigoParada}|b:{estado.BusesTomados}|ctx:{CrearContextoBusEstado(estado)}"; + } + + private static string CrearContextoBusEstado(EstadoBusquedaParadaDbus estado) + { + var movimientoBus = BuscarMovimientoBusAnterior(estado); + if (movimientoBus is null) + return "sin-bus"; + + var variante = movimientoBus.Patron.Variante; + var sentido = UtilidadesTransitoDbus.NormalizarSentidoCodigo(variante.Sentido?.Codigo); + return $"{UtilidadesTransitoDbus.LineaCodigoStr(variante)}.{variante.CodigoItinerario}-{sentido}|v:{variante.Variante.Codigo}"; + } + + /// + /// Compara dos estados de parada. + /// + private static bool EstadoEsMejor(EstadoBusquedaParadaDbus candidato, EstadoBusquedaParadaDbus actual) + { + var comparacionLlegada = candidato.Llegada.CompareTo(actual.Llegada); + if (comparacionLlegada != 0) + return comparacionLlegada < 0; + + var comparacionEspera = candidato.TiempoEsperaTotalSegundos.CompareTo(actual.TiempoEsperaTotalSegundos); + if (comparacionEspera != 0) + return comparacionEspera < 0; + + var comparacionTransbordos = candidato.TransbordosReales.CompareTo(actual.TransbordosReales); + if (comparacionTransbordos != 0) + return comparacionTransbordos < 0; + + return candidato.DistanciaCaminadaTotal < actual.DistanciaCaminadaTotal; + } + + /// + /// Compara dos resultados ya construidos. + /// + private static bool ResultadoEsMejor(ResultadoBusquedaRutaBusDbus candidato, ResultadoBusquedaRutaBusDbus actual) + { + var comparacionCoste = candidato.PuntuacionCoste.CompareTo(actual.PuntuacionCoste); + if (comparacionCoste != 0) + return comparacionCoste < 0; + + var comparacionDuracion = candidato.DuracionEstimadaSegundos.CompareTo(actual.DuracionEstimadaSegundos); + if (comparacionDuracion != 0) + return comparacionDuracion < 0; + + var comparacionTrans = candidato.RutaBus.NumeroTransbordos.CompareTo(actual.RutaBus.NumeroTransbordos); + if (comparacionTrans != 0) + return comparacionTrans < 0; + + return candidato.DistanciaPieTotal < actual.DistanciaPieTotal; + } + + /// + /// Obtiene una fecha a partir de segundos desde el inicio del dia. + /// + private static DateTime ObtenerFechaDesdeSegundos(DateTime fechaBase, int segundos) + => fechaBase.Date.AddSeconds(segundos); +} + +/// +/// Estado de una parada en la busqueda por rondas. +/// +public sealed class EstadoBusquedaParadaDbus +{ + /// + /// Codigo de la parada. + /// + public string CodigoParada { get; init; } = ""; + + /// + /// Momento de llegada a la parada. + /// + public DateTime Llegada { get; init; } + + /// + /// Distancia total caminada hasta este punto. + /// + public double DistanciaCaminadaTotal { get; init; } + + /// + /// Distancia caminada solo en transbordos. + /// + public double DistanciaPieTransbordos { get; init; } + + /// + /// Espera inicial acumulada. + /// + public double TiempoEsperaInicialSegundos { get; init; } + + /// + /// Espera acumulada en transbordos. + /// + public double TiempoEsperaTransbordosSegundos { get; init; } + + /// + /// Numero de buses tomados hasta llegar aqui. + /// + public int BusesTomados { get; init; } + + /// + /// Numero de transbordos reales acumulados hasta llegar aqui. + /// + public int TransbordosReales { get; init; } + + /// + /// Estado previo del que cuelga la reconstruccion. + /// + public EstadoBusquedaParadaDbus? EstadoAnterior { get; init; } + + /// + /// Movimiento aplicado para llegar a este estado. + /// + public MovimientoBusquedaDbus? Movimiento { get; init; } + + /// + /// Espera total acumulada. + /// + public double TiempoEsperaTotalSegundos => TiempoEsperaInicialSegundos + TiempoEsperaTransbordosSegundos; +} + +/// +/// Movimiento abstracto dentro de la reconstruccion. +/// +public abstract class MovimientoBusquedaDbus +{ +} + +/// +/// Movimiento inicial a pie desde el origen. +/// +public sealed class MovimientoAccesoDbus : MovimientoBusquedaDbus +{ + /// + /// Distancia del acceso. + /// + public double DistanciaMetros { get; init; } + + /// + /// Duracion del acceso. + /// + public double DuracionSegundos { get; init; } +} + +/// +/// Movimiento a pie entre dos paradas. +/// +public sealed class MovimientoPieTransbordoDbus : MovimientoBusquedaDbus +{ + /// + /// Distancia del transbordo. + /// + public double DistanciaMetros { get; init; } + + /// + /// Duracion del transbordo. + /// + public double DuracionSegundos { get; init; } +} + +/// +/// Movimiento en bus sobre un patron concreto. +/// +public sealed class MovimientoBusDbus : MovimientoBusquedaDbus +{ + /// + /// Patron recorrido. + /// + public PatronTransitoDbus Patron { get; init; } = default!; + + /// + /// Indice de subida. + /// + public int IndiceSubida { get; init; } + + /// + /// Indice de bajada. + /// + public int IndiceBajada { get; init; } + + /// + /// Hora de salida del tramo. + /// + public DateTime Salida { get; init; } + + /// + /// Hora de llegada del tramo. + /// + public DateTime Llegada { get; init; } +} + +/// +/// Resultado intermedio del buscador temporal. +/// +public sealed class ResultadoBusquedaRutaBusDbus +{ + /// + /// Parada de acceso inicial. + /// + public Parada ParadaOrigen { get; init; } = default!; + + /// + /// Parada de salida final del bus. + /// + public Parada ParadaDestino { get; init; } = default!; + + /// + /// Distancia a pie inicial. + /// + public double DistanciaPieInicio { get; init; } + + /// + /// Tiempo a pie inicial. + /// + public double TiempoPieInicio { get; init; } + + /// + /// Distancia a pie final. + /// + public double DistanciaPieFin { get; init; } + + /// + /// Tiempo a pie final. + /// + public double TiempoPieFin { get; init; } + + /// + /// Distancia peatonal en transbordos. + /// + public double DistanciaPieTransbordos { get; init; } + + /// + /// Espera inicial acumulada. + /// + public double TiempoEsperaInicialSegundos { get; init; } + + /// + /// Espera acumulada en transbordos. + /// + public double TiempoEsperaTransbordosSegundos { get; init; } + + /// + /// Duracion estimada total desde el origen real. + /// + public double DuracionEstimadaSegundos { get; init; } + + /// + /// Coste interno con penalizacion progresiva de caminata. + /// + public double PuntuacionCoste { get; init; } + + /// + /// Ruta de bus reconstruida. + /// + public RutaBusAlternativa RutaBus { get; init; } = default!; + + /// + /// Distancia peatonal total. + /// + public double DistanciaPieTotal => DistanciaPieInicio + DistanciaPieTransbordos + DistanciaPieFin; +} diff --git a/RutasDBUS/Servicios/Planificacion/CreadorIndiceTransitoDbus.cs b/RutasDBUS/Servicios/Planificacion/CreadorIndiceTransitoDbus.cs new file mode 100644 index 0000000..9045ab5 --- /dev/null +++ b/RutasDBUS/Servicios/Planificacion/CreadorIndiceTransitoDbus.cs @@ -0,0 +1,407 @@ +using RutasDBUS.Modelos.Importacion; +using RutasDBUS.Modelos.RedBus; +using RutasDBUS.Servicios.RedBus; + +namespace RutasDBUS.Servicios.Planificacion; + +/// +/// Construye el indice temporal compartido a partir del ZIP y de la red visual. +/// +public sealed class CreadorIndiceTransitoDbus +{ + private const int EscalaGrid = 400; + + /// + /// Construye el indice temporal de la red. + /// + public IndiceTransitoDbus Crear( + IRedBusServicio redBusServicio, + ArchivoImportacionDbus archivo, + int distanciaMaximaTransferenciaMetros = 250) + { + var paradasPorCodigo = redBusServicio.Paradas + .GroupBy(parada => parada.Codigo, StringComparer.OrdinalIgnoreCase) + .ToDictionary(grupo => grupo.Key, grupo => grupo.First(), StringComparer.OrdinalIgnoreCase); + + var codigosPorIdParada = ConstruirCodigosPorIdParada(archivo, paradasPorCodigo); + var variantes = ConstruirVariantes(redBusServicio); + var variantesPorFirma = variantes.ToDictionary( + variante => UtilidadesTransitoDbus.FirmaVariante( + UtilidadesTransitoDbus.LineaCodigoStr(variante), + variante.CodigoItinerario, + UtilidadesTransitoDbus.NormalizarSentidoCodigo(variante.Sentido?.Codigo), + variante.CodigosParadas), + variante => variante, + StringComparer.OrdinalIgnoreCase); + + var variantesPorFallback = variantes + .GroupBy(CrearClaveFallbackVariante, StringComparer.OrdinalIgnoreCase) + .ToDictionary(grupo => grupo.Key, grupo => grupo.First(), StringComparer.OrdinalIgnoreCase); + + var codigoLineaPorRuta = archivo.Rutas.ToDictionary( + fila => fila.Id, + fila => !string.IsNullOrWhiteSpace(fila.Abreviatura) + ? UtilidadesTransitoDbus.CodigoSinDecimal(fila.Abreviatura) + : UtilidadesTransitoDbus.CodigoSinDecimal(fila.Id), + StringComparer.OrdinalIgnoreCase); + + var patrones = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var expedicion in archivo.Expediciones) + { + if (!archivo.TiemposParadaPorExpedicion.TryGetValue(expedicion.Id, out var tiemposParada)) + continue; + + var tiemposOrdenados = tiemposParada.OrderBy(fila => fila.SecPar).ToList(); + var codigosParada = tiemposOrdenados + .Select(fila => codigosPorIdParada.TryGetValue(fila.IdTiempoPar, out var codigo) ? codigo : "") + .Where(codigo => !string.IsNullOrWhiteSpace(codigo)) + .ToList(); + + if (codigosParada.Count < 2) + continue; + + var codigoLinea = !string.IsNullOrWhiteSpace(expedicion.IdRuta) && + codigoLineaPorRuta.TryGetValue(expedicion.IdRuta, out var codigo) + ? codigo + : UtilidadesTransitoDbus.CodigoSinDecimal(expedicion.IdRuta); + var sentido = UtilidadesTransitoDbus.DireccionZipASentido(expedicion.Direccion); + var codigoItinerario = UtilidadesTransitoDbus.ExtraerCodigoItinerario(expedicion.IdRuta, expedicion.IdPatronExp); + var firmaVariante = UtilidadesTransitoDbus.FirmaVariante(codigoLinea, codigoItinerario, sentido, codigosParada); + + if (!variantesPorFirma.TryGetValue(firmaVariante, out var variante) && + !variantesPorFallback.TryGetValue(CrearClaveFallback(codigoLinea, codigoItinerario, sentido, codigosParada), out variante)) + { + continue; + } + + var clavePatron = $"{expedicion.IdRuta}|{expedicion.IdPatronExp}|{sentido}|{codigoItinerario}|{string.Join(">", codigosParada)}"; + if (!patrones.TryGetValue(clavePatron, out var patron)) + { + patron = new PatronTransitoDbus + { + Id = clavePatron, + IdPatronExp = expedicion.IdPatronExp ?? "", + IdRuta = expedicion.IdRuta ?? "", + CodigoLinea = codigoLinea, + NumeroLinea = UtilidadesTransitoDbus.ExtraerNumeroLinea(expedicion.IdRuta) + ?? UtilidadesTransitoDbus.ExtraerNumeroLinea(codigoLinea), + CodigoItinerario = codigoItinerario, + Sentido = sentido, + Destino = UtilidadesTransitoDbus.ResolverDestinoDescriptivo(expedicion.DirDestino, codigosParada, paradasPorCodigo), + Variante = variante, + CodigosParada = codigosParada, + DistanciaAcumuladaPorParadaMetros = ConstruirDistanciaAcumulada(variante, redBusServicio, paradasPorCodigo) + }; + + patrones[clavePatron] = patron; + } + + var expedicionProgramada = ConstruirExpedicion(expedicion, tiemposOrdenados, patron, codigosPorIdParada); + if (expedicionProgramada is not null) + patron.Expediciones.Add(expedicionProgramada); + } + + foreach (var patron in patrones.Values) + { + patron.Expediciones.Sort((izquierda, derecha) => + { + var salidaIzquierda = izquierda.SalidasSegundos.Count > 0 ? izquierda.SalidasSegundos[0] : int.MaxValue; + var salidaDerecha = derecha.SalidasSegundos.Count > 0 ? derecha.SalidasSegundos[0] : int.MaxValue; + return salidaIzquierda.CompareTo(salidaDerecha); + }); + } + + var patronesPorParada = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var patron in patrones.Values) + { + for (var indice = 0; indice < patron.CodigosParada.Count; indice++) + { + var codigo = patron.CodigosParada[indice]; + if (!patronesPorParada.TryGetValue(codigo, out var lista)) + { + lista = new List(4); + patronesPorParada[codigo] = lista; + } + + lista.Add(new ReferenciaPatronParadaDbus + { + Patron = patron, + IndiceParada = indice + }); + } + } + + var gridParadas = ConstruirGridParadas(redBusServicio.Paradas); + var transferenciasPorParada = ConstruirTransferencias(redBusServicio.Paradas, gridParadas, distanciaMaximaTransferenciaMetros); + + return new IndiceTransitoDbus( + paradasPorCodigo, + patrones.Values.ToList(), + patronesPorParada, + transferenciasPorParada, + gridParadas, + EscalaGrid); + } + + /// + /// Construye el mapa de stop id interno a codigo de parada. + /// + private static Dictionary ConstruirCodigosPorIdParada( + ArchivoImportacionDbus archivo, + IReadOnlyDictionary paradasPorCodigo) + { + var resultado = new Dictionary(); + + foreach (var fila in archivo.Paradas) + { + var codigo = !string.IsNullOrWhiteSpace(fila.CodigoSms) + ? UtilidadesTransitoDbus.NormalizarCodigoParada(fila.CodigoSms) + : UtilidadesTransitoDbus.NormalizarCodigoParada(fila.Id.ToString()); + + if (paradasPorCodigo.ContainsKey(codigo)) + resultado[fila.Id] = codigo; + } + + return resultado; + } + + /// + /// Construye las variantes enriquecidas de la red visual. + /// + private static List ConstruirVariantes(IRedBusServicio redBusServicio) + { + var variantes = new List(2048); + + foreach (var linea in redBusServicio.Lineas) + { + foreach (var itinerario in linea.Itinerarios) + { + foreach (var sentido in itinerario.Sentidos) + { + foreach (var variante in sentido.Variantes) + { + if (variante.IdsTrayectos.Count == 0) + continue; + + if (!redBusServicio.Trayectos.TryGetValue(variante.IdsTrayectos[0], out var trayectoInicial)) + continue; + + var info = new InfoVariante + { + Linea = linea, + Sentido = sentido, + Variante = variante, + CodigoItinerario = itinerario.Codigo + }; + + info.CodigosParadas.Add(trayectoInicial.CodigoParadaOrigen); + foreach (var idTrayecto in variante.IdsTrayectos) + { + if (redBusServicio.Trayectos.TryGetValue(idTrayecto, out var trayecto)) + info.CodigosParadas.Add(trayecto.CodigoParadaDestino); + } + + variantes.Add(info); + } + } + } + } + + return variantes; + } + + /// + /// Construye una expedicion programada alineada con el patron. + /// + private static ExpedicionTransitoDbus? ConstruirExpedicion( + FilaExpedicionCsv expedicion, + IReadOnlyList tiemposOrdenados, + PatronTransitoDbus patron, + IReadOnlyDictionary codigosPorIdParada) + { + if (tiemposOrdenados.Count != patron.CodigosParada.Count) + return null; + + var llegadas = new int[tiemposOrdenados.Count]; + var salidas = new int[tiemposOrdenados.Count]; + var ultimoSegundo = -1; + + for (var indice = 0; indice < tiemposOrdenados.Count; indice++) + { + var tiempo = tiemposOrdenados[indice]; + if (!codigosPorIdParada.TryGetValue(tiempo.IdTiempoPar, out var codigoParada) || + !codigoParada.Equals(patron.CodigosParada[indice], StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var llegadaBase = (tiempo.HoraLlegada ?? tiempo.HoraSalida ?? TimeOnly.MinValue).ToTimeSpan(); + var salidaBase = (tiempo.HoraSalida ?? tiempo.HoraLlegada ?? TimeOnly.MinValue).ToTimeSpan(); + + var llegadaSegundos = (int)llegadaBase.TotalSeconds; + var salidaSegundos = (int)salidaBase.TotalSeconds; + + while (llegadaSegundos < ultimoSegundo) + llegadaSegundos += 24 * 3600; + + while (salidaSegundos < llegadaSegundos) + salidaSegundos += 24 * 3600; + + llegadas[indice] = llegadaSegundos; + salidas[indice] = salidaSegundos; + ultimoSegundo = salidaSegundos; + } + + return new ExpedicionTransitoDbus + { + Id = expedicion.Id ?? "", + IdCalendario = expedicion.IdCal ?? "", + LlegadasSegundos = llegadas, + SalidasSegundos = salidas + }; + } + + /// + /// Calcula la distancia acumulada por parada de una variante. + /// + private static IReadOnlyList ConstruirDistanciaAcumulada( + InfoVariante variante, + IRedBusServicio redBusServicio, + IReadOnlyDictionary paradasPorCodigo) + { + var acumulada = new double[variante.CodigosParadas.Count]; + + for (var indice = 0; indice < variante.CodigosParadas.Count - 1; indice++) + { + var distancia = 0.0; + + if (indice < variante.Variante.IdsTrayectos.Count && + redBusServicio.Trayectos.TryGetValue(variante.Variante.IdsTrayectos[indice], out var trayecto)) + { + for (var p = 1; p < trayecto.Posiciones.Count; p++) + { + distancia += UtilidadesTransitoDbus.CalcularDistanciaMetros( + trayecto.Posiciones[p - 1][0], + trayecto.Posiciones[p - 1][1], + trayecto.Posiciones[p][0], + trayecto.Posiciones[p][1]); + } + } + else if (paradasPorCodigo.TryGetValue(variante.CodigosParadas[indice], out var paradaOrigen) && + paradasPorCodigo.TryGetValue(variante.CodigosParadas[indice + 1], out var paradaDestino)) + { + distancia = UtilidadesTransitoDbus.CalcularDistanciaMetros( + paradaOrigen.Latitud, + paradaOrigen.Longitud, + paradaDestino.Latitud, + paradaDestino.Longitud); + } + + acumulada[indice + 1] = acumulada[indice] + distancia; + } + + return acumulada; + } + + /// + /// Construye el grid espacial de paradas. + /// + private static Dictionary<(int cx, int cy), List> ConstruirGridParadas(IReadOnlyList paradas) + { + var resultado = new Dictionary<(int cx, int cy), List>(); + + foreach (var parada in paradas) + { + var celda = Celda(parada.Latitud, parada.Longitud, EscalaGrid); + if (!resultado.TryGetValue(celda, out var lista)) + { + lista = new List(16); + resultado[celda] = lista; + } + + lista.Add(parada); + } + + return resultado; + } + + /// + /// Construye las transferencias a pie entre paradas cercanas. + /// + private static Dictionary> ConstruirTransferencias( + IReadOnlyList paradas, + IReadOnlyDictionary<(int cx, int cy), List> gridParadas, + int distanciaMaximaTransferenciaMetros) + { + var transferencias = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var deg = distanciaMaximaTransferenciaMetros / 111_000.0; + var paso = Math.Max(1, (int)Math.Ceiling(deg * EscalaGrid)); + + foreach (var parada in paradas) + { + var celdaOrigen = Celda(parada.Latitud, parada.Longitud, EscalaGrid); + for (var dx = -paso; dx <= paso; dx++) + { + for (var dy = -paso; dy <= paso; dy++) + { + if (!gridParadas.TryGetValue((celdaOrigen.cx + dx, celdaOrigen.cy + dy), out var vecinas)) + continue; + + foreach (var vecina in vecinas) + { + if (ReferenceEquals(parada, vecina)) + continue; + + var distancia = UtilidadesTransitoDbus.CalcularDistanciaMetros( + parada.Latitud, + parada.Longitud, + vecina.Latitud, + vecina.Longitud); + + if (distancia > distanciaMaximaTransferenciaMetros) + continue; + + if (!transferencias.TryGetValue(parada.Codigo, out var lista)) + { + lista = new List(8); + transferencias[parada.Codigo] = lista; + } + + if (lista.Any(x => x.CodigoParadaDestino.Equals(vecina.Codigo, StringComparison.OrdinalIgnoreCase))) + continue; + + lista.Add(new TransferenciaPeatonalDbus + { + CodigoParadaDestino = vecina.Codigo, + DistanciaMetros = distancia + }); + } + } + } + } + + return transferencias; + } + + /// + /// Crea la clave de respaldo para una variante. + /// + private static string CrearClaveFallbackVariante(InfoVariante variante) + => CrearClaveFallback( + UtilidadesTransitoDbus.LineaCodigoStr(variante), + variante.CodigoItinerario, + UtilidadesTransitoDbus.NormalizarSentidoCodigo(variante.Sentido?.Codigo), + variante.CodigosParadas); + + /// + /// Crea una clave de respaldo por extremos y longitud. + /// + private static string CrearClaveFallback(string codigoLinea, int codigoItinerario, string sentido, IReadOnlyList codigosParada) + => codigosParada.Count == 0 + ? $"{codigoLinea}|{codigoItinerario}|{sentido}|0" + : $"{codigoLinea}|{codigoItinerario}|{sentido}|{codigosParada[0]}|{codigosParada[^1]}|{codigosParada.Count}"; + + private static (int cx, int cy) Celda(double latitud, double longitud, int escala) + => ((int)Math.Floor(latitud * escala), (int)Math.Floor(longitud * escala)); +} diff --git a/RutasDBUS/Servicios/Planificacion/IPlanificadorRutas.cs b/RutasDBUS/Servicios/Planificacion/IPlanificadorRutas.cs new file mode 100644 index 0000000..4a1c7ca --- /dev/null +++ b/RutasDBUS/Servicios/Planificacion/IPlanificadorRutas.cs @@ -0,0 +1,14 @@ +using RutasDBUS.Modelos.Planificacion; + +namespace RutasDBUS.Servicios.Planificacion; + +public interface IPlanificadorRutas +{ + void InvalidarCachesModelo(); + + Task CalcularAsync( + double[] origenLatLon, + double[] destinoLatLon, + ParametrosPlanificadorRuta parametros, + CancellationToken ct = default); +} diff --git a/RutasDBUS/Servicios/Planificacion/IndiceTransitoDbus.cs b/RutasDBUS/Servicios/Planificacion/IndiceTransitoDbus.cs new file mode 100644 index 0000000..ed27c7b --- /dev/null +++ b/RutasDBUS/Servicios/Planificacion/IndiceTransitoDbus.cs @@ -0,0 +1,200 @@ +using RutasDBUS.Modelos.RedBus; + +namespace RutasDBUS.Servicios.Planificacion; + +/// +/// Indice compartido de paradas, patrones y transferencias del motor de transito. +/// +public sealed class IndiceTransitoDbus +{ + private readonly Dictionary<(int cx, int cy), List> _gridParadas; + private readonly int _escalaGrid; + + /// + /// Inicializa una nueva instancia del indice. + /// + public IndiceTransitoDbus( + IReadOnlyDictionary paradasPorCodigo, + IReadOnlyList patrones, + IReadOnlyDictionary> patronesPorParada, + IReadOnlyDictionary> transferenciasPorParada, + Dictionary<(int cx, int cy), List> gridParadas, + int escalaGrid) + { + ParadasPorCodigo = paradasPorCodigo; + Patrones = patrones; + PatronesPorParada = patronesPorParada; + TransferenciasPorParada = transferenciasPorParada; + _gridParadas = gridParadas; + _escalaGrid = escalaGrid; + } + + /// + /// Paradas cargadas por codigo. + /// + public IReadOnlyDictionary ParadasPorCodigo { get; } + + /// + /// Patrones transitables construidos desde expediciones reales. + /// + public IReadOnlyList Patrones { get; } + + /// + /// Patrones que pasan por cada parada. + /// + public IReadOnlyDictionary> PatronesPorParada { get; } + + /// + /// Transferencias a pie entre paradas cercanas. + /// + public IReadOnlyDictionary> TransferenciasPorParada { get; } + + /// + /// Devuelve las paradas cercanas a unas coordenadas. + /// + public List QueryParadasEnRadio(double latitud, double longitud, int radioMetros) + { + var deg = Math.Max(0.0005, radioMetros / 111_000.0); + var paso = Math.Max(1, (int)Math.Ceiling(deg * _escalaGrid)); + var celda = Celda(latitud, longitud, _escalaGrid); + var resultado = new List(128); + + for (var dx = -paso; dx <= paso; dx++) + { + for (var dy = -paso; dy <= paso; dy++) + { + if (_gridParadas.TryGetValue((celda.cx + dx, celda.cy + dy), out var paradas)) + resultado.AddRange(paradas); + } + } + + return resultado; + } + + private static (int cx, int cy) Celda(double latitud, double longitud, int escala) + => ((int)Math.Floor(latitud * escala), (int)Math.Floor(longitud * escala)); +} + +/// +/// Patron de viaje con su secuencia de paradas y sus expediciones. +/// +public sealed class PatronTransitoDbus +{ + /// + /// Identificador estable del patron. + /// + public string Id { get; init; } = ""; + + /// + /// Identificador del patron de expedicion del ZIP. + /// + public string IdPatronExp { get; init; } = ""; + + /// + /// Identificador bruto de la ruta del ZIP. + /// + public string IdRuta { get; init; } = ""; + + /// + /// Codigo visible de la linea. + /// + public string CodigoLinea { get; init; } = ""; + + /// + /// Codigo numerico de la linea si existe. + /// + public int? NumeroLinea { get; init; } + + /// + /// Codigo de itinerario. + /// + public int CodigoItinerario { get; init; } + + /// + /// Sentido normalizado. + /// + public string Sentido { get; init; } = ""; + + /// + /// Destino descriptivo. + /// + public string Destino { get; init; } = ""; + + /// + /// Variante de la red visual asociada al patron. + /// + public InfoVariante Variante { get; init; } = default!; + + /// + /// Secuencia ordenada de codigos de parada. + /// + public IReadOnlyList CodigosParada { get; init; } = Array.Empty(); + + /// + /// Distancia acumulada por parada para calculos aproximados. + /// + public IReadOnlyList DistanciaAcumuladaPorParadaMetros { get; init; } = Array.Empty(); + + /// + /// Expediciones que circulan por este patron. + /// + public List Expediciones { get; } = new(); +} + +/// +/// Expedicion concreta de un patron. +/// +public sealed class ExpedicionTransitoDbus +{ + /// + /// Identificador de la expedicion. + /// + public string Id { get; init; } = ""; + + /// + /// Calendario asociado. + /// + public string IdCalendario { get; init; } = ""; + + /// + /// Tiempos de llegada absolutos respecto al inicio del dia de servicio. + /// + public IReadOnlyList LlegadasSegundos { get; init; } = Array.Empty(); + + /// + /// Tiempos de salida absolutos respecto al inicio del dia de servicio. + /// + public IReadOnlyList SalidasSegundos { get; init; } = Array.Empty(); +} + +/// +/// Referencia de una parada dentro de un patron. +/// +public sealed class ReferenciaPatronParadaDbus +{ + /// + /// Patron al que pertenece la parada. + /// + public PatronTransitoDbus Patron { get; init; } = default!; + + /// + /// Indice de la parada dentro del patron. + /// + public int IndiceParada { get; init; } +} + +/// +/// Conexion peatonal entre dos paradas. +/// +public sealed class TransferenciaPeatonalDbus +{ + /// + /// Codigo de la parada destino. + /// + public string CodigoParadaDestino { get; init; } = ""; + + /// + /// Distancia aproximada de la transferencia. + /// + public double DistanciaMetros { get; init; } +} diff --git a/RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs b/RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs new file mode 100644 index 0000000..3100fa6 --- /dev/null +++ b/RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs @@ -0,0 +1,3422 @@ +using System.Collections.Concurrent; +using System.Globalization; +using Microsoft.AspNetCore.Hosting; +using RutasDBUS.Modelos.Planificacion; +using RutasDBUS.Modelos.RedBus; +using RutasDBUS.Servicios.Caminata; +using RutasDBUS.Servicios.Horarios; +using RutasDBUS.Servicios.RedBus; + +namespace RutasDBUS.Servicios.Planificacion; + +/// +/// Orquesta la seleccion de candidatas, el motor temporal y la evaluacion final. +/// +public sealed class PlanificadorRutasServicio : IPlanificadorRutas +{ + private readonly IRedBusServicio _redBusServicio; + private readonly IClienteOsrm _clienteOsrm; + private readonly IClienteValhalla _clienteValhalla; + private readonly ServicioHorariosDbus _servicioHorarios; + private readonly IWebHostEnvironment _entorno; + private readonly ServicioIndiceTransitoDbus _servicioIndiceTransito; + private readonly BuscadorRutasTemporalesDbus _buscadorRutasTemporales; + + private static readonly ConcurrentDictionary _cacheCaminatas = + new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary _cacheDuracionBusRefinamiento = + new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary _cacheSegmentosHorarios = + new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary lineas, double distancia)> _cacheLineasBusPlanificacion = + new(StringComparer.OrdinalIgnoreCase); + private const double UmbralCosturaVisualMetros = 12.0; + private const double UmbralUnionPeatonalAutomaticaMetros = 250.0; + private const double ToleranciaEmpateRapidezSegundos = 5.0; + private const double MejoraMinimaAccesoExtensionCircularMetros = 20.0; + private const double MaxExcesoAccesoDirectoCircularMetros = 120.0; + private const double ToleranciaMismoBusCircularSegundos = 5.0; + private const int MaxContinuidadesCircularesConsecutivas = 2; + private const int VentanaRefinamientoParadaInicio = 4; + private const int VentanaRefinamientoParadaFin = 4; + private const int MaxConcurrenciaRefinamientoParadas = 4; + private const int MaxOrigenesTransbordoRobusto = 20; + private const int MaxBajadasPrimeraPiernaTransbordoRobusto = 256; + private const double UmbralActivacionTransbordoRobustoMetros = 400.0; + private const double UmbralAccesoDestinoTransbordoRobustoMetros = 350.0; + private const double ToleranciaDominanciaFinalSegundos = 45.0; + private const double ToleranciaDominanciaFinalMetros = 40.0; + private const double MejoraClaraDominanciaFinalSegundos = 90.0; + private const double MejoraClaraDominanciaFinalMetros = 120.0; + private const double AhorroClaroAndandoFinalMetros = 250.0; + private const double FactorAhorroClaroAndandoFinal = 0.75; + private const double UmbralAccesoCercanoRefinamientoInicioMetros = 120.0; + private const double MaxIncrementoAccesoCercanoRefinamientoInicioMetros = 60.0; + + /// + /// Inicializa una nueva instancia del planificador. + /// + public PlanificadorRutasServicio( + IRedBusServicio redBusServicio, + IClienteOsrm clienteOsrm, + IClienteValhalla clienteValhalla, + ServicioHorariosDbus servicioHorarios, + IWebHostEnvironment entorno, + ServicioIndiceTransitoDbus servicioIndiceTransito, + BuscadorRutasTemporalesDbus buscadorRutasTemporales) + { + _redBusServicio = redBusServicio; + _clienteOsrm = clienteOsrm; + _clienteValhalla = clienteValhalla; + _servicioHorarios = servicioHorarios; + _entorno = entorno; + _servicioIndiceTransito = servicioIndiceTransito; + _buscadorRutasTemporales = buscadorRutasTemporales; + } + + public void InvalidarCachesModelo() + { + _cacheDuracionBusRefinamiento.Clear(); + _cacheSegmentosHorarios.Clear(); + _cacheLineasBusPlanificacion.Clear(); + } + + /// + /// Calcula alternativas de ruta entre origen y destino. + /// + public async Task CalcularAsync( + double[] origenLatLon, + double[] destinoLatLon, + ParametrosPlanificadorRuta parametros, + CancellationToken ct = default) + { + await _redBusServicio.CargarRedAsync(ct); + if (_redBusServicio.Paradas.Count == 0) + { + return new ResultadoPlanificadorRuta + { + Ok = false, + Mensaje = "No hay red de paradas cargada.", + Alternativas = new(), + IndicePreferido = 0 + }; + } + + var indiceTransito = await _servicioIndiceTransito.ObtenerAsync(ct); + if (parametros.UsarHorariosReales) + { + try + { + await _servicioHorarios.AsegurarCargaAsync(_entorno.ContentRootPath, ct); + } + catch + { + parametros.UsarHorariosReales = false; + } + } + + var candidatasOrigen = await SeleccionarCandidatasAsync(indiceTransito, origenLatLon[0], origenLatLon[1], parametros, ct); + var candidatasDestino = await SeleccionarCandidatasAsync(indiceTransito, destinoLatLon[0], destinoLatLon[1], parametros, ct); + + if (candidatasOrigen.Count == 0 || candidatasDestino.Count == 0) + { + return new ResultadoPlanificadorRuta + { + Ok = false, + Mensaje = "No hay paradas validas cerca del origen o del destino con los filtros actuales.", + Alternativas = new(), + IndicePreferido = 0 + }; + } + + var alternativasSoloPie = await CrearAlternativasSoloPieAsync(origenLatLon, destinoLatLon, parametros); + var instanteSalida = UtilidadesTransitoDbus.ResolverInstanteSalida(parametros); + var calendariosActivos = parametros.UsarHorariosReales + ? _servicioHorarios.GetActiveCalendars(DateOnly.FromDateTime(instanteSalida)) + : null; + + var usarBusquedaDirigida = parametros.UsarHorariosReales && DebeUsarBusquedaDirigidaUnaTransbordo(parametros); + var candidatasBus = usarBusquedaDirigida + ? new List() + : _buscadorRutasTemporales.BuscarAlternativas( + indiceTransito, + candidatasOrigen, + candidatasDestino, + instanteSalida, + parametros, + calendariosActivos, + ct).ToList(); + + if (parametros.UsarHorariosReales) + { + var directasRobustas = BuscarDirectasRobustasConHorario( + indiceTransito, + candidatasOrigen, + candidatasDestino, + instanteSalida, + parametros); + var continuidadesCirculares = BuscarContinuidadesCircularesConHorario( + indiceTransito, + candidatasOrigen, + candidatasDestino, + instanteSalida, + parametros); + var transbordosRobustos = usarBusquedaDirigida || DebeBuscarTransbordosRobustos( + candidatasBus, + candidatasOrigen, + candidatasDestino) + ? BuscarTransbordosRobustosConHorario( + indiceTransito, + candidatasOrigen, + candidatasDestino, + instanteSalida, + parametros, + incluirPrimeraPiernaDirecta: usarBusquedaDirigida || candidatasBus.Count == 0) + : new List(); + + if (directasRobustas.Count > 0 || + continuidadesCirculares.Count > 0 || + transbordosRobustos.Count > 0) + { + var porFirma = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var candidata in candidatasBus) + { + var firma = UtilidadesTransitoDbus.FirmaRutaBus(candidata.RutaBus); + porFirma[firma] = candidata; + } + + foreach (var directa in directasRobustas) + { + var firma = UtilidadesTransitoDbus.FirmaRutaBus(directa.RutaBus); + if (!porFirma.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(directa, actual)) + porFirma[firma] = directa; + } + + foreach (var circular in continuidadesCirculares) + { + var firma = UtilidadesTransitoDbus.FirmaRutaBus(circular.RutaBus); + if (!porFirma.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(circular, actual)) + porFirma[firma] = circular; + } + + foreach (var transbordo in transbordosRobustos) + { + var firma = UtilidadesTransitoDbus.FirmaRutaBus(transbordo.RutaBus); + if (!porFirma.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(transbordo, actual)) + porFirma[firma] = transbordo; + } + + candidatasBus = SeleccionarPoolBusqueda( + porFirma.Values, + Math.Max(16, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 10)); + } + + var extensionesCircularesInicio = BuscarExtensionesCircularesInicialesConHorario( + indiceTransito, + candidatasOrigen, + candidatasBus, + instanteSalida, + parametros); + + if (extensionesCircularesInicio.Count > 0) + { + var porFirma = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var candidata in candidatasBus) + { + var firma = UtilidadesTransitoDbus.FirmaRutaBus(candidata.RutaBus); + porFirma[firma] = candidata; + } + + foreach (var extension in extensionesCircularesInicio) + { + var firma = UtilidadesTransitoDbus.FirmaRutaBus(extension.RutaBus); + if (!porFirma.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(extension, actual)) + porFirma[firma] = extension; + } + + candidatasBus = SeleccionarPoolBusqueda( + porFirma.Values, + Math.Max(16, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 10)); + } + } + + candidatasBus = candidatasBus + .Where(candidata => !UtilidadesTransitoDbus.TieneContinuidadesNoRealesConsecutivasExcesivas( + candidata.RutaBus.Tramos, + MaxContinuidadesCircularesConsecutivas)) + .ToList(); + + var alternativasBus = new List(); + foreach (var candidataBus in candidatasBus) + { + ct.ThrowIfCancellationRequested(); + + var rutaBus = ClonarRutaBus(candidataBus.RutaBus); + + var alternativa = new AlternativaRuta + { + EsSoloAPie = false, + OrigenParada = candidataBus.ParadaOrigen, + DestinoParada = candidataBus.ParadaDestino, + AlternativaBus = rutaBus, + DistanciaPieInicioAprox = candidataBus.DistanciaPieInicio, + DistanciaPieFinAprox = candidataBus.DistanciaPieFin, + DistanciaPieTransbordosAprox = candidataBus.DistanciaPieTransbordos, + TiempoPieInicio = candidataBus.TiempoPieInicio, + TiempoPieFin = candidataBus.TiempoPieFin, + TiempoEsperaInicialSegundos = candidataBus.TiempoEsperaInicialSegundos, + TiempoEsperaTransbordosSegundos = candidataBus.TiempoEsperaTransbordosSegundos, + NumeroTransbordosReales = UtilidadesTransitoDbus.ContarTransbordosReales(rutaBus.Tramos), + UsaHorariosReales = parametros.UsarHorariosReales, + HorarioCompletoDisponible = parametros.UsarHorariosReales, + DuracionTotalAproxSegundos = candidataBus.DuracionEstimadaSegundos, + PuntuacionCoste = candidataBus.PuntuacionCoste + }; + + var duracionExacta = await RecalcularDuracionExactaAsync(origenLatLon, destinoLatLon, alternativa, parametros, ct); + if (!double.IsFinite(duracionExacta)) + continue; + + alternativa.DuracionTotalAproxSegundos = duracionExacta; + alternativa.PuntuacionCoste = CalcularPuntuacionCoste(alternativa, parametros); + alternativasBus.Add(alternativa); + } + + alternativasBus = alternativasBus + .GroupBy(alternativa => UtilidadesTransitoDbus.FirmaRutaBus(alternativa.AlternativaBus!), StringComparer.OrdinalIgnoreCase) + .Select(grupo => grupo.OrderBy(x => x.PuntuacionCoste).First()) + .ToList(); + + alternativasBus = SeleccionarPoolFinal(DeduplicarAlternativasEquivalentes(alternativasBus)); + + var finales = SeleccionarAlternativasFinales(alternativasBus, alternativasSoloPie, parametros); + if (finales.Count == 0) + { + return new ResultadoPlanificadorRuta + { + Ok = false, + Mensaje = alternativasSoloPie.Count > 0 + ? "No hay salidas viables en bus para la hora indicada." + : "No he encontrado una combinacion valida de bus para ese origen y destino.", + Alternativas = alternativasSoloPie.OrderBy(x => x.PuntuacionCoste).ToList(), + IndicePreferido = 0 + }; + } + + var indicePreferido = 0; + if (!string.IsNullOrWhiteSpace(parametros.PreferenciaEtiquetaRuta)) + { + var encontrado = finales.FindIndex(x => + string.Equals(x.Etiqueta, parametros.PreferenciaEtiquetaRuta, StringComparison.OrdinalIgnoreCase)); + if (encontrado >= 0) + indicePreferido = encontrado; + } + + return new ResultadoPlanificadorRuta + { + Ok = true, + Mensaje = "Ok", + Alternativas = finales, + IndicePreferido = indicePreferido + }; + } + + /// + /// Inyecta candidatas directas (sin transbordos) usando horarios reales para evitar perder + /// servicios claramente mejores por podas tempranas del buscador principal. + /// + private List BuscarDirectasRobustasConHorario( + IndiceTransitoDbus indiceTransito, + IReadOnlyList candidatasOrigen, + IReadOnlyList candidatasDestino, + DateTime instanteSalida, + ParametrosPlanificadorRuta parametros) + { + if (candidatasOrigen.Count == 0 || candidatasDestino.Count == 0) + return new(); + + if (parametros.ActivarRangoTransbordos && + (parametros.MinTransbordosPermitidos > 0 || parametros.MaxTransbordosPermitidos < 0)) + { + return new(); + } + + var destinoPorCodigo = candidatasDestino + .GroupBy(x => x.Parada.Codigo, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderBy(x => x.TiempoAPie).First(), StringComparer.OrdinalIgnoreCase); + + var mejores = new Dictionary(StringComparer.OrdinalIgnoreCase); + var maxResultados = Math.Max(24, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 12); + + foreach (var origen in candidatasOrigen.OrderBy(x => x.TiempoAPie)) + { + if (!indiceTransito.PatronesPorParada.TryGetValue(origen.Parada.Codigo, out var referencias) || referencias.Count == 0) + continue; + + var llegadaParada = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, origen.TiempoAPie); + + foreach (var referencia in referencias) + { + var patron = referencia.Patron; + if (!PatronPermitido(patron, parametros)) + continue; + + var codigoLinea = UtilidadesTransitoDbus.LineaCodigoStr(patron.Variante); + var codigoItinerario = patron.CodigoItinerario; + var sentido = UtilidadesTransitoDbus.NormalizarSentidoCodigo(patron.Sentido); + var codigos = patron.CodigosParada; + + var indiceSubida = referencia.IndiceParada; + if (indiceSubida < 0 || indiceSubida >= codigos.Count - 1) + continue; + + for (var indiceBajada = indiceSubida + 1; indiceBajada < codigos.Count; indiceBajada++) + { + if (!destinoPorCodigo.TryGetValue(codigos[indiceBajada], out var destino)) + continue; + + if (!ResolverSegmentoHorario( + origen.Parada.Codigo, + destino.Parada.Codigo, + codigoLinea, + codigoItinerario, + sentido, + llegadaParada, + out var dep, + out var arr)) + { + continue; + } + + var esperaInicial = Math.Max(0, (dep - llegadaParada).TotalSeconds); + if (parametros.MaxEsperaInicialMinutos > 0 && + esperaInicial > parametros.MaxEsperaInicialMinutos * 60.0) + { + continue; + } + + if (parametros.MaxEsperaTotalMinutos > 0 && + esperaInicial > parametros.MaxEsperaTotalMinutos * 60.0) + { + continue; + } + + var duracionTotal = Math.Max(0, (arr - instanteSalida).TotalSeconds) + destino.TiempoAPie; + var ruta = new RutaBusAlternativa + { + Tramos = new List + { + new() + { + Variante = patron.Variante, + IndiceInicio = indiceSubida, + IndiceFin = indiceBajada + } + }, + NumeroTransbordos = 0, + CodigosParadasTransbordo = new List() + }; + + var candidata = new ResultadoBusquedaRutaBusDbus + { + ParadaOrigen = origen.Parada, + ParadaDestino = destino.Parada, + DistanciaPieInicio = origen.DistanciaAPie, + TiempoPieInicio = origen.TiempoAPie, + DistanciaPieFin = destino.DistanciaAPie, + TiempoPieFin = destino.TiempoAPie, + DistanciaPieTransbordos = 0, + TiempoEsperaInicialSegundos = esperaInicial, + TiempoEsperaTransbordosSegundos = 0, + DuracionEstimadaSegundos = duracionTotal, + PuntuacionCoste = UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos( + duracionTotal, + parametros, + origen.DistanciaAPie, + 0, + destino.DistanciaAPie), + RutaBus = ruta + }; + + var firma = UtilidadesTransitoDbus.FirmaRutaBus(ruta); + if (!mejores.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(candidata, actual)) + mejores[firma] = candidata; + + if (mejores.Count > maxResultados * 3) + { + mejores = SeleccionarPoolBusqueda(mejores.Values, maxResultados * 2) + .ToDictionary(x => UtilidadesTransitoDbus.FirmaRutaBus(x.RutaBus), StringComparer.OrdinalIgnoreCase); + } + } + } + } + + return SeleccionarPoolBusqueda(mejores.Values, maxResultados); + } + + /// + /// Inyecta continuidades circulares de la misma linea/itinerario que cambian de sentido sin transbordo real. + /// + private List BuscarContinuidadesCircularesConHorario( + IndiceTransitoDbus indiceTransito, + IReadOnlyList candidatasOrigen, + IReadOnlyList candidatasDestino, + DateTime instanteSalida, + ParametrosPlanificadorRuta parametros) + { + if (candidatasOrigen.Count == 0 || candidatasDestino.Count == 0) + return new(); + + if (parametros.ActivarRangoTransbordos && + (parametros.MinTransbordosPermitidos > 0 || parametros.MaxTransbordosPermitidos < 0)) + { + return new(); + } + + var destinoPorCodigo = candidatasDestino + .GroupBy(x => x.Parada.Codigo, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderBy(x => x.TiempoAPie).First(), StringComparer.OrdinalIgnoreCase); + + var mejores = new Dictionary(StringComparer.OrdinalIgnoreCase); + var maxResultados = Math.Max(24, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 12); + + foreach (var origen in candidatasOrigen.OrderBy(x => x.TiempoAPie)) + { + if (!indiceTransito.PatronesPorParada.TryGetValue(origen.Parada.Codigo, out var referenciasOrigen)) + continue; + + var llegadaParadaOrigen = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, origen.TiempoAPie); + + foreach (var referenciaOrigen in referenciasOrigen) + { + var primerPatron = referenciaOrigen.Patron; + if (!PatronPermitido(primerPatron, parametros)) + continue; + + var indiceSubida = referenciaOrigen.IndiceParada; + if (indiceSubida < 0 || indiceSubida >= primerPatron.CodigosParada.Count - 1) + continue; + + for (var indiceIntermedioPrimero = indiceSubida + 1; + indiceIntermedioPrimero < primerPatron.CodigosParada.Count; + indiceIntermedioPrimero++) + { + var codigoIntermedio = primerPatron.CodigosParada[indiceIntermedioPrimero]; + if (!indiceTransito.PatronesPorParada.TryGetValue(codigoIntermedio, out var referenciasIntermedio)) + continue; + + foreach (var referenciaIntermedio in referenciasIntermedio) + { + var segundoPatron = referenciaIntermedio.Patron; + if (!EsContinuidadCircular(primerPatron, segundoPatron) || + !PatronPermitido(segundoPatron, parametros)) + { + continue; + } + + var indiceIntermedioSegundo = referenciaIntermedio.IndiceParada; + if (indiceIntermedioSegundo < 0 || indiceIntermedioSegundo >= segundoPatron.CodigosParada.Count - 1) + continue; + + for (var indiceBajada = indiceIntermedioSegundo + 1; + indiceBajada < segundoPatron.CodigosParada.Count; + indiceBajada++) + { + if (!destinoPorCodigo.TryGetValue(segundoPatron.CodigosParada[indiceBajada], out var destino)) + continue; + + if (!ResolverSegmentoHorario( + primerPatron, + origen.Parada.Codigo, + codigoIntermedio, + llegadaParadaOrigen, + out var salidaPrimera, + out var llegadaIntermedia)) + { + continue; + } + + if (!ResolverSegmentoHorario( + segundoPatron, + codigoIntermedio, + destino.Parada.Codigo, + llegadaIntermedia, + out _, + out var llegadaDestinoBus)) + { + continue; + } + + var esperaInicial = Math.Max(0, (salidaPrimera - llegadaParadaOrigen).TotalSeconds); + if (parametros.MaxEsperaInicialMinutos > 0 && + esperaInicial > parametros.MaxEsperaInicialMinutos * 60.0) + { + continue; + } + + var duracionTotal = Math.Max(0, (llegadaDestinoBus - instanteSalida).TotalSeconds) + destino.TiempoAPie; + if (parametros.MaxEsperaTotalMinutos > 0 && + esperaInicial > parametros.MaxEsperaTotalMinutos * 60.0) + { + continue; + } + + var ruta = new RutaBusAlternativa + { + Tramos = new List + { + new() + { + Variante = primerPatron.Variante, + IndiceInicio = indiceSubida, + IndiceFin = indiceIntermedioPrimero + }, + new() + { + Variante = segundoPatron.Variante, + IndiceInicio = indiceIntermedioSegundo, + IndiceFin = indiceBajada + } + }, + NumeroTransbordos = 0, + CodigosParadasTransbordo = new List() + }; + + var candidata = new ResultadoBusquedaRutaBusDbus + { + ParadaOrigen = origen.Parada, + ParadaDestino = destino.Parada, + DistanciaPieInicio = origen.DistanciaAPie, + TiempoPieInicio = origen.TiempoAPie, + DistanciaPieFin = destino.DistanciaAPie, + TiempoPieFin = destino.TiempoAPie, + DistanciaPieTransbordos = 0, + TiempoEsperaInicialSegundos = esperaInicial, + TiempoEsperaTransbordosSegundos = 0, + DuracionEstimadaSegundos = duracionTotal, + PuntuacionCoste = UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos( + duracionTotal, + parametros, + origen.DistanciaAPie, + 0, + destino.DistanciaAPie), + RutaBus = ruta + }; + + var firma = UtilidadesTransitoDbus.FirmaRutaBus(ruta); + if (!mejores.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(candidata, actual)) + mejores[firma] = candidata; + } + } + } + } + } + + return SeleccionarPoolBusqueda(mejores.Values, maxResultados); + } + + /// + /// Refuerza combinaciones de un transbordo que pueden quedar tapadas por podas del buscador principal, + /// especialmente cuando una continuidad circular permite llegar al transbordo andando muy poco. + /// + private List BuscarTransbordosRobustosConHorario( + IndiceTransitoDbus indiceTransito, + IReadOnlyList candidatasOrigen, + IReadOnlyList candidatasDestino, + DateTime instanteSalida, + ParametrosPlanificadorRuta parametros, + bool incluirPrimeraPiernaDirecta) + { + if (candidatasOrigen.Count == 0 || candidatasDestino.Count == 0) + return new(); + + if (parametros.ActivarRangoTransbordos) + { + var minimo = Math.Min(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos); + var maximo = Math.Max(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos); + if (minimo > 1 || maximo < 1) + return new(); + } + + var destinoPorCodigo = candidatasDestino + .GroupBy(x => x.Parada.Codigo, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.OrderBy(x => x.TiempoAPie).First(), StringComparer.OrdinalIgnoreCase); + var segundasPiernasPorSubida = ConstruirSegundasPiernasPorSubida(); + if (segundasPiernasPorSubida.Count == 0) + return new(); + + var mejores = new Dictionary(StringComparer.OrdinalIgnoreCase); + var transferenciasUtilesPorBajada = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var maxResultados = Math.Max(32, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 8); + var origenes = candidatasOrigen + .OrderBy(x => x.TiempoAPie) + .ThenBy(x => x.DistanciaAPie) + .Take(Math.Min(candidatasOrigen.Count, MaxOrigenesTransbordoRobusto)) + .ToList(); + var margenTransbordo = Math.Clamp(parametros.MargenTransbordoSegundos, 0, 1800); + + foreach (var origen in origenes) + { + if (!indiceTransito.PatronesPorParada.TryGetValue(origen.Parada.Codigo, out var referenciasOrigen)) + continue; + + var llegadaParadaOrigen = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, origen.TiempoAPie); + + foreach (var referenciaOrigen in referenciasOrigen) + { + var primerPatron = referenciaOrigen.Patron; + if (!PatronPermitido(primerPatron, parametros)) + continue; + + var indiceSubida = referenciaOrigen.IndiceParada; + if (indiceSubida < 0 || indiceSubida >= primerPatron.CodigosParada.Count - 1) + continue; + + if (incluirPrimeraPiernaDirecta) + ExplorarPrimeraPiernaDirecta(origen, primerPatron, indiceSubida, llegadaParadaOrigen); + ExplorarPrimeraPiernaCircular(origen, primerPatron, indiceSubida, llegadaParadaOrigen); + + if (mejores.Count > maxResultados * 3) + PodarMejores(); + } + } + + return SeleccionarPoolBusqueda(mejores.Values, maxResultados); + + void ExplorarPrimeraPiernaDirecta( + ParadaCandidata origen, + PatronTransitoDbus primerPatron, + int indiceSubida, + DateTime llegadaParadaOrigen) + { + var limiteBajadas = Math.Min( + primerPatron.CodigosParada.Count, + indiceSubida + 1 + MaxBajadasPrimeraPiernaTransbordoRobusto); + + for (var indiceBajada = indiceSubida + 1; indiceBajada < limiteBajadas; indiceBajada++) + { + var codigoBajada = primerPatron.CodigosParada[indiceBajada]; + if (!TieneTransferenciaUtil(codigoBajada)) + continue; + + if (!ResolverSegmentoHorario( + primerPatron, + origen.Parada.Codigo, + codigoBajada, + llegadaParadaOrigen, + out var salidaPrimera, + out var llegadaBajada)) + { + continue; + } + + var esperaInicial = Math.Max(0, (salidaPrimera - llegadaParadaOrigen).TotalSeconds); + var tramosPrimeraPierna = new List + { + new() + { + Variante = primerPatron.Variante, + IndiceInicio = indiceSubida, + IndiceFin = indiceBajada + } + }; + + ProcesarBajadaPrimeraPierna( + origen, + tramosPrimeraPierna, + codigoBajada, + llegadaBajada, + esperaInicial); + } + } + + void ExplorarPrimeraPiernaCircular( + ParadaCandidata origen, + PatronTransitoDbus primerPatron, + int indiceSubida, + DateTime llegadaParadaOrigen) + { + var limiteIntermedias = Math.Min( + primerPatron.CodigosParada.Count, + indiceSubida + 1 + MaxBajadasPrimeraPiernaTransbordoRobusto); + + for (var indiceIntermedioPrimero = indiceSubida + 1; + indiceIntermedioPrimero < limiteIntermedias; + indiceIntermedioPrimero++) + { + var codigoIntermedio = primerPatron.CodigosParada[indiceIntermedioPrimero]; + if (!indiceTransito.PatronesPorParada.TryGetValue(codigoIntermedio, out var referenciasIntermedio)) + continue; + + var continuidadesIntermedio = referenciasIntermedio + .Where(referencia => + PatronPermitido(referencia.Patron, parametros) && + EsContinuidadCircular(primerPatron, referencia.Patron) && + referencia.IndiceParada >= 0 && + referencia.IndiceParada < referencia.Patron.CodigosParada.Count - 1) + .ToList(); + if (continuidadesIntermedio.Count == 0) + continue; + + if (!ResolverSegmentoHorario( + primerPatron, + origen.Parada.Codigo, + codigoIntermedio, + llegadaParadaOrigen, + out var salidaPrimera, + out var llegadaIntermedia)) + { + continue; + } + + var esperaPrimera = Math.Max(0, (salidaPrimera - llegadaParadaOrigen).TotalSeconds); + + foreach (var referenciaIntermedio in continuidadesIntermedio) + { + var segundoPatronCircular = referenciaIntermedio.Patron; + var indiceIntermedioSegundo = referenciaIntermedio.IndiceParada; + + var limiteBajadas = Math.Min( + segundoPatronCircular.CodigosParada.Count, + indiceIntermedioSegundo + 1 + MaxBajadasPrimeraPiernaTransbordoRobusto); + + for (var indiceBajadaSegundo = indiceIntermedioSegundo + 1; + indiceBajadaSegundo < limiteBajadas; + indiceBajadaSegundo++) + { + var codigoBajada = segundoPatronCircular.CodigosParada[indiceBajadaSegundo]; + if (!TieneTransferenciaUtil(codigoBajada)) + continue; + + if (!ResolverSegmentoHorario( + segundoPatronCircular, + codigoIntermedio, + codigoBajada, + llegadaIntermedia, + out var salidaSegunda, + out var llegadaBajada)) + { + continue; + } + + var esperaCircular = Math.Max(0, (salidaSegunda - llegadaIntermedia).TotalSeconds); + var tramosPrimeraPierna = new List + { + new() + { + Variante = primerPatron.Variante, + IndiceInicio = indiceSubida, + IndiceFin = indiceIntermedioPrimero + }, + new() + { + Variante = segundoPatronCircular.Variante, + IndiceInicio = indiceIntermedioSegundo, + IndiceFin = indiceBajadaSegundo + } + }; + + ProcesarBajadaPrimeraPierna( + origen, + tramosPrimeraPierna, + codigoBajada, + llegadaBajada, + esperaPrimera + esperaCircular); + } + } + } + } + + void ProcesarBajadaPrimeraPierna( + ParadaCandidata origen, + IReadOnlyList tramosPrimeraPierna, + string codigoBajada, + DateTime llegadaBajada, + double esperaInicial) + { + if (tramosPrimeraPierna.Count == 0 || !EsEsperaInicialValida(esperaInicial)) + return; + + var ultimoTramoPrimeraPierna = tramosPrimeraPierna[^1]; + foreach (var transferencia in ObtenerTransferenciasTransbordo(codigoBajada)) + { + if (!segundasPiernasPorSubida.TryGetValue(transferencia.CodigoParada, out var opcionesSegundaPierna)) + continue; + + var duracionTransbordo = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(transferencia.DistanciaMetros, parametros); + var llegadaParadaTransbordo = UtilidadesTransitoDbus.AddSecondsSafe(llegadaBajada, duracionTransbordo); + var primeraSalidaSegundaPierna = llegadaParadaTransbordo.AddSeconds(margenTransbordo); + + foreach (var opcionSegunda in opcionesSegundaPierna) + { + var segundoPatron = opcionSegunda.PrimerPatron; + if (!UtilidadesTransitoDbus.EsTransbordoReal(ultimoTramoPrimeraPierna.Variante, segundoPatron.Variante)) + { + continue; + } + + if (opcionSegunda.IndiceSubidaPrimerPatron < 0 || + opcionSegunda.IndiceSubidaPrimerPatron >= segundoPatron.CodigosParada.Count || + opcionSegunda.IndiceFinPrimerPatron <= opcionSegunda.IndiceSubidaPrimerPatron || + opcionSegunda.IndiceFinPrimerPatron >= segundoPatron.CodigosParada.Count) + { + continue; + } + + var destino = opcionSegunda.Destino; + var codigoSubidaSegundo = segundoPatron.CodigosParada[opcionSegunda.IndiceSubidaPrimerPatron]; + if (!string.Equals(codigoSubidaSegundo, transferencia.CodigoParada, StringComparison.OrdinalIgnoreCase)) + continue; + + var codigoFinPrimerSegmento = segundoPatron.CodigosParada[opcionSegunda.IndiceFinPrimerPatron]; + if (!ResolverSegmentoHorario( + segundoPatron, + codigoSubidaSegundo, + codigoFinPrimerSegmento, + primeraSalidaSegundaPierna, + out var salidaSegunda, + out var llegadaFinPrimerSegmento)) + { + continue; + } + + var esperaTransbordo = Math.Max(0, (salidaSegunda - llegadaParadaTransbordo).TotalSeconds); + var llegadaDestinoBus = llegadaFinPrimerSegmento; + var esperaCircularSegundaPierna = 0.0; + + if (opcionSegunda.PatronContinuacion is { } patronContinuacion) + { + if (opcionSegunda.IndiceInicioContinuacion < 0 || + opcionSegunda.IndiceInicioContinuacion >= patronContinuacion.CodigosParada.Count || + opcionSegunda.IndiceDestinoContinuacion <= opcionSegunda.IndiceInicioContinuacion || + opcionSegunda.IndiceDestinoContinuacion >= patronContinuacion.CodigosParada.Count) + { + continue; + } + + var codigoInicioContinuacion = patronContinuacion.CodigosParada[opcionSegunda.IndiceInicioContinuacion]; + if (!string.Equals(codigoInicioContinuacion, codigoFinPrimerSegmento, StringComparison.OrdinalIgnoreCase)) + continue; + + var codigoDestinoContinuacion = patronContinuacion.CodigosParada[opcionSegunda.IndiceDestinoContinuacion]; + if (!ResolverSegmentoHorario( + patronContinuacion, + codigoInicioContinuacion, + codigoDestinoContinuacion, + llegadaFinPrimerSegmento, + out var salidaContinuacion, + out llegadaDestinoBus)) + { + continue; + } + + esperaCircularSegundaPierna = Math.Max(0, (salidaContinuacion - llegadaFinPrimerSegmento).TotalSeconds); + } + + var esperaTotalTransbordos = esperaTransbordo + esperaCircularSegundaPierna; + if (!SonEsperasValidas(esperaInicial, esperaTotalTransbordos)) + continue; + + var tramos = tramosPrimeraPierna.Select(tramo => new TramoBus + { + Variante = tramo.Variante, + IndiceInicio = tramo.IndiceInicio, + IndiceFin = tramo.IndiceFin + }).ToList(); + tramos.Add(new TramoBus + { + Variante = segundoPatron.Variante, + IndiceInicio = opcionSegunda.IndiceSubidaPrimerPatron, + IndiceFin = opcionSegunda.IndiceFinPrimerPatron + }); + if (opcionSegunda.PatronContinuacion is { } patronContinuacionTramo) + { + tramos.Add(new TramoBus + { + Variante = patronContinuacionTramo.Variante, + IndiceInicio = opcionSegunda.IndiceInicioContinuacion, + IndiceFin = opcionSegunda.IndiceDestinoContinuacion + }); + } + + var numeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(tramos); + if (numeroTransbordos != 1 || !TransbordosEnRango(numeroTransbordos)) + continue; + + var ruta = new RutaBusAlternativa + { + Tramos = tramos, + NumeroTransbordos = numeroTransbordos, + CodigosParadasTransbordo = ConstruirCodigosTransbordo(tramos) + }; + var duracionTotal = Math.Max(0, (llegadaDestinoBus - instanteSalida).TotalSeconds) + destino.TiempoAPie; + var candidata = new ResultadoBusquedaRutaBusDbus + { + ParadaOrigen = origen.Parada, + ParadaDestino = destino.Parada, + DistanciaPieInicio = origen.DistanciaAPie, + TiempoPieInicio = origen.TiempoAPie, + DistanciaPieFin = destino.DistanciaAPie, + TiempoPieFin = destino.TiempoAPie, + DistanciaPieTransbordos = transferencia.DistanciaMetros, + TiempoEsperaInicialSegundos = esperaInicial, + TiempoEsperaTransbordosSegundos = esperaTotalTransbordos, + DuracionEstimadaSegundos = duracionTotal, + PuntuacionCoste = UtilidadesTransitoDbus.CalcularCosteConPenalizacionesRankingSegundos( + duracionTotal, + parametros, + numeroTransbordos, + origen.DistanciaAPie, + transferencia.DistanciaMetros, + destino.DistanciaAPie), + RutaBus = ruta + }; + + var firma = UtilidadesTransitoDbus.FirmaRutaBus(ruta); + if (!mejores.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(candidata, actual)) + mejores[firma] = candidata; + } + } + } + + List<(string CodigoParada, double DistanciaMetros)> ObtenerTransferenciasTransbordo(string codigoParada) + { + if (transferenciasUtilesPorBajada.TryGetValue(codigoParada, out var cacheadas)) + return cacheadas; + + var mejoresTransferencias = new Dictionary(StringComparer.OrdinalIgnoreCase) + { }; + + if (segundasPiernasPorSubida.ContainsKey(codigoParada)) + mejoresTransferencias[codigoParada] = 0; + + if (indiceTransito.TransferenciasPorParada.TryGetValue(codigoParada, out var transferencias)) + { + foreach (var transferencia in transferencias) + { + if (!segundasPiernasPorSubida.ContainsKey(transferencia.CodigoParadaDestino)) + continue; + + if (!mejoresTransferencias.TryGetValue(transferencia.CodigoParadaDestino, out var actual) || + transferencia.DistanciaMetros < actual) + { + mejoresTransferencias[transferencia.CodigoParadaDestino] = transferencia.DistanciaMetros; + } + } + } + + var resultado = mejoresTransferencias + .Select(par => (CodigoParada: par.Key, DistanciaMetros: par.Value)) + .OrderBy(par => par.DistanciaMetros) + .ToList(); + + transferenciasUtilesPorBajada[codigoParada] = resultado; + return resultado; + } + + bool TieneTransferenciaUtil(string codigoParada) + => ObtenerTransferenciasTransbordo(codigoParada).Count > 0; + + Dictionary> ConstruirSegundasPiernasPorSubida() + { + var porSubida = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var firmas = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var destino in destinoPorCodigo.Values) + { + if (!indiceTransito.PatronesPorParada.TryGetValue(destino.Parada.Codigo, out var referenciasDestino)) + continue; + + foreach (var referenciaDestino in referenciasDestino) + { + var patron = referenciaDestino.Patron; + if (!PatronPermitido(patron, parametros)) + continue; + + var indiceDestino = referenciaDestino.IndiceParada; + if (indiceDestino <= 0 || indiceDestino >= patron.CodigosParada.Count) + continue; + + var indiceMinimoSubida = Math.Max(0, indiceDestino - MaxBajadasPrimeraPiernaTransbordoRobusto); + for (var indiceSubida = indiceMinimoSubida; indiceSubida < indiceDestino; indiceSubida++) + { + var codigoSubida = patron.CodigosParada[indiceSubida]; + AgregarOpcion( + codigoSubida, + new SegundaPiernaTransbordoRobusta( + patron, + indiceSubida, + indiceDestino, + null, + -1, + -1, + destino)); + } + + for (var indiceIntermedioDestino = 0; indiceIntermedioDestino < indiceDestino; indiceIntermedioDestino++) + { + var codigoIntermedio = patron.CodigosParada[indiceIntermedioDestino]; + if (!indiceTransito.PatronesPorParada.TryGetValue(codigoIntermedio, out var referenciasIntermedio)) + continue; + + foreach (var referenciaIntermedio in referenciasIntermedio) + { + var patronInicialCircular = referenciaIntermedio.Patron; + if (!PatronPermitido(patronInicialCircular, parametros) || + !EsContinuidadCircular(patronInicialCircular, patron)) + { + continue; + } + + var indiceIntermedioInicial = referenciaIntermedio.IndiceParada; + if (indiceIntermedioInicial <= 0 || + indiceIntermedioInicial >= patronInicialCircular.CodigosParada.Count) + { + continue; + } + + var indiceMinimoSubidaCircular = Math.Max( + 0, + indiceIntermedioInicial - MaxBajadasPrimeraPiernaTransbordoRobusto); + for (var indiceSubidaCircular = indiceMinimoSubidaCircular; + indiceSubidaCircular < indiceIntermedioInicial; + indiceSubidaCircular++) + { + var codigoSubida = patronInicialCircular.CodigosParada[indiceSubidaCircular]; + AgregarOpcion( + codigoSubida, + new SegundaPiernaTransbordoRobusta( + patronInicialCircular, + indiceSubidaCircular, + indiceIntermedioInicial, + patron, + indiceIntermedioDestino, + indiceDestino, + destino)); + } + } + } + } + } + + foreach (var codigo in porSubida.Keys.ToList()) + { + porSubida[codigo] = porSubida[codigo] + .OrderBy(x => x.Destino.TiempoAPie) + .ThenBy(x => x.NumeroTramos) + .ThenBy(x => (x.IndiceFinPrimerPatron - x.IndiceSubidaPrimerPatron) + + Math.Max(0, x.IndiceDestinoContinuacion - x.IndiceInicioContinuacion)) + .Take(20) + .ToList(); + } + + return porSubida; + + void AgregarOpcion(string codigoSubida, SegundaPiernaTransbordoRobusta opcion) + { + var firma = string.Join("|", + opcion.PrimerPatron.Id, + opcion.IndiceSubidaPrimerPatron.ToString(CultureInfo.InvariantCulture), + opcion.IndiceFinPrimerPatron.ToString(CultureInfo.InvariantCulture), + opcion.PatronContinuacion?.Id ?? "-", + opcion.IndiceInicioContinuacion.ToString(CultureInfo.InvariantCulture), + opcion.IndiceDestinoContinuacion.ToString(CultureInfo.InvariantCulture), + opcion.Destino.Parada.Codigo); + if (!firmas.Add(firma)) + return; + + if (!porSubida.TryGetValue(codigoSubida, out var lista)) + { + lista = new List(); + porSubida[codigoSubida] = lista; + } + + lista.Add(opcion); + } + } + + bool EsEsperaInicialValida(double esperaInicial) + { + return parametros.MaxEsperaInicialMinutos <= 0 || + esperaInicial <= parametros.MaxEsperaInicialMinutos * 60.0; + } + + bool SonEsperasValidas(double esperaInicial, double esperaTransbordo) + { + if (!EsEsperaInicialValida(esperaInicial)) + return false; + + if (parametros.MaxEsperaTransbordoMinutos > 0 && + esperaTransbordo > parametros.MaxEsperaTransbordoMinutos * 60.0) + { + return false; + } + + var esperaTotal = esperaInicial + esperaTransbordo; + return parametros.MaxEsperaTotalMinutos <= 0 || + esperaTotal <= parametros.MaxEsperaTotalMinutos * 60.0; + } + + bool TransbordosEnRango(int numeroTransbordos) + { + if (!parametros.ActivarRangoTransbordos) + return true; + + var minimo = Math.Min(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos); + var maximo = Math.Max(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos); + return numeroTransbordos >= minimo && numeroTransbordos <= maximo; + } + + void PodarMejores() + { + mejores = SeleccionarPoolBusqueda(mejores.Values, maxResultados * 2) + .ToDictionary( + x => UtilidadesTransitoDbus.FirmaRutaBus(x.RutaBus), + StringComparer.OrdinalIgnoreCase); + } + } + + /// + /// Extiende rutas que empiezan en una circular para poder coger antes el sentido contrario + /// cuando eso evita una caminata inicial claramente peor. + /// + private List BuscarExtensionesCircularesInicialesConHorario( + IndiceTransitoDbus indiceTransito, + IReadOnlyList candidatasOrigen, + IReadOnlyList candidatasBase, + DateTime instanteSalida, + ParametrosPlanificadorRuta parametros) + { + if (candidatasOrigen.Count == 0 || candidatasBase.Count == 0) + return new(); + + var mejores = new Dictionary(StringComparer.OrdinalIgnoreCase); + var maxResultados = Math.Max(24, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 12); + var basesExtension = SeleccionarPoolBusqueda( + candidatasBase, + Math.Max(12, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 4)); + + foreach (var baseRuta in basesExtension) + { + if (baseRuta.RutaBus.Tramos.Count == 0) + continue; + + var primerTramoBase = baseRuta.RutaBus.Tramos[0]; + var segundoPatron = ResolverPatronDesdeTramo(indiceTransito, primerTramoBase); + if (segundoPatron is null) + continue; + + foreach (var origen in candidatasOrigen.OrderBy(x => x.TiempoAPie)) + { + if (origen.DistanciaAPie + MejoraMinimaAccesoExtensionCircularMetros >= baseRuta.DistanciaPieInicio) + continue; + + if (!indiceTransito.PatronesPorParada.TryGetValue(origen.Parada.Codigo, out var referenciasOrigen)) + continue; + + var llegadaParadaOrigen = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, origen.TiempoAPie); + foreach (var referenciaOrigen in referenciasOrigen) + { + var primerPatron = referenciaOrigen.Patron; + if (!PatronPermitido(primerPatron, parametros) || + !EsContinuidadCircular(primerPatron, segundoPatron)) + { + continue; + } + + var indiceSubida = referenciaOrigen.IndiceParada; + if (indiceSubida < 0 || indiceSubida >= primerPatron.CodigosParada.Count - 1) + continue; + + for (var indiceIntermedioPrimero = indiceSubida + 1; + indiceIntermedioPrimero < primerPatron.CodigosParada.Count; + indiceIntermedioPrimero++) + { + var codigoIntermedio = primerPatron.CodigosParada[indiceIntermedioPrimero]; + var indicesSegundo = IndicesParadaAntesDe(segundoPatron, codigoIntermedio, primerTramoBase.IndiceFin); + foreach (var indiceIntermedioSegundo in indicesSegundo) + { + if (!ResolverSegmentoHorario( + primerPatron, + origen.Parada.Codigo, + codigoIntermedio, + llegadaParadaOrigen, + out var salidaPrimera, + out var llegadaIntermedia)) + { + continue; + } + + if (!ResolverSegmentoHorario( + segundoPatron, + codigoIntermedio, + primerTramoBase.CodigoParadaFin, + llegadaIntermedia, + out var salidaSegunda, + out _)) + { + continue; + } + + var tramos = new List + { + new() + { + Variante = primerPatron.Variante, + IndiceInicio = indiceSubida, + IndiceFin = indiceIntermedioPrimero + }, + new() + { + Variante = segundoPatron.Variante, + IndiceInicio = indiceIntermedioSegundo, + IndiceFin = primerTramoBase.IndiceFin + } + }; + + tramos.AddRange(baseRuta.RutaBus.Tramos.Skip(1).Select(tramo => new TramoBus + { + Variante = tramo.Variante, + IndiceInicio = tramo.IndiceInicio, + IndiceFin = tramo.IndiceFin + })); + + var numeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(tramos); + if (parametros.ActivarRangoTransbordos) + { + var minimo = Math.Min(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos); + var maximo = Math.Max(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos); + if (numeroTransbordos < minimo || numeroTransbordos > maximo) + continue; + } + + var ruta = new RutaBusAlternativa + { + Tramos = tramos, + NumeroTransbordos = numeroTransbordos, + CodigosParadasTransbordo = ConstruirCodigosTransbordo(tramos) + }; + + var esperaInicial = Math.Max(0, (salidaPrimera - llegadaParadaOrigen).TotalSeconds) + + Math.Max(0, (salidaSegunda - llegadaIntermedia).TotalSeconds); + var duracionTotal = baseRuta.DuracionEstimadaSegundos; + var candidata = new ResultadoBusquedaRutaBusDbus + { + ParadaOrigen = origen.Parada, + ParadaDestino = baseRuta.ParadaDestino, + DistanciaPieInicio = origen.DistanciaAPie, + TiempoPieInicio = origen.TiempoAPie, + DistanciaPieFin = baseRuta.DistanciaPieFin, + TiempoPieFin = baseRuta.TiempoPieFin, + DistanciaPieTransbordos = baseRuta.DistanciaPieTransbordos, + TiempoEsperaInicialSegundos = esperaInicial, + TiempoEsperaTransbordosSegundos = baseRuta.TiempoEsperaTransbordosSegundos, + DuracionEstimadaSegundos = duracionTotal, + PuntuacionCoste = UtilidadesTransitoDbus.CalcularCosteConPenalizacionesRankingSegundos( + duracionTotal, + parametros, + numeroTransbordos, + origen.DistanciaAPie, + baseRuta.DistanciaPieTransbordos, + baseRuta.DistanciaPieFin), + RutaBus = ruta + }; + + var firma = UtilidadesTransitoDbus.FirmaRutaBus(ruta); + if (!mejores.TryGetValue(firma, out var actual) || ResultadoBusquedaEsMejor(candidata, actual)) + mejores[firma] = candidata; + } + } + } + } + } + + return SeleccionarPoolBusqueda(mejores.Values, maxResultados); + } + + private static PatronTransitoDbus? ResolverPatronDesdeTramo(IndiceTransitoDbus indiceTransito, TramoBus tramo) + { + if (!tramo.EsValido) + return null; + + if (indiceTransito.PatronesPorParada.TryGetValue(tramo.CodigoParadaInicio, out var referencias)) + { + var encontrado = referencias + .Where(referencia => referencia.IndiceParada == tramo.IndiceInicio) + .Select(referencia => referencia.Patron) + .FirstOrDefault(patron => PatronCoincideConTramo(patron, tramo)); + if (encontrado is not null) + return encontrado; + } + + return indiceTransito.Patrones.FirstOrDefault(patron => PatronCoincideConTramo(patron, tramo)); + } + + private static bool PatronCoincideConTramo(PatronTransitoDbus patron, TramoBus tramo) + { + var linea = UtilidadesTransitoDbus.LineaCodigoStr(tramo.Variante); + var sentido = UtilidadesTransitoDbus.NormalizarSentidoCodigo(tramo.Variante.Sentido?.Codigo); + return + ReferenceEquals(patron.Variante, tramo.Variante) || + (string.Equals(patron.CodigoLinea, linea, StringComparison.OrdinalIgnoreCase) && + patron.CodigoItinerario == tramo.Variante.CodigoItinerario && + string.Equals( + UtilidadesTransitoDbus.NormalizarSentidoCodigo(patron.Sentido), + sentido, + StringComparison.OrdinalIgnoreCase) && + patron.CodigosParada.Count == tramo.Variante.CodigosParadas.Count && + patron.CodigosParada.SequenceEqual(tramo.Variante.CodigosParadas, StringComparer.OrdinalIgnoreCase)); + } + + private static IEnumerable IndicesParadaAntesDe(PatronTransitoDbus patron, string codigoParada, int indiceLimiteFin) + { + var limite = Math.Min(indiceLimiteFin, patron.CodigosParada.Count); + for (var indice = 0; indice < limite; indice++) + { + if (string.Equals(patron.CodigosParada[indice], codigoParada, StringComparison.OrdinalIgnoreCase)) + yield return indice; + } + } + + private static List ConstruirCodigosTransbordo(IReadOnlyList tramos) + { + var codigos = new List(); + for (var indice = 1; indice < tramos.Count; indice++) + { + if (!UtilidadesTransitoDbus.EsTransbordoReal(tramos[indice - 1], tramos[indice])) + continue; + + var codigo = tramos[indice].CodigoParadaInicio; + if (!codigos.Contains(codigo, StringComparer.OrdinalIgnoreCase)) + codigos.Add(codigo); + } + + return codigos; + } + + /// + /// Conserva candidatas buenas por coste y tambien un cupo por menor caminata. + /// + private static List SeleccionarPoolBusqueda( + IEnumerable candidatas, + int maxPorCoste) + { + var lista = candidatas.ToList(); + if (lista.Count == 0) + return new(); + + var seleccionadas = new List(); + var firmas = new HashSet(StringComparer.OrdinalIgnoreCase); + var limiteCoste = Math.Max(1, maxPorCoste); + var limiteAndar = Math.Max(8, limiteCoste / 2); + var duracionMinima = lista + .Select(x => x.DuracionEstimadaSegundos) + .Where(double.IsFinite) + .DefaultIfEmpty(double.PositiveInfinity) + .Min(); + var limiteDuracionAndarRazonable = double.IsFinite(duracionMinima) + ? duracionMinima + Math.Max(15 * 60.0, duracionMinima * 0.5) + : double.PositiveInfinity; + + Agregar(lista + .OrderBy(x => x.PuntuacionCoste) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.RutaBus.NumeroTransbordos) + .ThenBy(x => x.DistanciaPieTotal) + .Take(limiteCoste)); + + Agregar(lista + .OrderBy(x => x.DistanciaPieTotal) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.PuntuacionCoste) + .ThenBy(x => x.RutaBus.NumeroTransbordos) + .Take(limiteAndar)); + + Agregar(lista + .Where(x => x.DuracionEstimadaSegundos <= limiteDuracionAndarRazonable) + .OrderBy(x => x.DistanciaPieTotal) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.PuntuacionCoste) + .ThenBy(x => x.RutaBus.NumeroTransbordos) + .Take(Math.Max(8, limiteCoste / 2))); + + Agregar(lista + .OrderBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.PuntuacionCoste) + .ThenBy(x => x.DistanciaPieTotal) + .ThenBy(x => x.RutaBus.NumeroTransbordos) + .Take(Math.Max(6, limiteCoste / 3))); + + return seleccionadas + .OrderBy(x => x.PuntuacionCoste) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.RutaBus.NumeroTransbordos) + .ThenBy(x => x.DistanciaPieTotal) + .ToList(); + + void Agregar(IEnumerable origen) + { + foreach (var candidata in origen) + { + var firma = UtilidadesTransitoDbus.FirmaRutaBus(candidata.RutaBus); + if (firmas.Add(firma)) + seleccionadas.Add(candidata); + } + } + } + + private static bool DebeBuscarTransbordosRobustos( + IReadOnlyList candidatasBus, + IReadOnlyList candidatasOrigen, + IReadOnlyList candidatasDestino) + { + if (candidatasOrigen.Count == 0 || candidatasDestino.Count == 0) + return false; + + if (candidatasBus.Count == 0) + return true; + + var mejorPieActual = candidatasBus + .Select(x => x.DistanciaPieTotal) + .Where(double.IsFinite) + .DefaultIfEmpty(double.PositiveInfinity) + .Min(); + if (mejorPieActual <= UmbralActivacionTransbordoRobustoMetros) + return false; + + var mejorAccesoExtremos = candidatasOrigen.Min(x => x.DistanciaAPie) + + candidatasDestino.Min(x => x.DistanciaAPie); + return mejorAccesoExtremos <= UmbralAccesoDestinoTransbordoRobustoMetros; + } + + private static bool DebeUsarBusquedaDirigidaUnaTransbordo(ParametrosPlanificadorRuta parametros) + { + if (!parametros.ActivarRangoTransbordos) + return false; + + var minimo = Math.Min(parametros.MinTransbordosPermitidos, parametros.MaxTransbordosPermitidos); + return minimo <= 1; + } + + private static List SeleccionarPoolRefinamientoExacto( + IEnumerable candidatas, + ParametrosPlanificadorRuta parametros) + { + var lista = candidatas.ToList(); + if (lista.Count == 0) + return new(); + + var maxAlternativas = Math.Max(1, parametros.MaxAlternativasBusPorPar); + var limiteTotal = Math.Max(24, maxAlternativas * 8); + var seleccionadas = new List(limiteTotal); + var firmas = new HashSet(StringComparer.OrdinalIgnoreCase); + + Agregar(lista + .OrderBy(x => x.PuntuacionCoste) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.RutaBus.NumeroTransbordos) + .ThenBy(x => x.DistanciaPieTotal) + .Take(Math.Max(10, limiteTotal / 2))); + + Agregar(lista + .OrderBy(x => x.DistanciaPieTotal) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.PuntuacionCoste) + .Take(Math.Max(8, limiteTotal / 3))); + + Agregar(lista + .OrderBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.PuntuacionCoste) + .ThenBy(x => x.DistanciaPieTotal) + .Take(Math.Max(8, limiteTotal / 4))); + + Agregar(lista + .OrderBy(x => x.RutaBus.NumeroTransbordos) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.DistanciaPieTotal) + .Take(Math.Max(6, limiteTotal / 4))); + + return seleccionadas + .OrderBy(x => x.PuntuacionCoste) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.RutaBus.NumeroTransbordos) + .ThenBy(x => x.DistanciaPieTotal) + .ToList(); + + void Agregar(IEnumerable origen) + { + foreach (var candidata in origen) + { + if (seleccionadas.Count >= limiteTotal) + return; + + var firma = UtilidadesTransitoDbus.FirmaRutaBus(candidata.RutaBus); + if (firmas.Add(firma)) + seleccionadas.Add(candidata); + } + } + } + + private bool ResolverSegmentoHorario( + PatronTransitoDbus patron, + string codigoParadaInicio, + string codigoParadaFin, + DateTime primeraSalidaAceptable, + out DateTime salida, + out DateTime llegada) + { + return ResolverSegmentoHorario( + codigoParadaInicio, + codigoParadaFin, + UtilidadesTransitoDbus.LineaCodigoStr(patron.Variante), + patron.CodigoItinerario, + UtilidadesTransitoDbus.NormalizarSentidoCodigo(patron.Sentido), + primeraSalidaAceptable, + out salida, + out llegada); + } + + private bool ResolverSegmentoHorario( + string codigoParadaInicio, + string codigoParadaFin, + string codigoLinea, + int codigoItinerario, + string sentido, + DateTime primeraSalidaAceptable, + out DateTime salida, + out DateTime llegada) + { + var primeraSalidaNormalizada = RedondearAlSegundoSuperior(primeraSalidaAceptable); + var fecha = DateOnly.FromDateTime(primeraSalidaNormalizada); + var hora = TimeOnly.FromDateTime(primeraSalidaNormalizada); + var clave = string.Join("|", + codigoParadaInicio, + codigoParadaFin, + codigoLinea, + codigoItinerario.ToString(CultureInfo.InvariantCulture), + sentido, + fecha.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + hora.ToString("HH:mm:ss", CultureInfo.InvariantCulture)); + + if (_cacheSegmentosHorarios.TryGetValue(clave, out var cacheado)) + { + salida = cacheado.salida; + llegada = cacheado.llegada; + return cacheado.ok; + } + + var ok = _servicioHorarios.TryGetNextBusSegment( + codigoParadaInicio, + codigoParadaFin, + codigoLinea, + codigoItinerario, + sentido, + fecha, + hora, + out salida, + out llegada, + out _); + + _cacheSegmentosHorarios[clave] = (ok, salida, llegada); + return ok; + } + + private static DateTime RedondearAlSegundoSuperior(DateTime fecha) + { + var resto = fecha.Ticks % TimeSpan.TicksPerSecond; + return resto == 0 + ? fecha + : new DateTime(fecha.Ticks + TimeSpan.TicksPerSecond - resto, fecha.Kind); + } + + private static bool EsContinuidadCircular(PatronTransitoDbus primero, PatronTransitoDbus segundo) + { + return string.Equals(primero.CodigoLinea, segundo.CodigoLinea, StringComparison.OrdinalIgnoreCase) && + primero.CodigoItinerario == segundo.CodigoItinerario && + !string.Equals( + UtilidadesTransitoDbus.NormalizarSentidoCodigo(primero.Sentido), + UtilidadesTransitoDbus.NormalizarSentidoCodigo(segundo.Sentido), + StringComparison.OrdinalIgnoreCase); + } + + private static bool EsContinuidadCircular(InfoVariante primero, InfoVariante segundo) + { + return string.Equals( + UtilidadesTransitoDbus.LineaCodigoStr(primero), + UtilidadesTransitoDbus.LineaCodigoStr(segundo), + StringComparison.OrdinalIgnoreCase) && + primero.CodigoItinerario == segundo.CodigoItinerario && + !string.Equals( + UtilidadesTransitoDbus.NormalizarSentidoCodigo(primero.Sentido?.Codigo), + UtilidadesTransitoDbus.NormalizarSentidoCodigo(segundo.Sentido?.Codigo), + StringComparison.OrdinalIgnoreCase); + } + + /// + /// Compara dos candidatas de bus de forma consistente con el buscador temporal. + /// + private static bool ResultadoBusquedaEsMejor(ResultadoBusquedaRutaBusDbus candidato, ResultadoBusquedaRutaBusDbus actual) + { + var comparacionCoste = candidato.PuntuacionCoste.CompareTo(actual.PuntuacionCoste); + if (comparacionCoste != 0) + return comparacionCoste < 0; + + var comparacionDuracion = candidato.DuracionEstimadaSegundos.CompareTo(actual.DuracionEstimadaSegundos); + if (comparacionDuracion != 0) + return comparacionDuracion < 0; + + var comparacionTrans = candidato.RutaBus.NumeroTransbordos.CompareTo(actual.RutaBus.NumeroTransbordos); + if (comparacionTrans != 0) + return comparacionTrans < 0; + + return candidato.DistanciaPieTotal < actual.DistanciaPieTotal; + } + + /// + /// Deduplica alternativas tecnicamente distintas pero equivalentes para la persona usuaria. + /// + private static List DeduplicarAlternativasEquivalentes(IReadOnlyList alternativas) + { + var mejoresPorFirma = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var alternativa in alternativas) + { + if (alternativa.AlternativaBus is null || alternativa.AlternativaBus.Tramos.Count == 0) + continue; + + var firma = CrearFirmaAlternativaEquivalente(alternativa); + if (!mejoresPorFirma.TryGetValue(firma, out var actual) || + AlternativaEquivalenteEsMejor(alternativa, actual)) + { + mejoresPorFirma[firma] = alternativa; + } + } + + return mejoresPorFirma.Values + .OrderBy(x => x.PuntuacionCoste) + .ThenBy(x => x.DuracionTotalAproxSegundos) + .ThenBy(x => x.NumeroTransbordosReales) + .ThenBy(x => x.DistanciaPieTotalAprox) + .ToList(); + } + + private static List SeleccionarPoolFinal(IReadOnlyList alternativas) + { + var seleccionadas = new List(); + var firmas = new HashSet(StringComparer.OrdinalIgnoreCase); + + Agregar(alternativas + .OrderBy(x => x.PuntuacionCoste) + .ThenBy(x => x.DuracionTotalAproxSegundos) + .Take(12)); + + Agregar(alternativas + .OrderBy(x => x.DistanciaPieTotalAprox) + .ThenBy(x => x.DuracionTotalAproxSegundos) + .Take(8)); + + return seleccionadas + .OrderBy(x => x.PuntuacionCoste) + .ThenBy(x => x.DuracionTotalAproxSegundos) + .ToList(); + + void Agregar(IEnumerable candidatas) + { + foreach (var alternativa in candidatas) + { + if (alternativa.AlternativaBus is null) + continue; + + var firma = UtilidadesTransitoDbus.FirmaRutaBus(alternativa.AlternativaBus); + if (firmas.Add(firma)) + seleccionadas.Add(alternativa); + } + } + } + + /// + /// Firma de equivalencia orientada a resultado visible (mismos tramos/paradas y tiempos practicos). + /// + private static string CrearFirmaAlternativaEquivalente(AlternativaRuta alternativa) + { + var tramos = alternativa.AlternativaBus!.Tramos + .Select(tramo => + { + var linea = UtilidadesTransitoDbus.LineaCodigoStr(tramo.Variante); + var sentido = UtilidadesTransitoDbus.NormalizarSentidoCodigo(tramo.Variante.Sentido?.Codigo); + return $"{linea}-{sentido}-{tramo.CodigoParadaInicio}>{tramo.CodigoParadaFin}"; + }); + + return string.Join("|", new[] + { + string.Join(";", tramos), + $"tp0:{BucketSegundos(alternativa.TiempoPieInicio)}", + $"te0:{BucketSegundos(alternativa.TiempoEsperaInicialSegundos)}", + $"tet:{BucketSegundos(alternativa.TiempoEsperaTransbordosSegundos)}", + $"tpf:{BucketSegundos(alternativa.TiempoPieFin)}", + $"tt:{BucketSegundos(alternativa.DuracionTotalAproxSegundos)}" + }); + } + + /// + /// Compara dos alternativas equivalentes y decide cual conservar. + /// + private static bool AlternativaEquivalenteEsMejor(AlternativaRuta candidato, AlternativaRuta actual) + { + var comparacionCoste = candidato.PuntuacionCoste.CompareTo(actual.PuntuacionCoste); + if (comparacionCoste != 0) + return comparacionCoste < 0; + + var comparacionDuracion = candidato.DuracionTotalAproxSegundos.CompareTo(actual.DuracionTotalAproxSegundos); + if (comparacionDuracion != 0) + return comparacionDuracion < 0; + + var comparacionTransbordos = candidato.NumeroTransbordosReales.CompareTo(actual.NumeroTransbordosReales); + if (comparacionTransbordos != 0) + return comparacionTransbordos < 0; + + var comparacionPie = candidato.DistanciaPieTotalAprox.CompareTo(actual.DistanciaPieTotalAprox); + if (comparacionPie != 0) + return comparacionPie < 0; + + return CodigoItinerarioMinimo(candidato) < CodigoItinerarioMinimo(actual); + } + + /// + /// Agrupa tiempos en cubos de 30 segundos para evitar ruido numerico. + /// + private static int BucketSegundos(double segundos) + { + if (!double.IsFinite(segundos) || segundos <= 0) + return 0; + + return (int)Math.Round(segundos / 30.0, MidpointRounding.AwayFromZero); + } + + /// + /// Devuelve el codigo de itinerario mas bajo de la alternativa para desempatar. + /// + private static int CodigoItinerarioMinimo(AlternativaRuta alternativa) + { + var tramos = alternativa.AlternativaBus?.Tramos; + if (tramos is null || tramos.Count == 0) + return int.MaxValue; + + return tramos + .Select(t => t.Variante.CodigoItinerario) + .DefaultIfEmpty(int.MaxValue) + .Min(); + } + + /// + /// Selecciona las paradas cercanas mas prometedoras. + /// + private async Task> SeleccionarCandidatasAsync( + IndiceTransitoDbus indiceTransito, + double latitud, + double longitud, + ParametrosPlanificadorRuta parametros, + CancellationToken ct) + { + var radio = Math.Clamp(parametros.RadioParadasCandidatasMetros, 200, 3500); + var maximo = Math.Clamp(parametros.MaxParadasCandidatasPorLado, 3, 20); + var pool = indiceTransito.QueryParadasEnRadio(latitud, longitud, radio); + var candidatas = new List(pool.Count); + + foreach (var parada in pool) + { + ct.ThrowIfCancellationRequested(); + + if ((parametros.ActivarRangoLineas || parametros.ActivarRangoItinerarios) && + !ParadaTienePatronPermitido(indiceTransito, parada.Codigo, parametros)) + { + continue; + } + + var distancia = UtilidadesTransitoDbus.CalcularDistanciaMetros(latitud, longitud, parada.Latitud, parada.Longitud); + if (distancia > radio * 1.9) + continue; + + candidatas.Add(new ParadaCandidata + { + Parada = parada, + DistanciaEuclidea = distancia, + DistanciaAPie = distancia, + TiempoAPie = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distancia, parametros) + }); + } + + candidatas = candidatas + .OrderBy(x => x.DistanciaEuclidea) + .Take(Math.Min(64, Math.Max(maximo + 20, maximo * 4))) + .ToList(); + + if (!parametros.UsarOsrmParaRanking || parametros.ActivarRangoLineas || parametros.ActivarRangoItinerarios) + return SeleccionarCandidatasConCoberturaPatrones(candidatas, maximo, indiceTransito, parametros); + + var semaforo = new SemaphoreSlim(4, 4); + var tareas = candidatas.Select(async candidata => + { + await semaforo.WaitAsync(ct); + try + { + var caminata = await WalkOsrmCached(latitud, longitud, candidata.Parada.Latitud, candidata.Parada.Longitud); + if (caminata.distancia > 0) + { + candidata.DistanciaAPie = caminata.distancia; + candidata.TiempoAPie = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(caminata.distancia, parametros); + } + } + catch + { + } + finally + { + semaforo.Release(); + } + }); + + await Task.WhenAll(tareas); + + var candidatasOrdenadas = candidatas + .OrderBy(x => x.TiempoAPie) + .ThenBy(x => x.DistanciaEuclidea) + .ToList(); + + return SeleccionarCandidatasConCoberturaPatrones(candidatasOrdenadas, maximo, indiceTransito, parametros); + } + + /// + /// Comprueba si una parada tiene al menos un patron permitido. + /// + private static bool ParadaTienePatronPermitido( + IndiceTransitoDbus indiceTransito, + string codigoParada, + ParametrosPlanificadorRuta parametros) + { + if (!indiceTransito.PatronesPorParada.TryGetValue(codigoParada, out var referencias)) + return false; + + foreach (var referencia in referencias) + { + if (PatronPermitido(referencia.Patron, parametros)) + return true; + } + + return false; + } + + /// + /// Selecciona candidatas manteniendo las mejores por distancia/tiempo y añade algunas extra + /// para cubrir lineas/itinerarios que no entren en el recorte principal. + /// + private static List SeleccionarCandidatasConCoberturaPatrones( + IReadOnlyList candidatasOrdenadas, + int maximo, + IndiceTransitoDbus indiceTransito, + ParametrosPlanificadorRuta parametros) + { + if (candidatasOrdenadas.Count <= maximo) + return candidatasOrdenadas.ToList(); + + var seleccion = candidatasOrdenadas.Take(maximo).ToList(); + var firmasCubiertas = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var candidata in seleccion) + AgregarFirmasPatronesPermitidos(candidata.Parada.Codigo, firmasCubiertas, indiceTransito, parametros); + + var extrasMaximos = Math.Min(10, Math.Max(4, maximo / 2)); + foreach (var candidata in candidatasOrdenadas.Skip(maximo)) + { + if (extrasMaximos <= 0) + break; + + if (!ParadaAportaCoberturaNueva(candidata.Parada.Codigo, firmasCubiertas, indiceTransito, parametros)) + continue; + + seleccion.Add(candidata); + AgregarFirmasPatronesPermitidos(candidata.Parada.Codigo, firmasCubiertas, indiceTransito, parametros); + extrasMaximos--; + } + + return seleccion; + } + + /// + /// Indica si una parada aporta al menos una firma de patron que todavia no estaba cubierta. + /// + private static bool ParadaAportaCoberturaNueva( + string codigoParada, + ISet firmasCubiertas, + IndiceTransitoDbus indiceTransito, + ParametrosPlanificadorRuta parametros) + { + if (!indiceTransito.PatronesPorParada.TryGetValue(codigoParada, out var referencias)) + return false; + + foreach (var referencia in referencias) + { + var patron = referencia.Patron; + if (!PatronPermitido(patron, parametros)) + continue; + + var firma = FirmaCoberturaPatron(patron); + if (!firmasCubiertas.Contains(firma)) + return true; + } + + return false; + } + + /// + /// Registra en el set las firmas permitidas que pasan por una parada. + /// + private static void AgregarFirmasPatronesPermitidos( + string codigoParada, + ISet firmasCubiertas, + IndiceTransitoDbus indiceTransito, + ParametrosPlanificadorRuta parametros) + { + if (!indiceTransito.PatronesPorParada.TryGetValue(codigoParada, out var referencias)) + return; + + foreach (var referencia in referencias) + { + var patron = referencia.Patron; + if (!PatronPermitido(patron, parametros)) + continue; + + firmasCubiertas.Add(FirmaCoberturaPatron(patron)); + } + } + + /// + /// Firma de cobertura para ampliar variedad de alternativas sin duplicar exceso. + /// + private static string FirmaCoberturaPatron(PatronTransitoDbus patron) + => $"{patron.CodigoLinea}|{patron.CodigoItinerario}|{patron.Sentido}"; + + /// + /// Crea las dos alternativas de solo a pie. + /// + private async Task> CrearAlternativasSoloPieAsync( + double[] origenLatLon, + double[] destinoLatLon, + ParametrosPlanificadorRuta parametros) + { + var alternativas = new List(2); + + var osrm = await WalkOsrmCached(origenLatLon[0], origenLatLon[1], destinoLatLon[0], destinoLatLon[1]); + if (osrm.linea is not null && osrm.distancia > 0) + { + var duracion = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(osrm.distancia, parametros); + var puntuacion = UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos(duracion, parametros, osrm.distancia); + alternativas.Add(new AlternativaRuta + { + EsSoloAPie = true, + Etiqueta = "Solo a pie (v1)", + ProveedorRuta = "v1", + LineaSoloAPie = osrm.linea, + DistanciaSoloAPie = osrm.distancia, + DuracionSoloAPie = duracion, + DuracionTotalAproxSegundos = duracion, + PuntuacionCoste = puntuacion + }); + } + + var valhalla = await WalkValhallaCached(origenLatLon[0], origenLatLon[1], destinoLatLon[0], destinoLatLon[1]); + if (valhalla.linea is not null && valhalla.distancia > 0) + { + var duracion = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(valhalla.distancia, parametros); + var puntuacion = UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos(duracion, parametros, valhalla.distancia); + alternativas.Add(new AlternativaRuta + { + EsSoloAPie = true, + Etiqueta = "Solo a pie (v2)", + ProveedorRuta = "v2", + LineaSoloAPie = valhalla.linea, + DistanciaSoloAPie = valhalla.distancia, + DuracionSoloAPie = duracion, + DuracionTotalAproxSegundos = duracion, + PuntuacionCoste = puntuacion + }); + } + + return alternativas; + } + + /// + /// Recalcula una alternativa de bus usando caminatas exactas y horarios reales. + /// + private async Task RecalcularDuracionExactaAsync( + double[] origenLatLon, + double[] destinoLatLon, + AlternativaRuta alternativa, + ParametrosPlanificadorRuta parametros, + CancellationToken ct) + { + if (alternativa.AlternativaBus is null || alternativa.AlternativaBus.Tramos.Count == 0) + return double.PositiveInfinity; + + var tramosBus = alternativa.AlternativaBus.Tramos; + var instanteSalida = UtilidadesTransitoDbus.ResolverInstanteSalida(parametros); + + await RecortarInicioCircularRedundanteAsync(origenLatLon, alternativa, parametros, instanteSalida, ct); + await RefinarParadaInicioAsync(origenLatLon, tramosBus, parametros, instanteSalida, ct); + await RecortarInicioCircularRedundanteAsync(origenLatLon, alternativa, parametros, instanteSalida, ct); + + var lineasBus = ConstruirLineasBusPlanificacionCached(tramosBus, out var distanciaBus); + alternativa.AlternativaBus.DistanciaTotalBus = distanciaBus; + + var paradaInicioBus = alternativa.OrigenParada; + var paradaFinBus = alternativa.DestinoParada; + + if (lineasBus.Count > 0) + { + var primerTramo = tramosBus[0]; + var ultimoTramo = tramosBus[^1]; + + paradaInicioBus = ResolverParadaFisica( + primerTramo.CodigoParadaInicio, + ObtenerPrimerPunto(lineasBus[0]), + alternativa.OrigenParada); + paradaFinBus = ResolverParadaFisica( + ultimoTramo.CodigoParadaFin, + ObtenerUltimoPunto(lineasBus[^1]), + alternativa.DestinoParada); + } + + alternativa.OrigenParada = paradaInicioBus; + alternativa.DestinoParada = paradaFinBus; + alternativa.TiempoEsperaInicialSegundos = 0; + alternativa.TiempoEsperaTransbordosSegundos = 0; + alternativa.HorarioCompletoDisponible = parametros.UsarHorariosReales; + + var total = 0.0; + var cursor = instanteSalida; + + var acceso = await WalkOsrmCached(origenLatLon[0], origenLatLon[1], paradaInicioBus.Latitud, paradaInicioBus.Longitud); + alternativa.DistanciaPieInicioAprox = acceso.distancia > 0 + ? acceso.distancia + : UtilidadesTransitoDbus.CalcularDistanciaMetros(origenLatLon[0], origenLatLon[1], paradaInicioBus.Latitud, paradaInicioBus.Longitud); + alternativa.TiempoPieInicio = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(alternativa.DistanciaPieInicioAprox, parametros); + total += alternativa.TiempoPieInicio; + cursor = UtilidadesTransitoDbus.AddSecondsSafe(cursor, alternativa.TiempoPieInicio); + + alternativa.DistanciaPieTransbordosAprox = 0; + + var paradaUltimaBajada = paradaInicioBus; + var puntoUltimaLlegada = new[] { paradaInicioBus.Latitud, paradaInicioBus.Longitud }; + for (var indice = 0; indice < tramosBus.Count; indice++) + { + ct.ThrowIfCancellationRequested(); + + if (indice == tramosBus.Count - 1) + { + await RefinarParadaFinAsync(destinoLatLon, tramosBus, parametros, cursor, ct); + lineasBus = ConstruirLineasBusPlanificacionCached(tramosBus, out distanciaBus); + alternativa.AlternativaBus.DistanciaTotalBus = distanciaBus; + } + + var tramo = tramosBus[indice]; + var esTransbordoReal = indice > 0 && UtilidadesTransitoDbus.EsTransbordoReal(tramosBus[indice - 1], tramo); + var lineaTramo = lineasBus[indice]; + var puntoInicioTramo = ObtenerPrimerPunto(lineaTramo); + var puntoFinTramo = ObtenerUltimoPunto(lineaTramo); + var paradaSubida = ResolverParadaFisica( + tramo.CodigoParadaInicio, + puntoInicioTramo, + indice == 0 ? paradaInicioBus : paradaUltimaBajada); + var paradaBajada = ResolverParadaFisica( + tramo.CodigoParadaFin, + puntoFinTramo, + indice == tramosBus.Count - 1 ? paradaFinBus : paradaUltimaBajada); + + var distanciaUnion = CalcularDistanciaEntrePuntos(puntoUltimaLlegada, puntoInicioTramo); + if (indice > 0 && + esTransbordoReal && + distanciaUnion > UmbralUnionPeatonalAutomaticaMetros) + { + return double.PositiveInfinity; + } + + if ((indice == 0 || esTransbordoReal) && + distanciaUnion > UmbralCosturaVisualMetros && + distanciaUnion <= UmbralUnionPeatonalAutomaticaMetros) + { + var transferencia = await WalkOsrmCached( + puntoUltimaLlegada[0], + puntoUltimaLlegada[1], + puntoInicioTramo![0], + puntoInicioTramo[1]); + + var distanciaTransferencia = transferencia.distancia > 0 + ? transferencia.distancia + : distanciaUnion; + + if (indice > 0 && + esTransbordoReal && + distanciaTransferencia > UmbralUnionPeatonalAutomaticaMetros) + { + return double.PositiveInfinity; + } + + if (indice == 0) + { + alternativa.DistanciaPieInicioAprox += distanciaTransferencia; + alternativa.TiempoPieInicio += UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaTransferencia, parametros); + } + else + { + alternativa.DistanciaPieTransbordosAprox += distanciaTransferencia; + } + + var duracionTransferencia = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaTransferencia, parametros); + total += duracionTransferencia; + cursor = UtilidadesTransitoDbus.AddSecondsSafe(cursor, duracionTransferencia); + } + + var resueltoConHorario = false; + if (parametros.UsarHorariosReales) + { + var primeraSalidaAceptable = esTransbordoReal + ? cursor.AddSeconds(Math.Clamp(parametros.MargenTransbordoSegundos, 0, 1800)) + : cursor; + + if (ResolverSegmentoHorario( + tramo.CodigoParadaInicio, + tramo.CodigoParadaFin, + UtilidadesTransitoDbus.LineaCodigoStr(tramo.Variante), + tramo.Variante.CodigoItinerario, + UtilidadesTransitoDbus.NormalizarSentidoCodigo(tramo.Variante.Sentido?.Codigo), + primeraSalidaAceptable, + out var salidaReal, + out var llegadaReal)) + { + var espera = Math.Max(0, (salidaReal - cursor).TotalSeconds); + if (espera > 0) + { + if (indice == 0 || !esTransbordoReal) + { + alternativa.TiempoEsperaInicialSegundos += espera; + if (parametros.MaxEsperaInicialMinutos > 0 && + indice == 0 && + espera > parametros.MaxEsperaInicialMinutos * 60.0) + { + return double.PositiveInfinity; + } + } + else + { + alternativa.TiempoEsperaTransbordosSegundos += espera; + if (parametros.MaxEsperaTransbordoMinutos > 0 && + espera > parametros.MaxEsperaTransbordoMinutos * 60.0) + { + return double.PositiveInfinity; + } + } + + if (parametros.MaxEsperaTotalMinutos > 0 && + alternativa.TiempoEsperaTotalSegundos > parametros.MaxEsperaTotalMinutos * 60.0) + { + return double.PositiveInfinity; + } + + total += espera; + cursor = salidaReal; + } + + var duracionBusReal = Math.Max(0, (llegadaReal - salidaReal).TotalSeconds); + total += duracionBusReal; + cursor = llegadaReal; + resueltoConHorario = true; + } + else + { + alternativa.HorarioCompletoDisponible = false; + if (parametros.DescartarAlternativasSinHorario) + return double.PositiveInfinity; + } + } + + if (!resueltoConHorario) + { + var linea = lineasBus[indice]; + var distanciaTramo = 0.0; + for (var p = 1; p < linea.GetLength(0); p++) + { + distanciaTramo += UtilidadesTransitoDbus.CalcularDistanciaMetros( + linea[p - 1, 0], + linea[p - 1, 1], + linea[p, 0], + linea[p, 1]); + } + + var paradasTramo = Math.Max(0, tramo.IndiceFin - tramo.IndiceInicio); + var duracionBusAprox = UtilidadesTransitoDbus.EstimarDuracionBusSegundos(distanciaTramo, parametros) + + (paradasTramo * Math.Max(0, parametros.SegundosPorParadaBus)); + + total += duracionBusAprox; + cursor = UtilidadesTransitoDbus.AddSecondsSafe(cursor, duracionBusAprox); + } + + paradaUltimaBajada = paradaBajada; + puntoUltimaLlegada = puntoFinTramo ?? new[] { paradaBajada.Latitud, paradaBajada.Longitud }; + } + + paradaFinBus = ResolverParadaFisica( + tramosBus[^1].CodigoParadaFin, + ObtenerUltimoPunto(lineasBus[^1]), + paradaFinBus); + alternativa.DestinoParada = paradaFinBus; + + var salidaFinal = await WalkOsrmCached(puntoUltimaLlegada[0], puntoUltimaLlegada[1], destinoLatLon[0], destinoLatLon[1]); + alternativa.DistanciaPieFinAprox = salidaFinal.distancia > 0 + ? salidaFinal.distancia + : UtilidadesTransitoDbus.CalcularDistanciaMetros(puntoUltimaLlegada[0], puntoUltimaLlegada[1], destinoLatLon[0], destinoLatLon[1]); + alternativa.TiempoPieFin = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(alternativa.DistanciaPieFinAprox, parametros); + total += alternativa.TiempoPieFin; + + if (!parametros.UsarHorariosReales || !alternativa.HorarioCompletoDisponible) + total += alternativa.NumeroTransbordosReales * (parametros.MinutosPorTransbordo * 60.0); + + return total; + } + + /// + /// Evita empezar una circular yendo hacia atras si se puede coger directamente el tramo siguiente. + /// + private async Task RecortarInicioCircularRedundanteAsync( + double[] origenLatLon, + AlternativaRuta alternativa, + ParametrosPlanificadorRuta parametros, + DateTime instanteSalida, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + if (!parametros.UsarHorariosReales || alternativa.AlternativaBus is null) + return; + + var tramosBus = alternativa.AlternativaBus.Tramos; + if (tramosBus.Count < 2) + return; + + var primero = tramosBus[0]; + var segundo = tramosBus[1]; + if (!primero.EsValido || + !segundo.EsValido || + !EsContinuidadCircular(primero.Variante, segundo.Variante) || + !string.Equals(primero.CodigoParadaFin, segundo.CodigoParadaInicio, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var paradaPrimera = ResolverParadaFisica(primero.CodigoParadaInicio); + var paradaDirecta = ResolverParadaFisica(segundo.CodigoParadaInicio); + if (string.IsNullOrWhiteSpace(paradaPrimera.Codigo) || string.IsNullOrWhiteSpace(paradaDirecta.Codigo)) + return; + + var accesoPrimero = await WalkOsrmCached(origenLatLon[0], origenLatLon[1], paradaPrimera.Latitud, paradaPrimera.Longitud); + var distanciaPrimero = accesoPrimero.distancia > 0 + ? accesoPrimero.distancia + : UtilidadesTransitoDbus.CalcularDistanciaMetros(origenLatLon[0], origenLatLon[1], paradaPrimera.Latitud, paradaPrimera.Longitud); + + var accesoDirecto = await WalkOsrmCached(origenLatLon[0], origenLatLon[1], paradaDirecta.Latitud, paradaDirecta.Longitud); + var distanciaDirecta = accesoDirecto.distancia > 0 + ? accesoDirecto.distancia + : UtilidadesTransitoDbus.CalcularDistanciaMetros(origenLatLon[0], origenLatLon[1], paradaDirecta.Latitud, paradaDirecta.Longitud); + + if (distanciaDirecta > distanciaPrimero + MaxExcesoAccesoDirectoCircularMetros) + return; + + var llegadaPrimera = UtilidadesTransitoDbus.AddSecondsSafe( + instanteSalida, + UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaPrimero, parametros)); + if (!ResolverSegmentoHorario( + primero.CodigoParadaInicio, + primero.CodigoParadaFin, + UtilidadesTransitoDbus.LineaCodigoStr(primero.Variante), + primero.Variante.CodigoItinerario, + UtilidadesTransitoDbus.NormalizarSentidoCodigo(primero.Variante.Sentido?.Codigo), + llegadaPrimera, + out _, + out var llegadaIntermedia)) + { + return; + } + + if (!ResolverSegmentoHorario( + segundo.CodigoParadaInicio, + segundo.CodigoParadaFin, + UtilidadesTransitoDbus.LineaCodigoStr(segundo.Variante), + segundo.Variante.CodigoItinerario, + UtilidadesTransitoDbus.NormalizarSentidoCodigo(segundo.Variante.Sentido?.Codigo), + llegadaIntermedia, + out var salidaOriginalSegundo, + out var llegadaOriginalSegundo)) + { + return; + } + + var llegadaDirecta = UtilidadesTransitoDbus.AddSecondsSafe( + instanteSalida, + UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaDirecta, parametros)); + if (!ResolverSegmentoHorario( + segundo.CodigoParadaInicio, + segundo.CodigoParadaFin, + UtilidadesTransitoDbus.LineaCodigoStr(segundo.Variante), + segundo.Variante.CodigoItinerario, + UtilidadesTransitoDbus.NormalizarSentidoCodigo(segundo.Variante.Sentido?.Codigo), + llegadaDirecta, + out var salidaDirectaSegundo, + out var llegadaDirectaSegundo)) + { + return; + } + + if (salidaDirectaSegundo > salidaOriginalSegundo.AddSeconds(ToleranciaMismoBusCircularSegundos) || + llegadaDirectaSegundo > llegadaOriginalSegundo.AddSeconds(ToleranciaMismoBusCircularSegundos)) + { + return; + } + + tramosBus.RemoveAt(0); + alternativa.OrigenParada = paradaDirecta; + alternativa.AlternativaBus.NumeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(tramosBus); + alternativa.AlternativaBus.CodigosParadasTransbordo = ConstruirCodigosTransbordo(tramosBus); + alternativa.NumeroTransbordosReales = alternativa.AlternativaBus.NumeroTransbordos; + } + + /// + /// Refina la parada de subida del primer tramo probando paradas vecinas de la misma variante. + /// + private async Task RefinarParadaInicioAsync( + double[] origenLatLon, + IReadOnlyList tramosBus, + ParametrosPlanificadorRuta parametros, + DateTime instanteSalida, + CancellationToken ct) + { + if (tramosBus.Count == 0) + return; + + var primerTramo = tramosBus[0]; + if (!primerTramo.EsValido) + return; + + var indiceOriginal = primerTramo.IndiceInicio; + var indiceMaximo = Math.Min(primerTramo.IndiceFin - 1, indiceOriginal + VentanaRefinamientoParadaInicio); + if (indiceMaximo <= indiceOriginal) + return; + + var mejorIndice = indiceOriginal; + var mejorScore = double.PositiveInfinity; + var mejorDistancia = double.PositiveInfinity; + + using var semaforo = new SemaphoreSlim(MaxConcurrenciaRefinamientoParadas, MaxConcurrenciaRefinamientoParadas); + var tareas = Enumerable.Range(indiceOriginal, indiceMaximo - indiceOriginal + 1) + .Select(async indice => + { + await semaforo.WaitAsync(ct); + try + { + ct.ThrowIfCancellationRequested(); + var evaluacion = await EvaluarSubidaPrimerTramoAsync( + origenLatLon, + primerTramo, + indice, + parametros, + instanteSalida, + ct); + + return (indice, evaluacion); + } + finally + { + semaforo.Release(); + } + }) + .ToArray(); + + var resultados = await Task.WhenAll(tareas); + var evaluacionOriginal = resultados + .FirstOrDefault(x => x.indice == indiceOriginal) + .evaluacion; + foreach (var (indice, evaluacion) in resultados) + { + if (evaluacion is null) + continue; + + if (indice != indiceOriginal && + evaluacionOriginal is not null && + !MovimientoInicioRefinadoPermitido(evaluacionOriginal, evaluacion)) + { + continue; + } + + if (evaluacion.Score + 1e-6 < mejorScore || + (Math.Abs(evaluacion.Score - mejorScore) <= 1e-6 && evaluacion.DistanciaWalk + 1e-6 < mejorDistancia)) + { + mejorIndice = indice; + mejorScore = evaluacion.Score; + mejorDistancia = evaluacion.DistanciaWalk; + } + } + + primerTramo.IndiceInicio = mejorIndice; + } + + private static bool MovimientoInicioRefinadoPermitido( + EvaluacionRefinamientoParada original, + EvaluacionRefinamientoParada candidata) + { + if (original.DistanciaWalk > UmbralAccesoCercanoRefinamientoInicioMetros) + return true; + + return candidata.DistanciaWalk <= original.DistanciaWalk + MaxIncrementoAccesoCercanoRefinamientoInicioMetros; + } + + /// + /// Refina la parada de bajada del ultimo tramo probando paradas vecinas de la misma variante. + /// + private async Task RefinarParadaFinAsync( + double[] destinoLatLon, + IReadOnlyList tramosBus, + ParametrosPlanificadorRuta parametros, + DateTime instanteDisponible, + CancellationToken ct) + { + if (tramosBus.Count == 0) + return; + + var ultimoTramo = tramosBus[^1]; + if (!ultimoTramo.EsValido) + return; + + var indiceOriginal = ultimoTramo.IndiceFin; + var indiceMinimo = Math.Max(ultimoTramo.IndiceInicio + 1, indiceOriginal - VentanaRefinamientoParadaFin); + var indiceMaximo = Math.Min(ultimoTramo.Variante.CodigosParadas.Count - 1, indiceOriginal + VentanaRefinamientoParadaFin); + if (indiceMaximo < indiceMinimo) + return; + + var mejorIndice = indiceOriginal; + var mejorScore = double.PositiveInfinity; + var mejorDistancia = double.PositiveInfinity; + + using var semaforo = new SemaphoreSlim(MaxConcurrenciaRefinamientoParadas, MaxConcurrenciaRefinamientoParadas); + var tareas = Enumerable.Range(indiceMinimo, indiceMaximo - indiceMinimo + 1) + .Select(async indice => + { + await semaforo.WaitAsync(ct); + try + { + ct.ThrowIfCancellationRequested(); + var evaluacion = await EvaluarBajadaUltimoTramoAsync( + destinoLatLon, + ultimoTramo, + indice, + parametros, + instanteDisponible, + ct); + + return (indice, evaluacion); + } + finally + { + semaforo.Release(); + } + }) + .ToArray(); + + var resultados = await Task.WhenAll(tareas); + foreach (var (indice, evaluacion) in resultados) + { + if (evaluacion is null) + continue; + + if (evaluacion.Score + 1e-6 < mejorScore || + (Math.Abs(evaluacion.Score - mejorScore) <= 1e-6 && evaluacion.DistanciaWalk + 1e-6 < mejorDistancia)) + { + mejorIndice = indice; + mejorScore = evaluacion.Score; + mejorDistancia = evaluacion.DistanciaWalk; + } + } + + ultimoTramo.IndiceFin = mejorIndice; + } + + /// + /// Evalua una parada candidata de subida para el primer tramo. + /// + private async Task EvaluarSubidaPrimerTramoAsync( + double[] origenLatLon, + TramoBus tramo, + int indiceSubida, + ParametrosPlanificadorRuta parametros, + DateTime instanteSalida, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (indiceSubida < 0 || indiceSubida >= tramo.IndiceFin || indiceSubida >= tramo.Variante.CodigosParadas.Count) + return null; + + var codigoParada = tramo.Variante.CodigosParadas[indiceSubida]; + var parada = ResolverParadaFisica(codigoParada); + if (parada is null) + return null; + + var acceso = await WalkOsrmCached(origenLatLon[0], origenLatLon[1], parada.Latitud, parada.Longitud); + var distanciaWalk = acceso.distancia > 0 + ? acceso.distancia + : UtilidadesTransitoDbus.CalcularDistanciaMetros(origenLatLon[0], origenLatLon[1], parada.Latitud, parada.Longitud); + var duracionWalk = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaWalk, parametros); + + double costeTotal; + if (parametros.UsarHorariosReales) + { + var llegadaParada = UtilidadesTransitoDbus.AddSecondsSafe(instanteSalida, duracionWalk); + if (!ResolverSegmentoHorario( + codigoParada, + tramo.CodigoParadaFin, + UtilidadesTransitoDbus.LineaCodigoStr(tramo.Variante), + tramo.Variante.CodigoItinerario, + UtilidadesTransitoDbus.NormalizarSentidoCodigo(tramo.Variante.Sentido?.Codigo), + llegadaParada, + out _, + out var llegadaReal)) + { + return null; + } + + costeTotal = Math.Max(0, (llegadaReal - instanteSalida).TotalSeconds); + } + else + { + var duracionBus = EstimarDuracionBusEntreIndices(tramo, indiceSubida, tramo.IndiceFin, parametros); + costeTotal = duracionWalk + duracionBus; + } + + return new EvaluacionRefinamientoParada( + UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos(costeTotal, parametros, distanciaWalk), + distanciaWalk); + } + + /// + /// Evalua una parada candidata de bajada para el ultimo tramo. + /// + private async Task EvaluarBajadaUltimoTramoAsync( + double[] destinoLatLon, + TramoBus tramo, + int indiceBajada, + ParametrosPlanificadorRuta parametros, + DateTime instanteDisponible, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (indiceBajada <= tramo.IndiceInicio || indiceBajada >= tramo.Variante.CodigosParadas.Count) + return null; + + var codigoParada = tramo.Variante.CodigosParadas[indiceBajada]; + var parada = ResolverParadaFisica(codigoParada); + if (parada is null) + return null; + + var salidaFinal = await WalkOsrmCached(parada.Latitud, parada.Longitud, destinoLatLon[0], destinoLatLon[1]); + var distanciaWalk = salidaFinal.distancia > 0 + ? salidaFinal.distancia + : UtilidadesTransitoDbus.CalcularDistanciaMetros(parada.Latitud, parada.Longitud, destinoLatLon[0], destinoLatLon[1]); + var duracionWalk = UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaWalk, parametros); + + double costeTotal; + if (parametros.UsarHorariosReales) + { + if (!ResolverSegmentoHorario( + tramo.CodigoParadaInicio, + codigoParada, + UtilidadesTransitoDbus.LineaCodigoStr(tramo.Variante), + tramo.Variante.CodigoItinerario, + UtilidadesTransitoDbus.NormalizarSentidoCodigo(tramo.Variante.Sentido?.Codigo), + instanteDisponible, + out _, + out var llegadaReal)) + { + return null; + } + + costeTotal = Math.Max(0, (llegadaReal - instanteDisponible).TotalSeconds) + duracionWalk; + } + else + { + var duracionBus = EstimarDuracionBusEntreIndices(tramo, tramo.IndiceInicio, indiceBajada, parametros); + costeTotal = duracionBus + duracionWalk; + } + + return new EvaluacionRefinamientoParada( + UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos(costeTotal, parametros, distanciaWalk), + distanciaWalk); + } + + private List ConstruirLineasBusPlanificacionCached( + IReadOnlyList tramos, + out double distanciaTotalBus) + { + if (tramos.Count == 0) + { + distanciaTotalBus = 0; + return new List(); + } + + var clave = CrearClaveLineasBus(tramos); + if (_cacheLineasBusPlanificacion.TryGetValue(clave, out var cacheado)) + { + distanciaTotalBus = cacheado.distancia; + return cacheado.lineas; + } + + if (_cacheLineasBusPlanificacion.Count > 10_000) + _cacheLineasBusPlanificacion.Clear(); + + var lineas = _redBusServicio.ConstruirLineasBus(tramos, out distanciaTotalBus); + _cacheLineasBusPlanificacion[clave] = (lineas, distanciaTotalBus); + return lineas; + } + + private static string CrearClaveLineasBus(IReadOnlyList tramos) + { + return string.Join(";", tramos.Select(tramo => + { + var variante = tramo.Variante; + return string.Join(":", + UtilidadesTransitoDbus.LineaCodigoStr(variante), + variante.CodigoItinerario.ToString(CultureInfo.InvariantCulture), + UtilidadesTransitoDbus.NormalizarSentidoCodigo(variante.Sentido?.Codigo), + Convert.ToString(variante.Variante.Codigo, CultureInfo.InvariantCulture) ?? string.Empty, + tramo.IndiceInicio.ToString(CultureInfo.InvariantCulture), + tramo.IndiceFin.ToString(CultureInfo.InvariantCulture)); + })); + } + + /// + /// Estima la duración aproximada de un tramo de bus entre dos indices concretos. + /// + private double EstimarDuracionBusEntreIndices( + TramoBus tramoBase, + int indiceInicio, + int indiceFin, + ParametrosPlanificadorRuta parametros) + { + if (indiceInicio < 0 || indiceFin <= indiceInicio) + return double.PositiveInfinity; + + var clave = BuildBusDurationRefinementKey(tramoBase, indiceInicio, indiceFin, parametros); + if (_cacheDuracionBusRefinamiento.TryGetValue(clave, out var cacheado)) + return cacheado; + + var tramo = new TramoBus + { + Variante = tramoBase.Variante, + IndiceInicio = indiceInicio, + IndiceFin = indiceFin + }; + + var lineas = ConstruirLineasBusPlanificacionCached(new[] { tramo }, out _); + if (lineas.Count == 0) + return double.PositiveInfinity; + + var linea = lineas[0]; + var distancia = 0.0; + for (var punto = 1; punto < linea.GetLength(0); punto++) + { + distancia += UtilidadesTransitoDbus.CalcularDistanciaMetros( + linea[punto - 1, 0], + linea[punto - 1, 1], + linea[punto, 0], + linea[punto, 1]); + } + + var paradasRecorridas = Math.Max(0, indiceFin - indiceInicio); + var duracion = UtilidadesTransitoDbus.EstimarDuracionBusSegundos(distancia, parametros) + + (paradasRecorridas * Math.Max(0, parametros.SegundosPorParadaBus)); + + _cacheDuracionBusRefinamiento[clave] = duracion; + return duracion; + } + + /// + /// Selecciona y etiqueta las alternativas finales. + /// + private static List SeleccionarAlternativasFinales( + IReadOnlyList alternativasBus, + IReadOnlyList alternativasSoloPie, + ParametrosPlanificadorRuta parametros) + { + var finales = new List(6); + var usadas = new HashSet(StringComparer.OrdinalIgnoreCase); + var categoriasPorFirma = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var preferenciaEtiquetaRuta = parametros.PreferenciaEtiquetaRuta; + + var masRapidaBus = ElegirMasRapidaBus(alternativasBus); + var priorizarTiempo = string.Equals(preferenciaEtiquetaRuta, "M\u00e1s r\u00e1pida", StringComparison.OrdinalIgnoreCase); + var modoMenosAndar = string.Equals(preferenciaEtiquetaRuta, "Menos andar", StringComparison.OrdinalIgnoreCase); + var alternativasBusRazonables = FiltrarAlternativasRazonablesFinales(alternativasBus, masRapidaBus); + var alternativasBusParaAndar = modoMenosAndar + ? AmpliarAlternativasPorAhorroAndando(alternativasBus, alternativasBusRazonables) + : alternativasBusRazonables; + var menosTransbordos = alternativasBusRazonables + .OrderBy(x => x.NumeroTransbordosReales) + .ThenBy(x => DuracionVisibleOrdenacion(x)) + .ThenBy(x => x.DistanciaPieTotalAprox) + .ThenBy(x => x.PuntuacionCoste) + .FirstOrDefault(); + var menosAndar = ElegirMenosAndarRazonable( + alternativasBusParaAndar, + modoMenosAndar ? null : masRapidaBus, + parametros); + var priorizarAndar = string.Equals(preferenciaEtiquetaRuta, "Menos andar", StringComparison.OrdinalIgnoreCase); + var modoEquilibrado = string.IsNullOrWhiteSpace(preferenciaEtiquetaRuta); + var equilibrada = modoEquilibrado + ? ElegirEquilibradaRazonable(alternativasBusRazonables, parametros) + : null; + + if (priorizarAndar) + { + AddAlternativaBus(finales, usadas, menosAndar, "Menos andar"); + AddAlternativaBus(finales, usadas, masRapidaBus, "M\u00e1s r\u00e1pida"); + AddAlternativaBus(finales, usadas, menosTransbordos, "Menos transbordos"); + } + else if (modoEquilibrado) + { + AddAlternativaBus(finales, usadas, masRapidaBus, "M\u00e1s r\u00e1pida"); + AddAlternativaBus(finales, usadas, equilibrada, "Equilibrada"); + AddAlternativaBus(finales, usadas, menosAndar, "Menos andar"); + AddAlternativaBus(finales, usadas, menosTransbordos, "Menos transbordos"); + } + else + { + AddAlternativaBus(finales, usadas, masRapidaBus, "M\u00e1s r\u00e1pida"); + AddAlternativaBus(finales, usadas, menosTransbordos, "Menos transbordos"); + AddAlternativaBus(finales, usadas, menosAndar, "Menos andar"); + } + + var candidatasRelleno = priorizarAndar + ? alternativasBusParaAndar.OrderBy(x => CalcularCosteAndarEquilibradoSegundos(x, parametros)).ThenBy(x => x.PuntuacionCoste) + : priorizarTiempo + ? alternativasBusRazonables.OrderBy(x => DuracionVisibleOrdenacion(x)).ThenBy(x => x.DistanciaPieTotalAprox).ThenBy(x => x.PuntuacionCoste) + : alternativasBusRazonables.OrderBy(x => CalcularScoreEquilibrado(x, alternativasBusRazonables, parametros)) + .ThenBy(x => x.PuntuacionCoste); + + foreach (var alternativa in candidatasRelleno) + { + if (finales.Count >= 4) + break; + + AddAlternativaBus(finales, usadas, alternativa, $"Opci\u00f3n {finales.Count + 1}"); + } + + foreach (var alternativaPie in alternativasSoloPie.OrderBy(x => x.PuntuacionCoste)) + { + if (finales.Count >= 6) + break; + + finales.Add(alternativaPie); + } + + var globalMasRapida = finales + .OrderBy(x => DuracionVisibleOrdenacion(x)) + .ThenBy(x => x.PuntuacionCoste) + .FirstOrDefault(); + if (globalMasRapida is not null && globalMasRapida.EsSoloAPie) + globalMasRapida.Etiqueta = "M\u00e1s r\u00e1pida"; + + return finales; + } + + private static AlternativaRuta? ElegirEquilibradaRazonable( + IReadOnlyList alternativasBus, + ParametrosPlanificadorRuta parametros) + { + if (alternativasBus.Count == 0) + return null; + + return alternativasBus + .OrderBy(x => CalcularScoreEquilibrado(x, alternativasBus, parametros)) + .ThenBy(x => CalcularSacrificioMedioEquilibrado(x, alternativasBus, parametros)) + .ThenBy(x => x.NumeroTransbordosReales) + .ThenBy(x => x.TiempoEsperaTotalSegundos) + .ThenBy(x => x.PuntuacionCoste) + .FirstOrDefault(); + } + + private static double CalcularScoreEquilibrado( + AlternativaRuta alternativa, + IReadOnlyList alternativasBus, + ParametrosPlanificadorRuta parametros) + { + var (sacrificioTiempo, sacrificioAndar) = CalcularSacrificiosEquilibrado( + alternativa, + alternativasBus, + parametros); + + return Math.Max(sacrificioTiempo, sacrificioAndar); + } + + private static double CalcularSacrificioMedioEquilibrado( + AlternativaRuta alternativa, + IReadOnlyList alternativasBus, + ParametrosPlanificadorRuta parametros) + { + var (sacrificioTiempo, sacrificioAndar) = CalcularSacrificiosEquilibrado( + alternativa, + alternativasBus, + parametros); + + return (sacrificioTiempo + sacrificioAndar) / 2.0; + } + + private static (double Tiempo, double Andar) CalcularSacrificiosEquilibrado( + AlternativaRuta alternativa, + IReadOnlyList alternativasBus, + ParametrosPlanificadorRuta parametros) + { + var duraciones = alternativasBus + .Select(DuracionVisibleOrdenacion) + .Where(double.IsFinite) + .ToList(); + + var costesAndar = alternativasBus + .Select(x => CalcularCosteAndarEquilibradoSegundos(x, parametros)) + .Where(double.IsFinite) + .ToList(); + + if (duraciones.Count == 0 || costesAndar.Count == 0) + return (0, 0); + + var sacrificioTiempo = CalcularSacrificioNormalizado( + DuracionVisibleOrdenacion(alternativa), + duraciones.Min(), + duraciones.Max()); + + var sacrificioAndar = CalcularSacrificioNormalizado( + CalcularCosteAndarEquilibradoSegundos(alternativa, parametros), + costesAndar.Min(), + costesAndar.Max()); + + return (sacrificioTiempo, sacrificioAndar); + } + + private static double CalcularCosteAndarEquilibradoSegundos( + AlternativaRuta alternativa, + ParametrosPlanificadorRuta parametros) + { + var distanciaCorregida = + UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieInicioAprox, parametros) + + UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieTransbordosAprox, parametros) + + UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieFinAprox, parametros); + + if (!double.IsFinite(distanciaCorregida) || distanciaCorregida < 0) + return double.PositiveInfinity; + + return UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaCorregida, parametros); + } + + private static double CalcularSacrificioNormalizado(double valor, double mejor, double peor) + { + if (!double.IsFinite(valor) || + !double.IsFinite(mejor) || + !double.IsFinite(peor) || + peor <= mejor) + { + return 0; + } + + return Math.Clamp((valor - mejor) / (peor - mejor), 0.0, 1.0); + } + + private static double DuracionVisibleOrdenacion(AlternativaRuta alternativa) + => double.IsFinite(alternativa.DuracionTotalAproxSegundos) + ? alternativa.DuracionTotalAproxSegundos + : double.PositiveInfinity; + + private static AlternativaRuta? ElegirMasRapidaBus(IReadOnlyList alternativasBus) + { + if (alternativasBus.Count == 0) + return null; + + var duracionMinima = alternativasBus.Min(DuracionVisibleOrdenacion); + return alternativasBus + .Where(x => DuracionVisibleOrdenacion(x) <= duracionMinima + ToleranciaEmpateRapidezSegundos) + .OrderBy(x => x.DistanciaPieTotalAprox) + .ThenBy(x => DuracionVisibleOrdenacion(x)) + .ThenBy(x => x.PuntuacionCoste) + .FirstOrDefault(); + } + + private static List FiltrarAlternativasRazonablesFinales( + IReadOnlyList alternativasBus, + AlternativaRuta? masRapidaBus) + { + if (alternativasBus.Count == 0) + return new(); + + if (masRapidaBus is null) + return alternativasBus.ToList(); + + var duracionMinima = DuracionVisibleOrdenacion(masRapidaBus); + var limiteDuracion = CalcularLimiteDuracionRazonable(duracionMinima); + var razonables = alternativasBus + .Where(x => DuracionVisibleOrdenacion(x) <= limiteDuracion) + .ToList(); + + var filtradas = razonables.Count > 0 + ? razonables + : alternativasBus.ToList(); + + return EliminarAlternativasDominadasFinales(filtradas); + } + + private static List AmpliarAlternativasPorAhorroAndando( + IReadOnlyList alternativasBus, + IReadOnlyList alternativasRazonables) + { + if (alternativasBus.Count == 0) + return new(); + + if (alternativasRazonables.Count == 0) + return EliminarAlternativasDominadasFinales(alternativasBus); + + var seleccionadas = new Dictionary(StringComparer.OrdinalIgnoreCase); + Agregar(alternativasRazonables); + + var mejorPieRazonable = alternativasRazonables + .Select(x => x.DistanciaPieTotalAprox) + .Where(double.IsFinite) + .DefaultIfEmpty(double.PositiveInfinity) + .Min(); + if (double.IsFinite(mejorPieRazonable)) + { + var limiteAhorroClaro = Math.Min( + mejorPieRazonable - AhorroClaroAndandoFinalMetros, + mejorPieRazonable * FactorAhorroClaroAndandoFinal); + if (limiteAhorroClaro > 0) + { + Agregar(alternativasBus + .Where(x => x.DistanciaPieTotalAprox <= limiteAhorroClaro) + .OrderBy(x => x.DistanciaPieTotalAprox) + .ThenBy(x => DuracionVisibleOrdenacion(x)) + .ThenBy(x => x.PuntuacionCoste) + .Take(8)); + } + } + + return EliminarAlternativasDominadasFinales(seleccionadas.Values.ToList()); + + void Agregar(IEnumerable alternativas) + { + foreach (var alternativa in alternativas) + { + if (alternativa.AlternativaBus is null) + continue; + + var firma = UtilidadesTransitoDbus.FirmaRutaBus(alternativa.AlternativaBus); + seleccionadas.TryAdd(firma, alternativa); + } + } + } + + private static List EliminarAlternativasDominadasFinales( + IReadOnlyList alternativas) + { + if (alternativas.Count <= 1) + return alternativas.ToList(); + + var resultado = new List(alternativas.Count); + foreach (var candidata in alternativas) + { + if (!EstaDominadaPorOtraAlternativa(candidata, alternativas)) + resultado.Add(candidata); + } + + return resultado.Count > 0 + ? resultado + : alternativas.ToList(); + } + + private static bool EstaDominadaPorOtraAlternativa( + AlternativaRuta candidata, + IReadOnlyList alternativas) + { + var duracionCandidata = DuracionVisibleOrdenacion(candidata); + var pieCandidata = candidata.DistanciaPieTotalAprox; + + foreach (var otra in alternativas) + { + if (ReferenceEquals(otra, candidata)) + continue; + + if (otra.NumeroTransbordosReales > candidata.NumeroTransbordosReales) + continue; + + var duracionOtra = DuracionVisibleOrdenacion(otra); + var pieOtra = otra.DistanciaPieTotalAprox; + var noPierdeTiempo = duracionOtra <= duracionCandidata + ToleranciaDominanciaFinalSegundos; + var noPierdeAndando = pieOtra <= pieCandidata + ToleranciaDominanciaFinalMetros; + if (!noPierdeTiempo || !noPierdeAndando) + continue; + + var mejoraTransbordo = otra.NumeroTransbordosReales < candidata.NumeroTransbordosReales; + var mejoraTiempo = duracionOtra <= duracionCandidata - MejoraClaraDominanciaFinalSegundos; + var mejoraAndando = pieOtra <= pieCandidata - MejoraClaraDominanciaFinalMetros; + if (mejoraTransbordo || mejoraTiempo || mejoraAndando) + return true; + } + + return false; + } + + private static AlternativaRuta? ElegirMenosAndarRazonable( + IReadOnlyList alternativasBus, + AlternativaRuta? masRapidaBus, + ParametrosPlanificadorRuta parametros) + { + if (alternativasBus.Count == 0) + return null; + + if (masRapidaBus is null) + { + return alternativasBus + .OrderBy(x => CalcularCosteAndarEquilibradoSegundos(x, parametros)) + .ThenBy(x => DuracionVisibleOrdenacion(x)) + .ThenBy(x => x.PuntuacionCoste) + .FirstOrDefault(); + } + + var duracionMinima = DuracionVisibleOrdenacion(masRapidaBus); + var limiteDuracion = CalcularLimiteDuracionRazonable(duracionMinima); + var candidatasRazonables = alternativasBus + .Where(x => DuracionVisibleOrdenacion(x) <= limiteDuracion) + .ToList(); + + if (candidatasRazonables.Count == 0) + candidatasRazonables = alternativasBus.ToList(); + + return candidatasRazonables + .OrderBy(x => CalcularCosteAndarEquilibradoSegundos(x, parametros)) + .ThenBy(x => DuracionVisibleOrdenacion(x)) + .ThenBy(x => x.PuntuacionCoste) + .FirstOrDefault(); + } + + private static double CalcularLimiteDuracionRazonable(double duracionMinima) + { + if (!double.IsFinite(duracionMinima)) + return double.PositiveInfinity; + + var margenRazonable = Math.Max(15 * 60.0, duracionMinima * 0.5); + return duracionMinima + margenRazonable; + } + + /// + /// Calcula el coste interno de una alternativa aplicando penalizacion progresiva a pie. + /// + private static double CalcularPuntuacionCoste(AlternativaRuta alternativa, ParametrosPlanificadorRuta parametros) + { + return UtilidadesTransitoDbus.CalcularCosteConPenalizacionesRankingSegundos( + alternativa.DuracionTotalAproxSegundos, + parametros, + alternativa.NumeroTransbordosReales, + alternativa.DistanciaPieInicioAprox, + alternativa.DistanciaPieTransbordosAprox, + alternativa.DistanciaPieFinAprox); + } + + /// + /// Intenta añadir una alternativa de bus a la lista final. + /// + private static void AddAlternativaBus( + ICollection finales, + ISet usadas, + AlternativaRuta? alternativa, + string etiqueta) + { + if (alternativa is null || alternativa.AlternativaBus is null) + return; + + var firma = CrearFirmaFamiliaRutaBus(alternativa.AlternativaBus); + if (!usadas.Add(firma)) + return; + + alternativa.Etiqueta = etiqueta; + finales.Add(alternativa); + } + + /// + /// Firma orientada a UI: agrupa alternativas que usan la misma familia de lineas aunque cambie la parada exacta. + /// + private static string CrearFirmaFamiliaRutaBus(RutaBusAlternativa alternativa) + { + var partes = new List(); + foreach (var tramo in alternativa.Tramos) + { + var linea = UtilidadesTransitoDbus.LineaCodigoStr(tramo.Variante); + var parte = $"{linea}.{tramo.Variante.CodigoItinerario}"; + if (partes.Count == 0 || !string.Equals(partes[^1], parte, StringComparison.OrdinalIgnoreCase)) + partes.Add(parte); + } + + return string.Join(">", partes); + } + + /// + /// Clona una ruta de bus para no compartir instancias mutables. + /// + private static RutaBusAlternativa ClonarRutaBus(RutaBusAlternativa rutaBus) + { + return new RutaBusAlternativa + { + Tramos = rutaBus.Tramos.Select(tramo => new TramoBus + { + Variante = tramo.Variante, + IndiceInicio = tramo.IndiceInicio, + IndiceFin = tramo.IndiceFin + }).ToList(), + NumeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(rutaBus.Tramos), + DistanciaTotalBus = rutaBus.DistanciaTotalBus, + CodigosParadasTransbordo = rutaBus.CodigosParadasTransbordo.ToList() + }; + } + + /// + /// Resuelve la parada fisica mas coherente para un codigo y una referencia espacial. + /// + private Parada ResolverParadaFisica(string? codigoParada, double[]? puntoReferencia = null, Parada? paradaPreferida = null) + { + if (!string.IsNullOrWhiteSpace(codigoParada)) + { + var candidatas = _redBusServicio.Paradas + .Where(parada => parada.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (candidatas.Count == 1) + return candidatas[0]; + + if (candidatas.Count > 1) + { + if (paradaPreferida is not null && + paradaPreferida.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase)) + { + var coincidenciaExacta = candidatas.FirstOrDefault(parada => + Math.Abs(parada.Latitud - paradaPreferida.Latitud) < 1e-9 && + Math.Abs(parada.Longitud - paradaPreferida.Longitud) < 1e-9); + + if (coincidenciaExacta is not null) + return coincidenciaExacta; + } + + if (puntoReferencia is not null) + { + return candidatas + .OrderBy(parada => UtilidadesTransitoDbus.CalcularDistanciaMetros( + parada.Latitud, + parada.Longitud, + puntoReferencia[0], + puntoReferencia[1])) + .First(); + } + + if (paradaPreferida is not null && + paradaPreferida.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase)) + { + return paradaPreferida; + } + + return candidatas[0]; + } + } + + return paradaPreferida ?? new Parada(); + } + + /// + /// Devuelve el primer punto de una linea. + /// + private static double[]? ObtenerPrimerPunto(double[,]? linea) + { + if (linea is null || linea.GetLength(0) == 0) + return null; + + return new[] { linea[0, 0], linea[0, 1] }; + } + + /// + /// Devuelve el ultimo punto de una linea. + /// + private static double[]? ObtenerUltimoPunto(double[,]? linea) + { + if (linea is null || linea.GetLength(0) == 0) + return null; + + var ultimo = linea.GetLength(0) - 1; + return new[] { linea[ultimo, 0], linea[ultimo, 1] }; + } + + /// + /// Calcula la distancia entre dos puntos geograficos. + /// + private static double CalcularDistanciaEntrePuntos(double[]? origen, double[]? destino) + { + if (origen is null || destino is null || origen.Length < 2 || destino.Length < 2) + return 0; + + return UtilidadesTransitoDbus.CalcularDistanciaMetros(origen[0], origen[1], destino[0], destino[1]); + } + + /// + /// Comprueba si un patron pasa el filtro del usuario. + /// + private static bool PatronPermitido(PatronTransitoDbus patron, ParametrosPlanificadorRuta parametros) + { + if (parametros.ActivarRangoLineas) + { + if (patron.NumeroLinea is null) + return false; + + if (parametros.LineasPermitidas.Count == 0 || + !parametros.LineasPermitidas.Contains(patron.NumeroLinea.Value)) + { + return false; + } + } + + if (parametros.ActivarRangoItinerarios) + { + var minimoItinerario = Math.Min(parametros.ItinerarioMin, parametros.ItinerarioMax); + var maximoItinerario = Math.Max(parametros.ItinerarioMin, parametros.ItinerarioMax); + if (patron.CodigoItinerario < minimoItinerario || patron.CodigoItinerario > maximoItinerario) + return false; + } + + return true; + } + + /// + /// Devuelve una caminata desde OSRM con cache. + /// + private async Task<(double[,]? linea, double distancia, double duracion)> WalkOsrmCached( + double latitudOrigen, + double longitudOrigen, + double latitudDestino, + double longitudDestino) + { + var clave = BuildWalkKey("osrm", latitudOrigen, longitudOrigen, latitudDestino, longitudDestino); + if (_cacheCaminatas.TryGetValue(clave, out var cacheado)) + return cacheado; + + try + { + var (linea, distancia, duracion) = await _clienteOsrm.ObtenerRutaAPieAsync( + latitudOrigen, + longitudOrigen, + latitudDestino, + longitudDestino); + + var valor = (linea, distancia, duracion); + _cacheCaminatas[clave] = valor; + return valor; + } + catch + { + var vacio = (linea: (double[,]?)null, distancia: 0.0, duracion: 0.0); + _cacheCaminatas[clave] = vacio; + return vacio; + } + } + + /// + /// Devuelve una caminata desde Valhalla con cache. + /// + private async Task<(double[,]? linea, double distancia, double duracion)> WalkValhallaCached( + double latitudOrigen, + double longitudOrigen, + double latitudDestino, + double longitudDestino) + { + var clave = BuildWalkKey("valhalla", latitudOrigen, longitudOrigen, latitudDestino, longitudDestino); + if (_cacheCaminatas.TryGetValue(clave, out var cacheado)) + return cacheado; + + try + { + var (linea, distancia, duracion) = await _clienteValhalla.ObtenerRutaAsync( + latitudOrigen, + longitudOrigen, + latitudDestino, + longitudDestino, + costing: "pedestrian"); + + var valor = (linea, distancia, duracion); + _cacheCaminatas[clave] = valor; + return valor; + } + catch + { + var vacio = (linea: (double[,]?)null, distancia: 0.0, duracion: 0.0); + _cacheCaminatas[clave] = vacio; + return vacio; + } + } + + /// + /// Construye una clave estable de cache para caminatas. + /// + private static string BuildWalkKey( + string proveedor, + double latitudOrigen, + double longitudOrigen, + double latitudDestino, + double longitudDestino) + { + static string Q(double valor) => Math.Round(valor, 5).ToString("0.00000", CultureInfo.InvariantCulture); + return $"{proveedor}|{Q(latitudOrigen)},{Q(longitudOrigen)}|{Q(latitudDestino)},{Q(longitudDestino)}"; + } + + /// + /// Construye una clave estable para cachear duraciones aproximadas de bus durante el refinamiento. + /// + private static string BuildBusDurationRefinementKey( + TramoBus tramoBase, + int indiceInicio, + int indiceFin, + ParametrosPlanificadorRuta parametros) + { + static string Q(double valor) => Math.Round(valor, 3).ToString("0.000", CultureInfo.InvariantCulture); + + return string.Join("|", + UtilidadesTransitoDbus.LineaCodigoStr(tramoBase.Variante), + tramoBase.Variante.CodigoItinerario.ToString(CultureInfo.InvariantCulture), + UtilidadesTransitoDbus.NormalizarSentidoCodigo(tramoBase.Variante.Sentido?.Codigo), + tramoBase.Variante.Variante.Codigo.ToString(CultureInfo.InvariantCulture), + indiceInicio.ToString(CultureInfo.InvariantCulture), + indiceFin.ToString(CultureInfo.InvariantCulture), + Q(parametros.VelocidadBusKmH), + parametros.SegundosPorParadaBus.ToString(CultureInfo.InvariantCulture)); + } + + /// + /// Resultado mínimo de una evaluación local de parada. + /// + private sealed record SegundaPiernaTransbordoRobusta( + PatronTransitoDbus PrimerPatron, + int IndiceSubidaPrimerPatron, + int IndiceFinPrimerPatron, + PatronTransitoDbus? PatronContinuacion, + int IndiceInicioContinuacion, + int IndiceDestinoContinuacion, + ParadaCandidata Destino) + { + public int NumeroTramos => PatronContinuacion is null ? 1 : 2; + } + + private sealed record EvaluacionRefinamientoParada(double Score, double DistanciaWalk); +} diff --git a/RutasDBUS/Servicios/Planificacion/ServicioIndiceTransitoDbus.cs b/RutasDBUS/Servicios/Planificacion/ServicioIndiceTransitoDbus.cs new file mode 100644 index 0000000..c917335 --- /dev/null +++ b/RutasDBUS/Servicios/Planificacion/ServicioIndiceTransitoDbus.cs @@ -0,0 +1,56 @@ +using RutasDBUS.Servicios.Importacion; +using RutasDBUS.Servicios.RedBus; + +namespace RutasDBUS.Servicios.Planificacion; + +/// +/// Proporciona un indice temporal compartido y cacheado para toda la app. +/// +public sealed class ServicioIndiceTransitoDbus +{ + private readonly IRedBusServicio _redBusServicio; + private readonly IServicioArchivoImportacionDbus _servicioArchivoImportacion; + private readonly CreadorIndiceTransitoDbus _creadorIndice; + private readonly SemaphoreSlim _semaforo = new(1, 1); + private IndiceTransitoDbus? _indice; + private object? _archivoCargado; + + /// + /// Inicializa una nueva instancia del servicio. + /// + public ServicioIndiceTransitoDbus( + IRedBusServicio redBusServicio, + IServicioArchivoImportacionDbus servicioArchivoImportacion, + CreadorIndiceTransitoDbus creadorIndice) + { + _redBusServicio = redBusServicio; + _servicioArchivoImportacion = servicioArchivoImportacion; + _creadorIndice = creadorIndice; + } + + /// + /// Devuelve el indice temporal, construyendolo una sola vez. + /// + public async Task ObtenerAsync(CancellationToken cancellationToken = default) + { + var archivo = await _servicioArchivoImportacion.ObtenerAsync(cancellationToken); + if (_indice is not null && ReferenceEquals(_archivoCargado, archivo)) + return _indice; + + await _semaforo.WaitAsync(cancellationToken); + try + { + if (_indice is not null && ReferenceEquals(_archivoCargado, archivo)) + return _indice; + + await _redBusServicio.CargarRedAsync(cancellationToken); + _indice = _creadorIndice.Crear(_redBusServicio, archivo); + _archivoCargado = archivo; + return _indice; + } + finally + { + _semaforo.Release(); + } + } +} diff --git a/RutasDBUS/Servicios/Planificacion/UtilidadesTransitoDbus.cs b/RutasDBUS/Servicios/Planificacion/UtilidadesTransitoDbus.cs new file mode 100644 index 0000000..f48729c --- /dev/null +++ b/RutasDBUS/Servicios/Planificacion/UtilidadesTransitoDbus.cs @@ -0,0 +1,405 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using RutasDBUS.Modelos.Planificacion; +using RutasDBUS.Modelos.RedBus; + +namespace RutasDBUS.Servicios.Planificacion; + +/// +/// Agrupa utilidades comunes del motor de planificacion. +/// +internal static class UtilidadesTransitoDbus +{ + /// + /// Quita prefijos y decimales de un codigo numerico. + /// + public static string CodigoSinDecimal(string? codigo) + { + if (string.IsNullOrWhiteSpace(codigo)) + return ""; + + codigo = codigo.Trim(); + if (codigo.StartsWith("P-", StringComparison.OrdinalIgnoreCase)) + codigo = codigo[2..]; + + var index = codigo.IndexOf('.'); + if (index > 0) + codigo = codigo[..index]; + + return codigo.Trim(); + } + + /// + /// Normaliza el codigo de parada al formato usado por la app. + /// + public static string NormalizarCodigoParada(string? codigo) + => CodigoSinDecimal(codigo); + + /// + /// Convierte la direccion del CSV a un sentido normalizado. + /// + public static string DireccionZipASentido(int direccion) + => direccion switch + { + 1 => "I", + 2 => "V", + 0 => "I", + _ => "?" + }; + + /// + /// Normaliza un codigo de sentido. + /// + public static string NormalizarSentidoCodigo(string? codigo) + { + if (string.IsNullOrWhiteSpace(codigo)) + return "?"; + + var texto = codigo.Trim(); + if (texto.Equals("I", StringComparison.OrdinalIgnoreCase) || + texto.Equals("Ida", StringComparison.OrdinalIgnoreCase)) + { + return "I"; + } + + if (texto.Equals("V", StringComparison.OrdinalIgnoreCase) || + texto.Equals("Vuelta", StringComparison.OrdinalIgnoreCase)) + { + return "V"; + } + + return char.ToUpperInvariant(texto[0]) switch + { + 'I' => "I", + 'V' => "V", + _ => "?" + }; + } + + /// + /// Extrae el codigo de itinerario a partir de la ruta y del patron. + /// + public static int ExtraerCodigoItinerario(string? idRuta, string? idPatronExp) + { + if (string.IsNullOrWhiteSpace(idPatronExp)) + return 0; + + var patron = idPatronExp.Trim(); + var ruta = CodigoSinDecimal(idRuta); + + if (!string.IsNullOrWhiteSpace(ruta) && + patron.StartsWith(ruta, StringComparison.OrdinalIgnoreCase)) + { + patron = patron[ruta.Length..]; + } + + patron = patron.Trim(); + if (patron.Length >= 4) + { + var sinDir = patron[..^2].TrimStart('0'); + if (int.TryParse(sinDir, NumberStyles.Integer, CultureInfo.InvariantCulture, out var directo)) + return directo; + } + + var match = Regex.Match(patron, @"(\d{1,3})"); + return match.Success && + int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var valor) + ? valor + : 0; + } + + /// + /// Extrae el codigo de variante desde el identificador del patron. + /// + public static int ExtraerCodigoVariante(string? idPatronExp) + { + if (string.IsNullOrWhiteSpace(idPatronExp)) + return 0; + + var texto = idPatronExp.Trim(); + if (int.TryParse(texto, NumberStyles.Integer, CultureInfo.InvariantCulture, out var directo)) + return directo; + + var match = Regex.Match(texto, @"(\d+)$"); + return match.Success && + int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var valor) + ? valor + : 0; + } + + /// + /// Resuelve el instante efectivo de salida. + /// + public static DateTime ResolverInstanteSalida(ParametrosPlanificadorRuta parametros) + { + var fecha = parametros.FechaSalida ?? DateOnly.FromDateTime(DateTime.Now); + var hora = parametros.HoraSalida ?? TimeOnly.FromDateTime(DateTime.Now); + return fecha.ToDateTime(hora); + } + + /// + /// Suma segundos evitando cambios inutiles cuando la duracion es cero. + /// + public static DateTime AddSecondsSafe(DateTime fecha, double segundos) + => segundos <= 0 ? fecha : fecha.AddSeconds(segundos); + + /// + /// Obtiene el codigo textual de una linea. + /// + public static string LineaCodigoStr(InfoVariante variante) + => variante.Linea.Codigo?.ToString() ?? "?"; + + /// + /// Indica si pasar de un tramo al siguiente exige cambiar realmente de bus. + /// + public static bool EsTransbordoReal(TramoBus? anterior, TramoBus? siguiente) + { + if (anterior?.Variante is null || siguiente?.Variante is null) + return false; + + return EsTransbordoReal(anterior.Variante, siguiente.Variante); + } + + public static bool EsTransbordoReal(InfoVariante? anterior, InfoVariante? siguiente) + { + if (anterior is null || siguiente is null) + return false; + + return !string.Equals( + LineaCodigoStr(anterior), + LineaCodigoStr(siguiente), + StringComparison.OrdinalIgnoreCase) || + anterior.CodigoItinerario != siguiente.CodigoItinerario; + } + + /// + /// Cuenta solo los cambios que obligan a bajarse y subir a otro bus. + /// + public static int ContarTransbordosReales(IReadOnlyList? tramos) + { + if (tramos is null || tramos.Count <= 1) + return 0; + + var total = 0; + for (var indice = 1; indice < tramos.Count; indice++) + { + if (EsTransbordoReal(tramos[indice - 1], tramos[indice])) + total++; + } + + return total; + } + + /// + /// Detecta rutas que encadenan demasiadas continuidades de la misma linea sin transbordo real. + /// + public static bool TieneContinuidadesNoRealesConsecutivasExcesivas( + IReadOnlyList? tramos, + int maxContinuidadesConsecutivas) + { + if (tramos is null || tramos.Count <= 1) + return false; + + var maximo = Math.Max(0, maxContinuidadesConsecutivas); + var consecutivas = 0; + for (var indice = 1; indice < tramos.Count; indice++) + { + if (EsTransbordoReal(tramos[indice - 1], tramos[indice])) + { + consecutivas = 0; + continue; + } + + consecutivas++; + if (consecutivas > maximo) + return true; + } + + return false; + } + + /// + /// Extrae el numero de linea desde codigos numericos o alfanumericos como TB6. + /// + public static int? ExtraerNumeroLinea(string? codigoLinea) + { + if (string.IsNullOrWhiteSpace(codigoLinea)) + return null; + + var texto = CodigoSinDecimal(codigoLinea); + if (int.TryParse(texto, NumberStyles.Integer, CultureInfo.InvariantCulture, out var directo)) + return directo; + + var match = Regex.Match(texto, @"(\d+)"); + return match.Success && + int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var valor) + ? valor + : null; + } + + /// + /// Comprueba si un destino descriptivo merece mostrarse tal cual al usuario. + /// + public static bool DestinoDescriptivoEsValido(string? destino) + { + if (string.IsNullOrWhiteSpace(destino)) + return false; + + var texto = destino.Trim(); + return !texto.Equals("N.A.", StringComparison.OrdinalIgnoreCase) && + !texto.Equals("N/A", StringComparison.OrdinalIgnoreCase) && + !texto.Equals("NA", StringComparison.OrdinalIgnoreCase) && + !texto.Equals("-", StringComparison.OrdinalIgnoreCase) && + !texto.Equals("?", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Resuelve un destino visible usando el texto original o, si no sirve, la ultima parada conocida. + /// + public static string ResolverDestinoDescriptivo( + string? destinoOriginal, + IReadOnlyList codigosParada, + IReadOnlyDictionary paradasPorCodigo) + { + if (DestinoDescriptivoEsValido(destinoOriginal)) + return destinoOriginal!.Trim(); + + if (codigosParada.Count > 0 && + paradasPorCodigo.TryGetValue(codigosParada[^1], out var ultimaParada) && + !string.IsNullOrWhiteSpace(ultimaParada.Nombre)) + { + return ultimaParada.Nombre.Trim(); + } + + return ""; + } + + /// + /// Calcula la distancia entre dos coordenadas con Haversine. + /// + public static double CalcularDistanciaMetros(double lat1, double lon1, double lat2, double lon2) + { + const double radioTierra = 6371000.0; + static double ToRad(double valor) => valor * Math.PI / 180.0; + + var dLat = ToRad(lat2 - lat1); + var dLon = ToRad(lon2 - lon1); + + var a = + Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(ToRad(lat1)) * Math.Cos(ToRad(lat2)) * + Math.Sin(dLon / 2) * Math.Sin(dLon / 2); + + var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + return radioTierra * c; + } + + /// + /// Estima la duracion andando. + /// + public static double EstimarDuracionPieSegundos(double distanciaMetros, ParametrosPlanificadorRuta parametros) + { + var velocidad = parametros.VelocidadPieKmH * 1000.0 / 3600.0; + return distanciaMetros / Math.Max(0.1, velocidad); + } + + /// + /// Calcula la distancia peatonal corregida para coste interno sin reducir nunca la distancia real. + /// + public static double CalcularDistanciaPieCorregidaMetros(double distanciaMetros, ParametrosPlanificadorRuta parametros) + { + if (!double.IsFinite(distanciaMetros) || distanciaMetros <= 0) + return 0; + + var factor = double.IsFinite(parametros.FactorPenalizacionDistanciaPie) + ? parametros.FactorPenalizacionDistanciaPie + : 1.6; + var parametroBaseMetros = double.IsFinite(parametros.ParametroPenalizacionDistanciaPieMetros) + ? Math.Abs(parametros.ParametroPenalizacionDistanciaPieMetros) + : 100.0; + + if (parametroBaseMetros <= 0) + parametroBaseMetros = 1.0; + + var corregida = Math.Pow(distanciaMetros / parametroBaseMetros, factor) * parametroBaseMetros; + return Math.Max(distanciaMetros, corregida); + } + + /// + /// Calcula un coste interno que penaliza de forma progresiva la caminata sin cambiar el tiempo real. + /// + public static double CalcularCosteConPenalizacionPieSegundos( + double duracionRealSegundos, + ParametrosPlanificadorRuta parametros, + params double[] distanciasPieMetros) + { + var coste = Math.Max(0, duracionRealSegundos); + + foreach (var distancia in distanciasPieMetros) + { + if (!double.IsFinite(distancia) || distancia <= 0) + continue; + + var distanciaCorregida = CalcularDistanciaPieCorregidaMetros(distancia, parametros); + var excesoDistancia = Math.Max(0, distanciaCorregida - distancia); + coste += EstimarDuracionPieSegundos(excesoDistancia, parametros); + } + + return coste; + } + + /// + /// Calcula la penalizacion interna por transbordos reales sin cambiar el tiempo visible. + /// + public static double CalcularPenalizacionTransbordosRankingSegundos( + int numeroTransbordosReales, + ParametrosPlanificadorRuta parametros) + { + if (numeroTransbordosReales <= 0) + return 0; + + var pesoMinutos = double.IsFinite(parametros.PesoTransbordoRankingMinutos) + ? parametros.PesoTransbordoRankingMinutos + : 4.0; + + if (pesoMinutos <= 0) + return 0; + + return numeroTransbordosReales * pesoMinutos * 60.0; + } + + /// + /// Calcula el coste interno aplicando preferencias de caminata y transbordos reales. + /// + public static double CalcularCosteConPenalizacionesRankingSegundos( + double duracionRealSegundos, + ParametrosPlanificadorRuta parametros, + int numeroTransbordosReales, + params double[] distanciasPieMetros) + { + return CalcularCosteConPenalizacionPieSegundos(duracionRealSegundos, parametros, distanciasPieMetros) + + CalcularPenalizacionTransbordosRankingSegundos(numeroTransbordosReales, parametros); + } + + /// + /// Estima la duracion en bus. + /// + public static double EstimarDuracionBusSegundos(double distanciaMetros, ParametrosPlanificadorRuta parametros) + { + var velocidad = parametros.VelocidadBusKmH * 1000.0 / 3600.0; + return distanciaMetros / Math.Max(0.1, velocidad); + } + + /// + /// Crea una firma estable de una variante. + /// + public static string FirmaVariante(string codigoLinea, int codigoItinerario, string sentido, IReadOnlyList codigosParada) + => $"{codigoLinea}|{codigoItinerario}|{sentido}|{string.Join(">", codigosParada)}"; + + /// + /// Crea una firma estable de una ruta de bus. + /// + public static string FirmaRutaBus(RutaBusAlternativa alternativa) + => string.Join(";", alternativa.Tramos.Select(tramo => + $"{LineaCodigoStr(tramo.Variante)}-{tramo.Variante.CodigoItinerario}-{NormalizarSentidoCodigo(tramo.Variante.Sentido?.Codigo)}-{tramo.Variante.Variante.Codigo}-{tramo.IndiceInicio}-{tramo.IndiceFin}")); +} diff --git a/RutasDBUS/Servicios/RedBus/CreadorRedTransporteDbus.cs b/RutasDBUS/Servicios/RedBus/CreadorRedTransporteDbus.cs new file mode 100644 index 0000000..76c61bf --- /dev/null +++ b/RutasDBUS/Servicios/RedBus/CreadorRedTransporteDbus.cs @@ -0,0 +1,582 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using RutasDBUS.Modelos.Importacion; +using RutasDBUS.Modelos.RedBus; + +namespace RutasDBUS.Servicios.RedBus; + +public sealed class CreadorRedTransporteDbus +{ + /// + /// + /// + public RedTransporteDbus Build(ArchivoImportacionDbus archive) + { + var paradas = new List(archive.Paradas.Count); + var trayectos = new Dictionary(); + var lineas = new List(archive.Rutas.Count); + + var paradaById = new Dictionary(); + + foreach (var row in archive.Paradas) + { + var (lat, lon) = ConvertirCoordenadaZipALatLon(row.LongitudXPar, row.LatitudYPar); + if (!EsLatLonValida(lat, lon)) + continue; + + var codigo = !string.IsNullOrWhiteSpace(row.CodigoSms) + ? NormalizarCodigoParada(row.CodigoSms) + : NormalizarCodigoParada(row.Id.ToString(CultureInfo.InvariantCulture)); + + var parada = new Parada + { + Codigo = codigo, + Nombre = row.NombrePar ?? "", + NombreCorto = !string.IsNullOrWhiteSpace(row.DescripcionPar) ? row.DescripcionPar : (row.NombrePar ?? ""), + Abreviatura = row.DescripcionPar ?? "", + PreZona = "", + PostZona = "", + ZonaSalida = "", + Altura = 0, + Latitud = lat, + Longitud = lon + }; + + paradas.Add(parada); + paradaById[row.Id] = parada; + } + + var lineaByRutaId = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in archive.Rutas) + { + var linea = new LineaBus + { + IdRuta = row.Id ?? "", + Codigo = !string.IsNullOrWhiteSpace(row.Abreviatura) + ? CodigoSinDecimal(row.Abreviatura) + : CodigoSinDecimal(row.Id), + Nombre = !string.IsNullOrWhiteSpace(row.Nombre) ? row.Nombre : (row.Descripcion ?? ""), + ColorArgb = NormalizarColorArgb(row.Color), + CodigoItinerarioPorDefecto = 0 + }; + + lineas.Add(linea); + lineaByRutaId[row.Id ?? ""] = linea; + } + + var stopSequenceByPattern = archive.Expediciones + .Where(exp => !string.IsNullOrWhiteSpace(exp.IdPatronExp)) + .Select(exp => new + { + exp.IdPatronExp, + Sequence = archive.TiemposParadaPorExpedicion.TryGetValue(exp.Id, out var seq) + ? seq.Select(x => x.IdTiempoPar).ToList() + : new List() + }) + .Where(x => x.Sequence.Count >= 2) + .GroupBy(x => x.IdPatronExp, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => g.First().Sequence, + StringComparer.OrdinalIgnoreCase); + + var variantesTemp = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var exp in archive.Expediciones) + { + if (!lineaByRutaId.TryGetValue(exp.IdRuta, out var linea)) + continue; + + if (!stopSequenceByPattern.TryGetValue(exp.IdPatronExp, out var stopIds) || stopIds.Count < 2) + continue; + + var sentido = DireccionZipASentido(exp.Direccion); + var codigoItinerario = ExtraerCodigoItinerario(exp.IdRuta, exp.IdPatronExp); + var codigoVariante = ExtraerCodigoVariante(exp.IdPatronExp); + var key = $"{exp.IdRuta}|{codigoItinerario}|{sentido}|{exp.IdPatronExp}"; + + if (!variantesTemp.ContainsKey(key)) + { + variantesTemp[key] = new VarianteTemporal + { + RutaId = exp.IdRuta, + CodigoItinerario = codigoItinerario, + Sentido = sentido, + CodigoVariante = codigoVariante, + IdPatron = exp.IdPatronExp, + Destino = exp.DirDestino ?? "", + StopIds = stopIds + }; + } + } + + var nextTrayectoId = 1; + foreach (var temp in variantesTemp.Values) + { + if (!lineaByRutaId.TryGetValue(temp.RutaId, out var linea)) + continue; + + var itinerario = linea.Itinerarios.FirstOrDefault(x => x.Codigo == temp.CodigoItinerario); + if (itinerario is null) + { + itinerario = new Itinerario { Codigo = temp.CodigoItinerario }; + linea.Itinerarios.Add(itinerario); + } + + var sentido = itinerario.Sentidos.FirstOrDefault(x => + string.Equals(NormalizarSentidoCodigo(x.Codigo), temp.Sentido, StringComparison.OrdinalIgnoreCase)); + + if (sentido is null) + { + sentido = new Sentido { Codigo = temp.Sentido }; + itinerario.Sentidos.Add(sentido); + } + + var variante = new Variante { Codigo = temp.CodigoVariante }; + var stopIdsValidos = temp.StopIds.Where(paradaById.ContainsKey).ToList(); + var codigosParadas = stopIdsValidos + .Select(id => paradaById[id].Codigo) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToList(); + + if (codigosParadas.Count < 2) + continue; + + var patronLatLon = archive.PatronesPorId.TryGetValue(temp.IdPatron, out var puntosPatron) + ? puntosPatron + .OrderBy(x => x.PtoSecuencia) + .Select(p => + { + var (lat, lon) = ConvertirCoordenadaZipALatLon(p.PtoLongitudX, p.PtoLatitudY); + return new[] { lat, lon }; + }) + .Where(p => EsLatLonValida(p[0], p[1])) + .ToList() + : new List(); + + var stopLatLon = stopIdsValidos + .Select(id => new[] { paradaById[id].Latitud, paradaById[id].Longitud }) + .ToList(); + + var segmentos = ConstruirSegmentosDelPatron(stopLatLon, patronLatLon); + + for (var i = 0; i < codigosParadas.Count - 1; i++) + { + var codigoOrigen = codigosParadas[i]; + var codigoDestino = codigosParadas[i + 1]; + + var puntos = (i < segmentos.Count && segmentos[i].Count >= 2) + ? segmentos[i] + : new List + { + new[] { stopLatLon[i][0], stopLatLon[i][1] }, + new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] } + }; + + trayectos[nextTrayectoId] = new Trayecto + { + Id = nextTrayectoId, + CodigoParadaOrigen = codigoOrigen, + CodigoParadaDestino = codigoDestino, + Posiciones = puntos + }; + + variante.IdsTrayectos.Add(nextTrayectoId); + nextTrayectoId++; + } + + if (variante.IdsTrayectos.Count > 0) + sentido.Variantes.Add(variante); + } + + foreach (var linea in lineas) + { + linea.Itinerarios = linea.Itinerarios + .Where(it => it.Sentidos.Any(s => s.Variantes.Any())) + .OrderBy(it => it.Codigo) + .ToList(); + + foreach (var itinerario in linea.Itinerarios) + { + itinerario.Sentidos = itinerario.Sentidos + .Where(s => s.Variantes.Any()) + .OrderBy(s => NormalizarSentidoCodigo(s.Codigo)) + .ToList(); + } + } + + lineas.RemoveAll(x => x.Itinerarios.Count == 0); + + return new RedTransporteDbus + { + Paradas = paradas, + Trayectos = trayectos, + Lineas = lineas + }; + } + + private sealed class VarianteTemporal + { + public string RutaId { get; set; } = ""; + public int CodigoItinerario { get; set; } + public string Sentido { get; set; } = ""; + public int CodigoVariante { get; set; } + public string IdPatron { get; set; } = ""; + public string Destino { get; set; } = ""; + public List StopIds { get; set; } = new(); + } + + /// + /// Gestiona segmentos del patron. + /// + private static List> ConstruirSegmentosDelPatron( + List stopLatLon, + List patronLatLon) + { + var res = new List>(); + + if (stopLatLon.Count < 2) + return res; + + if (patronLatLon.Count < 2) + { + for (var i = 0; i < stopLatLon.Count - 1; i++) + { + res.Add(new List + { + new[] { stopLatLon[i][0], stopLatLon[i][1] }, + new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] } + }); + } + + return res; + } + + var indices = CalcularIndicesParadasEnPatron(stopLatLon, patronLatLon); + + for (var i = 0; i < indices.Count - 1; i++) + { + var a = indices[i]; + var b = Math.Max(a, indices[i + 1]); + var segmento = new List(); + + for (var j = a; j <= b && j < patronLatLon.Count; j++) + segmento.Add(new[] { patronLatLon[j][0], patronLatLon[j][1] }); + + if (segmento.Count == 0) + { + segmento.Add(new[] { stopLatLon[i][0], stopLatLon[i][1] }); + segmento.Add(new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] }); + } + else if (segmento.Count == 1) + { + segmento.Insert(0, new[] { stopLatLon[i][0], stopLatLon[i][1] }); + segmento.Add(new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] }); + } + else + { + segmento[0] = new[] { stopLatLon[i][0], stopLatLon[i][1] }; + segmento[^1] = new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] }; + } + + res.Add(QuitarDuplicadosConsecutivos(segmento)); + } + + return res; + } + + /// + /// Calcula para cada parada el indice del punto del patron que mejor la representa, + /// forzando una progresion monotona para no perder detalle entre paradas consecutivas. + /// + private static List CalcularIndicesParadasEnPatron( + List stopLatLon, + List patronLatLon) + { + var indices = new List(stopLatLon.Count); + var ultimoIndicePatron = patronLatLon.Count - 1; + + for (var i = 0; i < stopLatLon.Count; i++) + { + var minIdx = indices.Count == 0 + ? 0 + : Math.Min(ultimoIndicePatron, indices[^1] + 1); + + var paradasRestantes = stopLatLon.Count - i - 1; + var maxIdx = Math.Max(minIdx, ultimoIndicePatron - paradasRestantes); + + indices.Add(BuscarIndicePatronMasCercano(stopLatLon[i], patronLatLon, minIdx, maxIdx)); + } + + return indices; + } + + /// + /// Busca el punto del patron mas cercano a una parada dentro de un rango valido. + /// + private static int BuscarIndicePatronMasCercano( + double[] paradaLatLon, + List patronLatLon, + int minIdx, + int maxIdx) + { + var inicio = Math.Clamp(minIdx, 0, patronLatLon.Count - 1); + var fin = Math.Clamp(maxIdx, inicio, patronLatLon.Count - 1); + + var mejorIdx = inicio; + var mejorDist = double.MaxValue; + + for (var j = inicio; j <= fin; j++) + { + var dist = Dist2(paradaLatLon[0], paradaLatLon[1], patronLatLon[j][0], patronLatLon[j][1]); + if (dist < mejorDist) + { + mejorDist = dist; + mejorIdx = j; + } + } + + return mejorIdx; + } + + /// + /// Quita duplicados consecutivos. + /// + private static List QuitarDuplicadosConsecutivos(List puntos) + { + var res = new List(puntos.Count); + foreach (var punto in puntos) + { + if (res.Count == 0) + { + res.Add(punto); + continue; + } + + var ultimo = res[^1]; + if (Math.Abs(ultimo[0] - punto[0]) < 1e-9 && Math.Abs(ultimo[1] - punto[1]) < 1e-9) + continue; + + res.Add(punto); + } + + return res; + } + + /// + /// + /// + private static double Dist2(double lat1, double lon1, double lat2, double lon2) + { + var dLat = lat1 - lat2; + var dLon = (lon1 - lon2) * Math.Cos((lat1 + lat2) / 2 * Math.PI / 180.0); + return dLat * dLat + dLon * dLon; + } + + /// + /// Gestiona lat lon valida. + /// + private static bool EsLatLonValida(double lat, double lon) + => lat >= 42.0 && lat <= 44.5 && lon >= -3.5 && lon <= 0.5; + + private const double OffsetLat = -0.0018141507864726236; + private const double OffsetLon = -0.0013070642408454791; + + /// + /// + /// + private static (double lat, double lon) ConvertirCoordenadaZipALatLon(double x, double y) + { + if (x == 0 || y == 0) + return (0, 0); + + var (lat, lon) = Utm30ToLatLonWgs84(x, y); + return (lat + OffsetLat, lon + OffsetLon); + } + + /// + /// + /// + private static (double lat, double lon) Utm30ToLatLonWgs84(double easting, double northing) + { + const int zone = 30; + const double a = 6378137.0; + const double eccSquared = 0.0066943799901413165; + const double k0 = 0.9996; + + var x = easting - 500000.0; + var y = northing; + var longOrigin = (zone - 1) * 6 - 180 + 3; + var eccPrimeSquared = eccSquared / (1 - eccSquared); + var m = y / k0; + var mu = m / (a * (1 - eccSquared / 4.0 - 3.0 * eccSquared * eccSquared / 64.0 - 5.0 * Math.Pow(eccSquared, 3) / 256.0)); + var e1 = (1.0 - Math.Sqrt(1.0 - eccSquared)) / (1.0 + Math.Sqrt(1.0 - eccSquared)); + + var phi1Rad = + mu + + (3.0 * e1 / 2.0 - 27.0 * Math.Pow(e1, 3) / 32.0) * Math.Sin(2.0 * mu) + + (21.0 * e1 * e1 / 16.0 - 55.0 * Math.Pow(e1, 4) / 32.0) * Math.Sin(4.0 * mu) + + (151.0 * Math.Pow(e1, 3) / 96.0) * Math.Sin(6.0 * mu) + + (1097.0 * Math.Pow(e1, 4) / 512.0) * Math.Sin(8.0 * mu); + + var n1 = a / Math.Sqrt(1.0 - eccSquared * Math.Sin(phi1Rad) * Math.Sin(phi1Rad)); + var t1 = Math.Tan(phi1Rad) * Math.Tan(phi1Rad); + var c1 = eccPrimeSquared * Math.Cos(phi1Rad) * Math.Cos(phi1Rad); + var r1 = a * (1.0 - eccSquared) / Math.Pow(1.0 - eccSquared * Math.Sin(phi1Rad) * Math.Sin(phi1Rad), 1.5); + var d = x / (n1 * k0); + + var latRad = + phi1Rad + - (n1 * Math.Tan(phi1Rad) / r1) * + ( + d * d / 2.0 + - (5.0 + 3.0 * t1 + 10.0 * c1 - 4.0 * c1 * c1 - 9.0 * eccPrimeSquared) * Math.Pow(d, 4) / 24.0 + + (61.0 + 90.0 * t1 + 298.0 * c1 + 45.0 * t1 * t1 - 252.0 * eccPrimeSquared - 3.0 * c1 * c1) * Math.Pow(d, 6) / 720.0 + ); + + var lonRad = + ( + d + - (1.0 + 2.0 * t1 + c1) * Math.Pow(d, 3) / 6.0 + + (5.0 - 2.0 * c1 + 28.0 * t1 - 3.0 * c1 * c1 + 8.0 * eccPrimeSquared + 24.0 * t1 * t1) * Math.Pow(d, 5) / 120.0 + ) / Math.Cos(phi1Rad); + + return (latRad * 180.0 / Math.PI, longOrigin + lonRad * 180.0 / Math.PI); + } + + /// + /// Gestiona sin decimal. + /// + private static string CodigoSinDecimal(string? codigo) + { + if (string.IsNullOrWhiteSpace(codigo)) return ""; + codigo = codigo.Trim(); + + if (codigo.StartsWith("P-", StringComparison.OrdinalIgnoreCase)) + codigo = codigo[2..]; + + var i = codigo.IndexOf('.'); + if (i > 0) + codigo = codigo[..i]; + + return codigo.Trim(); + } + + /// + /// Normaliza codigo parada. + /// + private static string NormalizarCodigoParada(string? codigo) + { + if (string.IsNullOrWhiteSpace(codigo)) return ""; + + codigo = codigo.Trim(); + codigo = codigo.Replace("P-", "", StringComparison.OrdinalIgnoreCase); + + var i = codigo.IndexOf('.'); + if (i > 0) + codigo = codigo[..i]; + + return codigo.Trim(); + } + + /// + /// Normaliza color argb. + /// + private static string NormalizarColorArgb(string? color) + { + if (string.IsNullOrWhiteSpace(color)) + return "FF2563EB"; + + color = color.Trim().TrimStart('#'); + + if (color.Length == 6) + return "FF" + color.ToUpperInvariant(); + + if (color.Length == 8) + return color.ToUpperInvariant(); + + return "FF2563EB"; + } + + /// + /// Gestiona zip a sentido. + /// + private static string DireccionZipASentido(int direccion) => + direccion switch + { + 1 => "I", + 2 => "V", + 0 => "I", + _ => "?" + }; + + /// + /// Normaliza sentido codigo. + /// + private static string NormalizarSentidoCodigo(string? codigo) + { + if (string.IsNullOrWhiteSpace(codigo)) return "?"; + + var s = codigo.Trim(); + if (s.Equals("V", StringComparison.OrdinalIgnoreCase)) return "V"; + if (s.Equals("I", StringComparison.OrdinalIgnoreCase)) return "I"; + if (s.Equals("Vuelta", StringComparison.OrdinalIgnoreCase)) return "V"; + if (s.Equals("Ida", StringComparison.OrdinalIgnoreCase)) return "I"; + + var c = char.ToUpperInvariant(s[0]); + return c switch + { + 'V' => "V", + 'I' => "I", + _ => "?" + }; + } + + /// + /// Gestiona codigo variante. + /// + private static int ExtraerCodigoVariante(string? idPatronExp) + { + if (string.IsNullOrWhiteSpace(idPatronExp)) + return 0; + + var s = idPatronExp.Trim(); + if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) + return n; + + var match = Regex.Match(s, @"(\d+)$"); + return match.Success && int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out n) + ? n + : 0; + } + + /// + /// Gestiona codigo itinerario. + /// + private static int ExtraerCodigoItinerario(string? idRuta, string? idPatronExp) + { + if (string.IsNullOrWhiteSpace(idPatronExp)) + return 0; + + var patron = idPatronExp.Trim(); + var ruta = CodigoSinDecimal(idRuta); + + if (!string.IsNullOrWhiteSpace(ruta) && + patron.StartsWith(ruta, StringComparison.OrdinalIgnoreCase)) + { + patron = patron[ruta.Length..]; + } + + patron = patron.Trim(); + if (patron.Length >= 4) + { + var sinDir = patron[..^2].TrimStart('0'); + if (int.TryParse(sinDir, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + return value; + } + + var match = Regex.Match(patron, @"(\d{1,3})"); + return match.Success && int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var fallback) + ? fallback + : 0; + } +} diff --git a/RutasDBUS/Servicios/RedBus/IRedBusServicio.cs b/RutasDBUS/Servicios/RedBus/IRedBusServicio.cs new file mode 100644 index 0000000..65d3fa3 --- /dev/null +++ b/RutasDBUS/Servicios/RedBus/IRedBusServicio.cs @@ -0,0 +1,69 @@ +using RutasDBUS.Modelos.RedBus; + +namespace RutasDBUS.Servicios.RedBus; + +/// +/// Define las operaciones sobre la red de autobuses. +/// +public interface IRedBusServicio +{ + /// Paradas cargadas en la red. + IReadOnlyList Paradas { get; } + + /// Trayectos cargados en la red. + IReadOnlyDictionary Trayectos { get; } + + /// Líneas de bus cargadas en la red. + IReadOnlyList Lineas { get; } + + /// + /// Carga la red (paradas, trayectos, líneas) desde el fichero XML. + /// + Task CargarRedAsync(CancellationToken cancellationToken = default); + + /// + /// Calcula la distancia aproximada en metros entre dos coordenadas. + /// + double CalcularDistanciaMetros(double lat1, double lon1, double lat2, double lon2); + + /// + /// Devuelve la parada más cercana a las coordenadas indicadas. + /// + Parada? BuscarParadaMasCercana(double latitud, double longitud); + + /// + /// Devuelve la parada más cercana dentro de un radio máximo. Si no hay ninguna dentro de ese radio, devuelve null. + /// + Parada? BuscarParadaCercanaEnRango(double latitud, double longitud, double maxDistMetros); + + /// + /// Convierte un color ARGB (8 caracteres hex) al formato CSS "#RRGGBB". + /// + string ConvertirColorArgbACss(string argb); + + /// + /// Busca una ruta de bus (una sola alternativa) entre dos paradas, devolviendo tramos y paradas de Transbordo. + /// + List? BuscarRutaBus( + string codigoParadaOrigen, + string codigoParadaDestino, + out List codigosParadasTransbordo); + + /// + /// Construye las polilíneas de los tramos de bus y calcula la distancia total recorrida. + /// + List ConstruirLineasBus( + IReadOnlyList tramos, + out double distanciaTotalBus); + + /// + /// Busca varias rutas alternativas de bus entre dos paradas. + /// De momento, la implementación devuelve al menos la ruta "clásica" encontrada por . + /// + IReadOnlyList BuscarRutasBusAlternativas( + string codigoParadaOrigen, + string codigoParadaDestino, + int maxAlternativas = 3); + + +} diff --git a/RutasDBUS/Servicios/RedBus/RedBusServicio.cs b/RutasDBUS/Servicios/RedBus/RedBusServicio.cs new file mode 100644 index 0000000..14ceccf --- /dev/null +++ b/RutasDBUS/Servicios/RedBus/RedBusServicio.cs @@ -0,0 +1,1805 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RutasDBUS.Modelos.Importacion; +using RutasDBUS.Modelos.RedBus; +using RutasDBUS.Servicios.Importacion; + +namespace RutasDBUS.Servicios.RedBus; + +public class RedBusServicio : IRedBusServicio +{ + private readonly IWebHostEnvironment _entorno; + private readonly OpcionesRedBus _opciones; + private readonly ILogger _logger; + private readonly IServicioArchivoImportacionDbus _archiveService; + private readonly CreadorRedTransporteDbus _networkBuilder; + + private readonly SemaphoreSlim _semCarga = new(1, 1); + private bool _redCargada; + private ArchivoImportacionDbus? _archivoCargado; + + private readonly List _paradas = new(); + private readonly Dictionary _trayectos = new(); + private readonly List _lineas = new(); + + private readonly List _infoVariantes = new(); + private readonly Dictionary> _indiceParadas = + new(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary> _paradasCercanas = + new(StringComparer.OrdinalIgnoreCase); + + private const int EscalaGrid = 400; + private Dictionary<(int cx, int cy), List> _gridParadas = new(); + + public IReadOnlyList Paradas => _paradas; + public IReadOnlyDictionary Trayectos => _trayectos; + public IReadOnlyList Lineas => _lineas; + + /// + /// Inicializa una nueva instancia de la clase RedBusServicio. + /// + public RedBusServicio( + IWebHostEnvironment entorno, + IOptions opciones, + ILogger logger, + IServicioArchivoImportacionDbus archiveService, + CreadorRedTransporteDbus networkBuilder) + { + _entorno = entorno; + _opciones = opciones.Value ?? new OpcionesRedBus(); + _logger = logger; + _archiveService = archiveService; + _networkBuilder = networkBuilder; + } + + /// + /// Gestiona red. + /// + public async Task CargarRedAsync(CancellationToken cancellationToken = default) + { + ArchivoImportacionDbus archiveActual; + try + { + archiveActual = await _archiveService.ObtenerAsync(cancellationToken); + } + catch (FileNotFoundException) + { + _logger.LogWarning("No se encontró ningún ZIP de horarios."); + return; + } + + if (_redCargada && ReferenceEquals(_archivoCargado, archiveActual)) return; + + await _semCarga.WaitAsync(cancellationToken); + try + { + if (_redCargada && ReferenceEquals(_archivoCargado, archiveActual)) return; + + LimpiarTodo(); + + ArchivoImportacionDbus archive; + try + { + archive = await _archiveService.ObtenerAsync(cancellationToken); + } + catch (FileNotFoundException) + { + _logger.LogWarning("No se encontró ningún ZIP de horarios."); + return; + } + + var network = _networkBuilder.Build(archive); + _paradas.AddRange(network.Paradas); + foreach (var kvp in network.Trayectos) + _trayectos[kvp.Key] = kvp.Value; + _lineas.AddRange(network.Lineas); + + if (_paradas.Count == 0 || _lineas.Count == 0) + { + _logger.LogWarning("No se han cargado suficientes datos desde el ZIP."); + return; + } + + ConstruirVariantesEIndice(); + ConstruirGridParadas(); + ConstruirParadasCercanasRapido(Math.Max(0, _opciones.DistanciaMaximaTransbordoMetros)); + + _archivoCargado = archive; + _redCargada = true; + + _logger.LogInformation("Paradas cargadas: {N}", _paradas.Count); + _logger.LogInformation("Líneas cargadas: {N}", _lineas.Count); + _logger.LogInformation("Trayectos cargados: {N}", _trayectos.Count); + + foreach (var p in _paradas.Take(10)) + { + _logger.LogInformation( + "STOP {Codigo} | {Nombre} | lat={Lat} | lon={Lon}", + p.Codigo, p.Nombre, p.Latitud, p.Longitud); + } + } + finally + { + _semCarga.Release(); + } + } + + /// + /// Resuelve zip. + /// + private string? ResolverZip() + { + if (!string.IsNullOrWhiteSpace(_opciones.ArchivoDatos)) + { + var ruta = Path.Combine( + _entorno.ContentRootPath, + _opciones.ArchivoDatos.Replace('/', Path.DirectorySeparatorChar)); + + if (File.Exists(ruta)) + return ruta; + } + + var carpeta = Path.Combine(_entorno.ContentRootPath, "Modelos", "horarios"); + if (!Directory.Exists(carpeta)) + return null; + + var zips = Directory.GetFiles(carpeta, "*.zip", SearchOption.TopDirectoryOnly); + if (zips.Length == 0) + return null; + + return zips + .Select(x => new FileInfo(x)) + .OrderByDescending(x => x.LastWriteTimeUtc) + .First() + .FullName; + } + + /// + /// Gestiona todo. + /// + private void LimpiarTodo() + { + _paradas.Clear(); + _trayectos.Clear(); + _lineas.Clear(); + _infoVariantes.Clear(); + _indiceParadas.Clear(); + _paradasCercanas.Clear(); + _gridParadas.Clear(); + } + + // ========================================================= + // ZIP + // ========================================================= + + /// + /// Inicializa una nueva instancia de la clase ZipParadaRow. + /// + private sealed record ZipParadaRow( + int Id, + string CodigoSms, + string Nombre, + double X, + double Y, + string Descripcion); + + /// + /// Inicializa una nueva instancia de la clase ZipRutaRow. + /// + private sealed record ZipRutaRow( + string Id, + string Abreviatura, + string Nombre, + string Descripcion, + string Color); + + /// + /// Inicializa una nueva instancia de la clase ZipExpedicionRow. + /// + private sealed record ZipExpedicionRow( + string Id, + string IdRuta, + string DirDestino, + int Direccion, + string IdPatronExp, + string IdCal); + + /// + /// Inicializa una nueva instancia de la clase ZipTiempoParadaRow. + /// + private sealed record ZipTiempoParadaRow( + string IdExp, + int StopId, + int Secuencia); + + /// + /// Inicializa una nueva instancia de la clase ZipPatronPointRow. + /// + private sealed record ZipPatronPointRow( + string IdPatron, + int Secuencia, + double X, + double Y); + + /// + /// Gestiona desde zip. + /// + private async Task CargarDesdeZipAsync(string zipAbsPath, CancellationToken ct) + { + using var fs = File.OpenRead(zipAbsPath); + using var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: false); + + var eParada = FindEntry(zip, "parada.csv"); + var eRuta = FindEntry(zip, "ruta.csv"); + var eExp = FindEntry(zip, "expedicion.csv"); + var eTiempo = FindEntry(zip, "tiempo_parada.csv"); + var ePatron = FindEntry(zip, "patron.csv"); + + if (eParada is null || eRuta is null || eExp is null || eTiempo is null || ePatron is null) + throw new InvalidOperationException("El ZIP no contiene los CSV mínimos esperados."); + + var paradas = await LeerParadasZipAsync(eParada, ct); + var rutas = await LeerRutasZipAsync(eRuta, ct); + var expediciones = await LeerExpedicionesZipAsync(eExp, ct); + var tiempos = await LeerTiemposParadaZipAsync(eTiempo, ct); + var patrones = await LeerPatronesZipAsync(ePatron, ct); + + ConstruirModeloDesdeZip(paradas, rutas, expediciones, tiempos, patrones); + } + + /// + /// Gestiona modelo desde zip. + /// + private void ConstruirModeloDesdeZip( + List paradasRows, + List rutasRows, + List expedicionesRows, + List tiempoRows, + Dictionary> patrones) + { + var paradaById = new Dictionary(); + var paradaByCodigo = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // ===================================================== + // 1) PARADAS + // ===================================================== + foreach (var p in paradasRows) + { + var (lat, lon) = ConvertirCoordenadaZipALatLon(p.X, p.Y); + + if (!EsLatLonValida(lat, lon)) + { + _logger.LogWarning( + "Parada descartada por coordenada inválida: id={Id} codigo={Codigo} nombre={Nombre} X={X} Y={Y}", + p.Id, p.CodigoSms, p.Nombre, p.X, p.Y); + continue; + } + + var codigo = !string.IsNullOrWhiteSpace(p.CodigoSms) + ? NormalizarCodigoParada(p.CodigoSms) + : NormalizarCodigoParada(p.Id.ToString(CultureInfo.InvariantCulture)); + + var parada = new Parada + { + Codigo = codigo, + Nombre = p.Nombre ?? "", + NombreCorto = !string.IsNullOrWhiteSpace(p.Descripcion) ? p.Descripcion : (p.Nombre ?? ""), + Abreviatura = p.Descripcion ?? "", + Altura = 0, + PreZona = "", + PostZona = "", + ZonaSalida = "", + Latitud = lat, + Longitud = lon + }; + + _paradas.Add(parada); + paradaById[p.Id] = parada; + paradaByCodigo[parada.Codigo] = parada; + } + + // ===================================================== + // 2) TIEMPOS POR EXPEDICIÓN + // ===================================================== + var tiemposPorExp = tiempoRows + .GroupBy(x => x.IdExp, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => g.OrderBy(x => x.Secuencia).ToList(), + StringComparer.OrdinalIgnoreCase); + + // patrón -> secuencia de stop ids + var secuenciaParadasPorPatron = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var exp in expedicionesRows) + { + if (string.IsNullOrWhiteSpace(exp.IdPatronExp)) + continue; + + if (!tiemposPorExp.TryGetValue(exp.Id, out var seq) || seq.Count < 2) + continue; + + if (!secuenciaParadasPorPatron.ContainsKey(exp.IdPatronExp)) + secuenciaParadasPorPatron[exp.IdPatronExp] = seq.Select(x => x.StopId).ToList(); + } + + // ===================================================== + // 3) RUTAS -> LÍNEAS + // ===================================================== + var lineaByRutaId = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var r in rutasRows) + { + var codigoLinea = !string.IsNullOrWhiteSpace(r.Abreviatura) + ? CodigoSinDecimal(r.Abreviatura) + : CodigoSinDecimal(r.Id); + + var lineaBus = new LineaBus + { + IdRuta = r.Id ?? "", + Codigo = codigoLinea, + Nombre = !string.IsNullOrWhiteSpace(r.Nombre) ? r.Nombre : (r.Descripcion ?? ""), + CodigoItinerarioPorDefecto = 0, + ColorArgb = NormalizarColorArgb(r.Color) + }; + + _lineas.Add(lineaBus); + lineaByRutaId[r.Id ?? ""] = lineaBus; + } + + // ===================================================== + // 4) EXPEDICIONES -> VARIANTES TEMPORALES + // ===================================================== + var variantesTemp = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var exp in expedicionesRows) + { + if (!lineaByRutaId.TryGetValue(exp.IdRuta, out var linea)) + continue; + + if (!secuenciaParadasPorPatron.TryGetValue(exp.IdPatronExp, out var stopIds) || stopIds.Count < 2) + continue; + + var sentido = DireccionZipASentido(exp.Direccion); + var codigoItinerario = ExtraerCodigoItinerario(exp.IdRuta, exp.IdPatronExp); + var codigoVariante = ExtraerCodigoVariante(exp.IdPatronExp); + + var key = $"{exp.IdRuta}|{codigoItinerario}|{sentido}|{exp.IdPatronExp}"; + + if (!variantesTemp.TryGetValue(key, out var temp)) + { + temp = new VarianteTemporal + { + RutaId = exp.IdRuta, + CodigoItinerario = codigoItinerario, + Sentido = sentido, + CodigoVariante = codigoVariante, + IdPatron = exp.IdPatronExp, + Destino = exp.DirDestino ?? "", + StopIds = stopIds + }; + variantesTemp[key] = temp; + } + } + + // ===================================================== + // 5) CREAR TRAYECTOS SINTÉTICOS + // ===================================================== + int nextTrayectoId = 1; + + foreach (var temp in variantesTemp.Values) + { + if (!lineaByRutaId.TryGetValue(temp.RutaId, out var linea)) + continue; + + var itinerario = linea.Itinerarios.FirstOrDefault(x => x.Codigo == temp.CodigoItinerario); + if (itinerario is null) + { + itinerario = new Itinerario { Codigo = temp.CodigoItinerario }; + linea.Itinerarios.Add(itinerario); + } + + var sentido = itinerario.Sentidos.FirstOrDefault(x => + string.Equals(NormalizarSentidoCodigo(x.Codigo), temp.Sentido, StringComparison.OrdinalIgnoreCase)); + + if (sentido is null) + { + sentido = new Sentido { Codigo = temp.Sentido }; + itinerario.Sentidos.Add(sentido); + } + + var variante = new Variante + { + Codigo = temp.CodigoVariante + }; + + var stopIdsValidos = temp.StopIds + .Where(paradaById.ContainsKey) + .ToList(); + + var codigosParadas = stopIdsValidos + .Select(id => paradaById[id].Codigo) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToList(); + + if (codigosParadas.Count < 2) + continue; + + var patronPts = patrones.TryGetValue(temp.IdPatron, out var pts) + ? pts.OrderBy(x => x.Secuencia).ToList() + : new List(); + + var patronLatLon = patronPts + .Select(p => + { + var (lat, lon) = ConvertirCoordenadaZipALatLon(p.X, p.Y); + return new[] { lat, lon }; + }) + .Where(x => EsLatLonValida(x[0], x[1])) + .ToList(); + + var stopLatLon = stopIdsValidos + .Select(id => new[] { paradaById[id].Latitud, paradaById[id].Longitud }) + .ToList(); + + var segmentos = ConstruirSegmentosDelPatron(stopLatLon, patronLatLon); + + for (int i = 0; i < codigosParadas.Count - 1; i++) + { + var codOrigen = codigosParadas[i]; + var codDestino = codigosParadas[i + 1]; + + var puntosSegmento = (i < segmentos.Count && segmentos[i].Count >= 2) + ? segmentos[i] + : new List + { + new[] { paradaByCodigo[codOrigen].Latitud, paradaByCodigo[codOrigen].Longitud }, + new[] { paradaByCodigo[codDestino].Latitud, paradaByCodigo[codDestino].Longitud } + }; + + var trayecto = new Trayecto + { + Id = nextTrayectoId, + CodigoParadaOrigen = codOrigen, + CodigoParadaDestino = codDestino, + Posiciones = puntosSegmento + }; + + _trayectos[nextTrayectoId] = trayecto; + variante.IdsTrayectos.Add(nextTrayectoId); + nextTrayectoId++; + } + + if (variante.IdsTrayectos.Count > 0) + sentido.Variantes.Add(variante); + } + + foreach (var linea in _lineas) + { + linea.Itinerarios = linea.Itinerarios + .Where(it => it.Sentidos.Any(s => s.Variantes.Any())) + .OrderBy(it => it.Codigo) + .ToList(); + + foreach (var it in linea.Itinerarios) + { + it.Sentidos = it.Sentidos + .Where(s => s.Variantes.Any()) + .OrderBy(s => NormalizarSentidoCodigo(s.Codigo)) + .ToList(); + } + } + + _lineas.RemoveAll(l => l.Itinerarios.Count == 0); + } + + private sealed class VarianteTemporal + { + public string RutaId { get; set; } = ""; + public int CodigoItinerario { get; set; } + public string Sentido { get; set; } = ""; + public int CodigoVariante { get; set; } + public string IdPatron { get; set; } = ""; + public string Destino { get; set; } = ""; + public List StopIds { get; set; } = new(); + } + + // ========================================================= + // CSV + // ========================================================= + + /// + /// Busca entrada. + /// + private static ZipArchiveEntry? FindEntry(ZipArchive zip, string fileName) + { + return zip.Entries.FirstOrDefault(e => + e.FullName.EndsWith("/" + fileName, StringComparison.OrdinalIgnoreCase) || + e.FullName.Equals(fileName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Lee csv. + /// + private static async Task> ReadCsvAsync(ZipArchiveEntry entry, CancellationToken ct) + { + using var stream = entry.Open(); + using var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var rows = new List(); + string? line; + while ((line = await sr.ReadLineAsync()) is not null) + { + ct.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(line)) continue; + rows.Add(SplitCsvLine(line)); + } + + return rows; + } + + /// + /// Gestiona csv linea. + /// + private static string[] SplitCsvLine(string line) + { + var res = new List(32); + var sb = new StringBuilder(line.Length); + bool inQuotes = false; + + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + + if (c == '"') + { + if (inQuotes && i + 1 < line.Length && line[i + 1] == '"') + { + sb.Append('"'); + i++; + continue; + } + + inQuotes = !inQuotes; + continue; + } + + if (c == ',' && !inQuotes) + { + res.Add(sb.ToString().Trim()); + sb.Clear(); + continue; + } + + sb.Append(c); + } + + res.Add(sb.ToString().Trim()); + return res.ToArray(); + } + + /// + /// + /// + private static int Col(string[] header, string name) + { + for (int i = 0; i < header.Length; i++) + { + if (header[i].Equals(name, StringComparison.OrdinalIgnoreCase)) + return i; + } + return -1; + } + + /// + /// + /// + private static string Get(string[] row, int idx) + => idx >= 0 && idx < row.Length ? row[idx] : ""; + + /// + /// Lee paradas zip. + /// + private async Task> LeerParadasZipAsync(ZipArchiveEntry entry, CancellationToken ct) + { + var rows = await ReadCsvAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + + int iId = Col(h, "ID"); + int iSms = Col(h, "CODIGO_SMS"); + int iNombre = Col(h, "NOMBRE_PAR"); + int iY = Col(h, "LATITUD_Y_PAR"); + int iX = Col(h, "LONGITUD_X_PAR"); + int iDesc = Col(h, "DESCRIPCION_PAR"); + + var res = new List(); + + foreach (var r in rows.Skip(1)) + { + if (!int.TryParse(Get(r, iId), NumberStyles.Integer, CultureInfo.InvariantCulture, out var id)) + continue; + + double.TryParse(Get(r, iX), NumberStyles.Any, CultureInfo.InvariantCulture, out var x); + double.TryParse(Get(r, iY), NumberStyles.Any, CultureInfo.InvariantCulture, out var y); + + res.Add(new ZipParadaRow( + id, + Get(r, iSms), + Get(r, iNombre), + x, + y, + Get(r, iDesc))); + } + + return res; + } + + /// + /// Lee rutas zip. + /// + private async Task> LeerRutasZipAsync(ZipArchiveEntry entry, CancellationToken ct) + { + var rows = await ReadCsvAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + + int iId = Col(h, "ID"); + int iAbr = Col(h, "ABREVIATURA"); + int iNombre = Col(h, "NOMBRE"); + int iDesc = Col(h, "DESCRIPCION"); + int iColor = Col(h, "COLOR"); + + var res = new List(); + + foreach (var r in rows.Skip(1)) + { + var id = Get(r, iId); + if (string.IsNullOrWhiteSpace(id)) continue; + + res.Add(new ZipRutaRow( + id, + Get(r, iAbr), + Get(r, iNombre), + Get(r, iDesc), + Get(r, iColor))); + } + + return res; + } + + /// + /// Lee expediciones zip. + /// + private async Task> LeerExpedicionesZipAsync(ZipArchiveEntry entry, CancellationToken ct) + { + var rows = await ReadCsvAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + + int iId = Col(h, "ID"); + int iRuta = Col(h, "ID_RUTA"); + int iDestino = Col(h, "DIR_DESTINO"); + int iDir = Col(h, "DIRECCION"); + int iPatron = Col(h, "ID_PATRON_EXP"); + int iCal = Col(h, "ID_CAL"); + + var res = new List(); + + foreach (var r in rows.Skip(1)) + { + var id = Get(r, iId); + if (string.IsNullOrWhiteSpace(id)) continue; + + int.TryParse(Get(r, iDir), NumberStyles.Integer, CultureInfo.InvariantCulture, out var dir); + + res.Add(new ZipExpedicionRow( + id, + Get(r, iRuta), + Get(r, iDestino), + dir, + Get(r, iPatron), + Get(r, iCal))); + } + + return res; + } + + /// + /// Lee tiempos parada zip. + /// + private async Task> LeerTiemposParadaZipAsync(ZipArchiveEntry entry, CancellationToken ct) + { + var rows = await ReadCsvAsync(entry, ct); + if (rows.Count == 0) return new(); + + var h = rows[0]; + + int iExp = Col(h, "ID_EXP"); + int iStop = Col(h, "ID_TIEMPO_PAR"); + int iSec = Col(h, "SEC_PAR"); + + var res = new List(); + + foreach (var r in rows.Skip(1)) + { + var expId = Get(r, iExp); + if (string.IsNullOrWhiteSpace(expId)) continue; + + if (!int.TryParse(Get(r, iStop), NumberStyles.Integer, CultureInfo.InvariantCulture, out var stopId)) + continue; + + if (!int.TryParse(Get(r, iSec), NumberStyles.Integer, CultureInfo.InvariantCulture, out var sec)) + continue; + + res.Add(new ZipTiempoParadaRow(expId, stopId, sec)); + } + + return res; + } + + /// + /// Lee patrones zip. + /// + private async Task>> LeerPatronesZipAsync(ZipArchiveEntry entry, CancellationToken ct) + { + var rows = await ReadCsvAsync(entry, ct); + if (rows.Count == 0) return new(StringComparer.OrdinalIgnoreCase); + + var h = rows[0]; + + int iId = Col(h, "ID"); + int iY = Col(h, "PTO_LATITUD_Y"); + int iX = Col(h, "PTO_LONGITUD_X"); + int iSec = Col(h, "PTO_SECUENCIA"); + + var res = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var r in rows.Skip(1)) + { + var id = Get(r, iId); + if (string.IsNullOrWhiteSpace(id)) continue; + + int.TryParse(Get(r, iSec), NumberStyles.Integer, CultureInfo.InvariantCulture, out var sec); + double.TryParse(Get(r, iX), NumberStyles.Any, CultureInfo.InvariantCulture, out var x); + double.TryParse(Get(r, iY), NumberStyles.Any, CultureInfo.InvariantCulture, out var y); + + if (!res.TryGetValue(id, out var list)) + { + list = new List(); + res[id] = list; + } + + list.Add(new ZipPatronPointRow(id, sec, x, y)); + } + + foreach (var kv in res) + kv.Value.Sort((a, b) => a.Secuencia.CompareTo(b.Secuencia)); + + return res; + } + + // ========================================================= + // RECONSTRUCCIÓN PATRÓN + // ========================================================= + + /// + /// Gestiona segmentos del patron. + /// + private static List> ConstruirSegmentosDelPatron( + List stopLatLon, + List patronLatLon) + { + var res = new List>(); + + if (stopLatLon.Count < 2) + return res; + + if (patronLatLon.Count < 2) + { + for (int i = 0; i < stopLatLon.Count - 1; i++) + { + res.Add(new List + { + new[] { stopLatLon[i][0], stopLatLon[i][1] }, + new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] } + }); + } + return res; + } + + var indices = CalcularIndicesParadasEnPatron(stopLatLon, patronLatLon); + + for (int i = 0; i < indices.Count - 1; i++) + { + int a = indices[i]; + int b = indices[i + 1]; + if (b < a) b = a; + + var seg = new List(); + + for (int j = a; j <= b && j < patronLatLon.Count; j++) + seg.Add(new[] { patronLatLon[j][0], patronLatLon[j][1] }); + + if (seg.Count == 0) + { + seg.Add(new[] { stopLatLon[i][0], stopLatLon[i][1] }); + seg.Add(new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] }); + } + else if (seg.Count == 1) + { + seg.Insert(0, new[] { stopLatLon[i][0], stopLatLon[i][1] }); + seg.Add(new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] }); + } + else + { + seg[0] = new[] { stopLatLon[i][0], stopLatLon[i][1] }; + seg[^1] = new[] { stopLatLon[i + 1][0], stopLatLon[i + 1][1] }; + } + + res.Add(QuitarDuplicadosConsecutivos(seg)); + } + + return res; + } + + /// + /// Calcula para cada parada el indice del punto del patron que mejor la representa + /// manteniendo un avance monotono a lo largo de la geometria. + /// + private static List CalcularIndicesParadasEnPatron( + List stopLatLon, + List patronLatLon) + { + var indices = new List(stopLatLon.Count); + var ultimoIndicePatron = patronLatLon.Count - 1; + + for (int i = 0; i < stopLatLon.Count; i++) + { + var minIdx = indices.Count == 0 + ? 0 + : Math.Min(ultimoIndicePatron, indices[^1] + 1); + + var paradasRestantes = stopLatLon.Count - i - 1; + var maxIdx = Math.Max(minIdx, ultimoIndicePatron - paradasRestantes); + + indices.Add(BuscarIndicePatronMasCercano(stopLatLon[i], patronLatLon, minIdx, maxIdx)); + } + + return indices; + } + + /// + /// Busca el punto del patron mas cercano a una parada dentro del rango permitido. + /// + private static int BuscarIndicePatronMasCercano( + double[] paradaLatLon, + List patronLatLon, + int minIdx, + int maxIdx) + { + var inicio = Math.Clamp(minIdx, 0, patronLatLon.Count - 1); + var fin = Math.Clamp(maxIdx, inicio, patronLatLon.Count - 1); + + var mejorIdx = inicio; + var mejorDist = double.MaxValue; + + for (int j = inicio; j <= fin; j++) + { + var dist = Dist2(paradaLatLon[0], paradaLatLon[1], patronLatLon[j][0], patronLatLon[j][1]); + if (dist < mejorDist) + { + mejorDist = dist; + mejorIdx = j; + } + } + + return mejorIdx; + } + + /// + /// Quita duplicados consecutivos. + /// + private static List QuitarDuplicadosConsecutivos(List pts) + { + var res = new List(pts.Count); + foreach (var p in pts) + { + if (res.Count == 0) + { + res.Add(p); + continue; + } + + var u = res[^1]; + if (Math.Abs(u[0] - p[0]) < 1e-9 && Math.Abs(u[1] - p[1]) < 1e-9) + continue; + + res.Add(p); + } + return res; + } + + /// + /// + /// + private static double Dist2(double lat1, double lon1, double lat2, double lon2) + { + double dLat = lat1 - lat2; + double dLon = (lon1 - lon2) * Math.Cos((lat1 + lat2) / 2 * Math.PI / 180.0); + return dLat * dLat + dLon * dLon; + } + + // ========================================================= + // ÍNDICES + // ========================================================= + + /// + /// Gestiona variantes e indice. + /// + private void ConstruirVariantesEIndice() + { + _infoVariantes.Clear(); + _indiceParadas.Clear(); + + foreach (var linea in _lineas) + { + foreach (var itinerario in linea.Itinerarios) + { + foreach (var sentido in itinerario.Sentidos) + { + foreach (var variante in sentido.Variantes) + { + if (variante.IdsTrayectos.Count == 0) continue; + if (!_trayectos.TryGetValue(variante.IdsTrayectos[0], out var t0)) continue; + + var info = new InfoVariante + { + Linea = linea, + Sentido = sentido, + Variante = variante, + CodigoItinerario = itinerario.Codigo + }; + + info.CodigosParadas.Add(t0.CodigoParadaOrigen); + + foreach (var idTr in variante.IdsTrayectos) + { + if (_trayectos.TryGetValue(idTr, out var t)) + info.CodigosParadas.Add(t.CodigoParadaDestino); + } + + _infoVariantes.Add(info); + + for (int i = 0; i < info.CodigosParadas.Count; i++) + { + string codigoParada = info.CodigosParadas[i]; + if (string.IsNullOrWhiteSpace(codigoParada)) continue; + + if (!_indiceParadas.TryGetValue(codigoParada, out var lista)) + { + lista = new List(4); + _indiceParadas[codigoParada] = lista; + } + + lista.Add(new AparicionParada + { + Variante = info, + IndiceParada = i + }); + } + } + } + } + } + } + + // ========================================================= + // PARADAS CERCANAS + // ========================================================= + + /// + /// + /// + private static (int cx, int cy) Celda(double lat, double lon) + => ((int)Math.Floor(lat * EscalaGrid), (int)Math.Floor(lon * EscalaGrid)); + + /// + /// Gestiona rejilla paradas. + /// + private void ConstruirGridParadas() + { + var dict = new Dictionary<(int, int), List>(); + + foreach (var p in _paradas) + { + var c = Celda(p.Latitud, p.Longitud); + if (!dict.TryGetValue(c, out var list)) + { + list = new List(16); + dict[c] = list; + } + list.Add(p); + } + + _gridParadas = dict; + } + + /// + /// Gestiona paradas cercanas rapido. + /// + private void ConstruirParadasCercanasRapido(double maxDistMetros) + { + _paradasCercanas.Clear(); + + if (maxDistMetros <= 0 || _paradas.Count == 0) + return; + + double deg = maxDistMetros / 111_000.0; + int step = (int)Math.Ceiling(deg * EscalaGrid); + if (step < 1) step = 1; + + foreach (var p in _paradas) + { + var c0 = Celda(p.Latitud, p.Longitud); + + for (int dx = -step; dx <= step; dx++) + { + for (int dy = -step; dy <= step; dy++) + { + var c = (c0.cx + dx, c0.cy + dy); + if (!_gridParadas.TryGetValue(c, out var list)) continue; + + foreach (var q in list) + { + if (ReferenceEquals(p, q)) continue; + + double d = CalcularDistanciaMetros(p.Latitud, p.Longitud, q.Latitud, q.Longitud); + if (d <= maxDistMetros) + AgregarParadaCercana(p.Codigo, q.Codigo); + } + } + } + } + } + + /// + /// Agrega parada cercana. + /// + private void AgregarParadaCercana(string codigoBase, string codigoCercana) + { + if (string.IsNullOrWhiteSpace(codigoBase) || string.IsNullOrWhiteSpace(codigoCercana)) + return; + + if (!_paradasCercanas.TryGetValue(codigoBase, out var lista)) + { + lista = new List(8); + _paradasCercanas[codigoBase] = lista; + } + + if (!lista.Contains(codigoCercana, StringComparer.OrdinalIgnoreCase)) + lista.Add(codigoCercana); + } + + // ========================================================= + // GEO + // ========================================================= + + /// + /// Gestiona lat lon valida. + /// + private static bool EsLatLonValida(double lat, double lon) + => lat >= 42.0 && lat <= 44.5 && lon >= -3.5 && lon <= 0.5; + + private const double OFFSET_LAT = -0.0018141507864726236; + private const double OFFSET_LON = -0.0013070642408454791; + + /// + /// + /// + private static (double lat, double lon) ConvertirCoordenadaZipALatLon(double x, double y) + { + if (x == 0 || y == 0) + return (0, 0); + + // Conversión base UTM 30N -> geográficas + var (lat, lon) = Utm30ToLatLonWgs84(x, y); + + // Corrección sistemática observada en los CSV reales + lat += OFFSET_LAT; + lon += OFFSET_LON; + + return (lat, lon); + } + /// + /// + /// + private static (double lat, double lon) Utm30ToLatLonWgs84(double easting, double northing) + { + const int zone = 30; + const double a = 6378137.0; + const double eccSquared = 0.0066943799901413165; + const double k0 = 0.9996; + + double x = easting - 500000.0; + double y = northing; + + double longOrigin = (zone - 1) * 6 - 180 + 3; + double eccPrimeSquared = eccSquared / (1 - eccSquared); + + double M = y / k0; + double mu = M / (a * ( + 1 + - eccSquared / 4.0 + - 3.0 * eccSquared * eccSquared / 64.0 + - 5.0 * Math.Pow(eccSquared, 3) / 256.0)); + + double e1 = (1.0 - Math.Sqrt(1.0 - eccSquared)) / (1.0 + Math.Sqrt(1.0 - eccSquared)); + + double phi1Rad = + mu + + (3.0 * e1 / 2.0 - 27.0 * Math.Pow(e1, 3) / 32.0) * Math.Sin(2.0 * mu) + + (21.0 * e1 * e1 / 16.0 - 55.0 * Math.Pow(e1, 4) / 32.0) * Math.Sin(4.0 * mu) + + (151.0 * Math.Pow(e1, 3) / 96.0) * Math.Sin(6.0 * mu) + + (1097.0 * Math.Pow(e1, 4) / 512.0) * Math.Sin(8.0 * mu); + + double N1 = a / Math.Sqrt(1.0 - eccSquared * Math.Sin(phi1Rad) * Math.Sin(phi1Rad)); + double T1 = Math.Tan(phi1Rad) * Math.Tan(phi1Rad); + double C1 = eccPrimeSquared * Math.Cos(phi1Rad) * Math.Cos(phi1Rad); + double R1 = a * (1.0 - eccSquared) / Math.Pow(1.0 - eccSquared * Math.Sin(phi1Rad) * Math.Sin(phi1Rad), 1.5); + double D = x / (N1 * k0); + + double latRad = + phi1Rad + - (N1 * Math.Tan(phi1Rad) / R1) * + ( + D * D / 2.0 + - (5.0 + 3.0 * T1 + 10.0 * C1 - 4.0 * C1 * C1 - 9.0 * eccPrimeSquared) * Math.Pow(D, 4) / 24.0 + + (61.0 + 90.0 * T1 + 298.0 * C1 + 45.0 * T1 * T1 - 252.0 * eccPrimeSquared - 3.0 * C1 * C1) * Math.Pow(D, 6) / 720.0 + ); + + double lonRad = + ( + D + - (1.0 + 2.0 * T1 + C1) * Math.Pow(D, 3) / 6.0 + + (5.0 - 2.0 * C1 + 28.0 * T1 - 3.0 * C1 * C1 + 8.0 * eccPrimeSquared + 24.0 * T1 * T1) * Math.Pow(D, 5) / 120.0 + ) / Math.Cos(phi1Rad); + + double lat = latRad * 180.0 / Math.PI; + double lon = longOrigin + lonRad * 180.0 / Math.PI; + + return (lat, lon); + } + + /// + /// Calcula distancia metros. + /// + public double CalcularDistanciaMetros(double lat1, double lon1, double lat2, double lon2) + { + double d2 = Dist2(lat1, lon1, lat2, lon2); + return Math.Sqrt(d2) * 111_000.0; + } + + /// + /// Busca parada mas cercana. + /// + public Parada? BuscarParadaMasCercana(double latitud, double longitud) + { + Parada? mejor = null; + double mejorDistancia = double.MaxValue; + + foreach (var p in _paradas) + { + double d = Dist2(latitud, longitud, p.Latitud, p.Longitud); + if (d < mejorDistancia) + { + mejorDistancia = d; + mejor = p; + } + } + + return mejor; + } + + /// + /// Busca parada cercana en rango. + /// + public Parada? BuscarParadaCercanaEnRango(double latitud, double longitud, double maxDistMetros) + { + Parada? mejor = null; + double mejorD2 = double.MaxValue; + + foreach (var p in _paradas) + { + double d2 = Dist2(latitud, longitud, p.Latitud, p.Longitud); + if (d2 < mejorD2) + { + mejorD2 = d2; + mejor = p; + } + } + + if (mejor is null) + return null; + + double distanciaMetros = Math.Sqrt(mejorD2) * 111_000.0; + return distanciaMetros <= maxDistMetros ? mejor : null; + } + + /// + /// Convierte color argb a css. + /// + public string ConvertirColorArgbACss(string argb) + { + if (string.IsNullOrWhiteSpace(argb)) + return "blue"; + + argb = argb.Trim(); + + if (argb.Length == 6) + return "#" + argb; + + if (argb.Length == 8) + return "#" + argb.Substring(2); + + return "blue"; + } + + // ========================================================= + // RUTADO BUS + // ========================================================= + + /// + /// Busca ruta bus con filtro. + /// + private List? BuscarRutaBusConFiltro( + string codigoParadaOrigen, + string codigoParadaDestino, + Func filtroVariante, + out List codigosParadasTransbordo) + { + codigosParadasTransbordo = new(); + + if (!_indiceParadas.TryGetValue(codigoParadaOrigen, out var aparicionesOrigen)) + return null; + if (!_indiceParadas.TryGetValue(codigoParadaDestino, out _)) + return null; + + var cola = new Queue<(InfoVariante Variante, int IndiceParada)>(); + var visitados = new HashSet<(InfoVariante Variante, int IndiceParada)>(); + var padre = new Dictionary<(InfoVariante Variante, int IndiceParada), + (InfoVariante Variante, int IndiceParada)?>(); + + (InfoVariante Variante, int IndiceParada)? objetivo = null; + + foreach (var aparicion in aparicionesOrigen) + { + if (!filtroVariante(aparicion.Variante)) + continue; + + var nodo = (aparicion.Variante, aparicion.IndiceParada); + if (!visitados.Add(nodo)) continue; + + padre[nodo] = null; + cola.Enqueue(nodo); + } + + while (cola.Count > 0) + { + var actual = cola.Dequeue(); + var (variante, indice) = actual; + + string codigoParadaActual = variante.CodigosParadas[indice]; + + if (codigoParadaActual == codigoParadaDestino) + { + objetivo = actual; + break; + } + + if (indice + 1 < variante.CodigosParadas.Count) + { + var siguiente = (variante, indice + 1); + if (visitados.Add(siguiente)) + { + padre[siguiente] = actual; + cola.Enqueue(siguiente); + } + } + + if (_indiceParadas.TryGetValue(codigoParadaActual, out var aparicionesParada)) + { + foreach (var ap in aparicionesParada) + { + if (!filtroVariante(ap.Variante)) + continue; + + var siguiente = (ap.Variante, ap.IndiceParada); + + if (ReferenceEquals(siguiente.Variante, variante) && siguiente.IndiceParada == indice) + continue; + + if (visitados.Add(siguiente)) + { + padre[siguiente] = actual; + cola.Enqueue(siguiente); + } + } + } + + if (_paradasCercanas.TryGetValue(codigoParadaActual, out var vecinas)) + { + foreach (var codVecino in vecinas) + { + if (!_indiceParadas.TryGetValue(codVecino, out var aparicionesVecino)) + continue; + + foreach (var ap in aparicionesVecino) + { + if (!filtroVariante(ap.Variante)) + continue; + + var siguiente = (ap.Variante, ap.IndiceParada); + + if (visitados.Add(siguiente)) + { + padre[siguiente] = actual; + cola.Enqueue(siguiente); + } + } + } + } + } + + if (objetivo is null) + return null; + + var nodosCamino = new List<(InfoVariante Variante, int IndiceParada)>(); + var nodoActual = objetivo.Value; + + while (true) + { + nodosCamino.Add(nodoActual); + if (!padre.TryGetValue(nodoActual, out var anterior) || anterior is null) + break; + nodoActual = anterior.Value; + } + + nodosCamino.Reverse(); + + int numeroTransbordos; + var tramos = ConstruirTramosYTransbordosDesdeCamino( + nodosCamino, + codigoParadaOrigen, + codigoParadaDestino, + out codigosParadasTransbordo, + out numeroTransbordos); + + return tramos; + } + + /// + /// Gestiona tramos y transbordos desde camino. + /// + private static List ConstruirTramosYTransbordosDesdeCamino( + List<(InfoVariante Variante, int IndiceParada)> nodosCamino, + string codigoParadaOrigen, + string codigoParadaDestino, + out List codigosParadasTransbordo, + out int numeroTransbordos) + { + codigosParadasTransbordo = new(); + + for (int i = 1; i < nodosCamino.Count; i++) + { + var previo = nodosCamino[i - 1]; + var ahora = nodosCamino[i]; + + if (!ReferenceEquals(previo.Variante, ahora.Variante)) + { + string codigoParada = ahora.Variante.CodigosParadas[ahora.IndiceParada]; + if (!codigosParadasTransbordo.Contains(codigoParada) && + codigoParada != codigoParadaOrigen && + codigoParada != codigoParadaDestino) + { + codigosParadasTransbordo.Add(codigoParada); + } + } + } + + numeroTransbordos = codigosParadasTransbordo.Count; + + var tramos = new List(); + if (nodosCamino.Count <= 1) return tramos; + + var varianteActual = nodosCamino[0].Variante; + int indiceInicio = nodosCamino[0].IndiceParada; + int ultimoIndice = nodosCamino[0].IndiceParada; + + for (int i = 1; i < nodosCamino.Count; i++) + { + var (variante, indice) = nodosCamino[i]; + + if (ReferenceEquals(variante, varianteActual) && indice == ultimoIndice + 1) + { + ultimoIndice = indice; + continue; + } + + if (ultimoIndice > indiceInicio) + { + tramos.Add(new TramoBus + { + Variante = varianteActual, + IndiceInicio = indiceInicio, + IndiceFin = ultimoIndice + }); + } + + varianteActual = variante; + indiceInicio = indice; + ultimoIndice = indice; + } + + if (ultimoIndice > indiceInicio) + { + tramos.Add(new TramoBus + { + Variante = varianteActual, + IndiceInicio = indiceInicio, + IndiceFin = ultimoIndice + }); + } + + return tramos; + } + + /// + /// Busca ruta bus. + /// + public List? BuscarRutaBus( + string codigoParadaOrigen, + string codigoParadaDestino, + out List codigosParadasTransbordo) + { + return BuscarRutaBusConFiltro( + codigoParadaOrigen, + codigoParadaDestino, + filtroVariante: _ => true, + codigosParadasTransbordo: out codigosParadasTransbordo); + } + + /// + /// Gestiona lineas bus. + /// + public List ConstruirLineasBus( + IReadOnlyList tramos, + out double distanciaTotalBus) + { + distanciaTotalBus = 0; + var resultado = new List(); + + foreach (var tramo in tramos) + { + var info = tramo.Variante; + var idsTrayectos = info.Variante.IdsTrayectos; + var puntos = new List(); + + for (int indiceParada = tramo.IndiceInicio; indiceParada < tramo.IndiceFin; indiceParada++) + { + int indiceTrayecto = indiceParada; + if (indiceTrayecto < 0 || indiceTrayecto >= idsTrayectos.Count) + continue; + + if (_trayectos.TryGetValue(idsTrayectos[indiceTrayecto], out var trayecto)) + { + foreach (var p in trayecto.Posiciones) + { + if (puntos.Count > 0) + { + var ultimo = puntos[^1]; + if (Math.Abs(ultimo[0] - p[0]) < 1e-6 && + Math.Abs(ultimo[1] - p[1]) < 1e-6) + continue; + } + + puntos.Add(p); + } + } + } + + if (puntos.Count == 0) continue; + + var paradaInicio = ResolverParadaFisicaCercana( + tramo.CodigoParadaInicio, + puntos.Count > 0 ? puntos[0] : null); + var paradaFin = ResolverParadaFisicaCercana( + tramo.CodigoParadaFin, + puntos.Count > 0 ? puntos[^1] : null); + + if (paradaInicio is not null) + puntos[0] = new[] { paradaInicio.Latitud, paradaInicio.Longitud }; + + if (paradaFin is not null) + puntos[^1] = new[] { paradaFin.Latitud, paradaFin.Longitud }; + + var linea = new double[puntos.Count, 2]; + for (int i = 0; i < puntos.Count; i++) + { + var lat = puntos[i][0]; + var lon = puntos[i][1]; + + linea[i, 0] = lat; + linea[i, 1] = lon; + + if (i > 0) + { + var prev = puntos[i - 1]; + distanciaTotalBus += CalcularDistanciaMetros(prev[0], prev[1], lat, lon); + } + } + + resultado.Add(linea); + } + + return resultado; + } + + /// + /// Busca rutas bus alternativas. + /// + public IReadOnlyList BuscarRutasBusAlternativas( + string codigoParadaOrigen, + string codigoParadaDestino, + int maxAlternativas = 3) + { + var alternativas = new List(); + if (maxAlternativas <= 0) return alternativas; + + var rutaPrincipal = BuscarRutaBusConFiltro( + codigoParadaOrigen, + codigoParadaDestino, + filtroVariante: _ => true, + codigosParadasTransbordo: out var transbordosPrincipal); + + if (rutaPrincipal is null || rutaPrincipal.Count == 0) + return alternativas; + + double distanciaPrincipal; + _ = ConstruirLineasBus(rutaPrincipal, out distanciaPrincipal); + + string FirmaRuta(List tramos) => + string.Join(";", tramos.Select(t => + $"{t.Variante.Linea.Codigo}-{t.Variante.CodigoItinerario}-{NormalizarSentidoCodigo(t.Variante.Sentido?.Codigo)}-{t.Variante.Variante.Codigo}-{t.IndiceInicio}-{t.IndiceFin}")); + + var firmas = new HashSet(StringComparer.OrdinalIgnoreCase); + firmas.Add(FirmaRuta(rutaPrincipal)); + + alternativas.Add(new RutaBusAlternativa + { + Tramos = rutaPrincipal, + CodigosParadasTransbordo = transbordosPrincipal, + NumeroTransbordos = Math.Max(0, transbordosPrincipal.Count), + DistanciaTotalBus = distanciaPrincipal + }); + + if (alternativas.Count >= maxAlternativas) + return alternativas; + + var variantesUsadas = rutaPrincipal + .Select(t => t.Variante) + .Distinct() + .ToList(); + + foreach (var varianteProhibida in variantesUsadas) + { + if (alternativas.Count >= maxAlternativas) + break; + + var rutaAlt = BuscarRutaBusConFiltro( + codigoParadaOrigen, + codigoParadaDestino, + filtroVariante: v => !ReferenceEquals(v, varianteProhibida), + codigosParadasTransbordo: out var transbordosAlt); + + if (rutaAlt is null || rutaAlt.Count == 0) + continue; + + var firmaAlt = FirmaRuta(rutaAlt); + if (!firmas.Add(firmaAlt)) + continue; + + double distanciaAlt; + _ = ConstruirLineasBus(rutaAlt, out distanciaAlt); + + alternativas.Add(new RutaBusAlternativa + { + Tramos = rutaAlt, + CodigosParadasTransbordo = transbordosAlt, + NumeroTransbordos = Math.Max(0, transbordosAlt.Count), + DistanciaTotalBus = distanciaAlt + }); + } + + return alternativas; + } + + // ========================================================= + // HELPERS + // ========================================================= + + /// + /// Resuelve la parada fisica mas cercana a un punto para un codigo concreto. + /// + private Parada? ResolverParadaFisicaCercana(string? codigoParada, double[]? puntoReferencia) + { + if (string.IsNullOrWhiteSpace(codigoParada)) + return null; + + var candidatas = _paradas + .Where(parada => parada.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (candidatas.Count == 0) + return null; + + if (candidatas.Count == 1 || puntoReferencia is null || puntoReferencia.Length < 2) + return candidatas[0]; + + return candidatas + .OrderBy(parada => CalcularDistanciaMetros( + parada.Latitud, + parada.Longitud, + puntoReferencia[0], + puntoReferencia[1])) + .First(); + } + + /// + /// Gestiona sin decimal. + /// + private static string CodigoSinDecimal(string? codigo) + { + if (string.IsNullOrWhiteSpace(codigo)) return ""; + codigo = codigo.Trim(); + + if (codigo.StartsWith("P-", StringComparison.OrdinalIgnoreCase)) + codigo = codigo[2..]; + + var i = codigo.IndexOf('.'); + if (i > 0) + codigo = codigo[..i]; + + return codigo.Trim(); + } + + /// + /// Normaliza codigo parada. + /// + private static string NormalizarCodigoParada(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return ""; + + s = s.Trim(); + s = s.Replace("P-", "", StringComparison.OrdinalIgnoreCase); + + var i = s.IndexOf('.'); + if (i > 0) + s = s[..i]; + + return s.Trim(); + } + + /// + /// Normaliza color argb. + /// + private static string NormalizarColorArgb(string? color) + { + if (string.IsNullOrWhiteSpace(color)) + return "FF2563EB"; + + color = color.Trim().TrimStart('#'); + + if (color.Length == 6) + return "FF" + color.ToUpperInvariant(); + + if (color.Length == 8) + return color.ToUpperInvariant(); + + return "FF2563EB"; + } + + /// + /// Gestiona zip a sentido. + /// + private static string DireccionZipASentido(int direccion) + { + return direccion switch + { + 1 => "I", + 2 => "V", + 0 => "I", + _ => "?" + }; + } + + /// + /// Normaliza sentido codigo. + /// + private static string NormalizarSentidoCodigo(string? codigo) + { + if (string.IsNullOrWhiteSpace(codigo)) return "?"; + + var s = codigo.Trim(); + + if (s.Equals("V", StringComparison.OrdinalIgnoreCase)) return "V"; + if (s.Equals("I", StringComparison.OrdinalIgnoreCase)) return "I"; + if (s.Equals("Vuelta", StringComparison.OrdinalIgnoreCase)) return "V"; + if (s.Equals("Ida", StringComparison.OrdinalIgnoreCase)) return "I"; + + var c = char.ToUpperInvariant(s[0]); + if (c == 'V') return "V"; + if (c == 'I') return "I"; + return "?"; + } + + /// + /// Gestiona codigo variante. + /// + private static int ExtraerCodigoVariante(string? idPatronExp) + { + if (string.IsNullOrWhiteSpace(idPatronExp)) + return 0; + + var s = idPatronExp.Trim(); + + if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) + return n; + + var m = Regex.Match(s, @"(\d+)$"); + return m.Success && int.TryParse(m.Groups[1].Value, out n) ? n : 0; + } + + /// + /// Gestiona codigo itinerario. + /// + private static int ExtraerCodigoItinerario(string? idRuta, string? idPatronExp) + { + if (string.IsNullOrWhiteSpace(idPatronExp)) + return 0; + + var patron = idPatronExp.Trim(); + var ruta = CodigoSinDecimal(idRuta); + + if (!string.IsNullOrWhiteSpace(ruta) && + patron.StartsWith(ruta, StringComparison.OrdinalIgnoreCase)) + { + patron = patron[ruta.Length..]; + } + + patron = patron.Trim(); + + if (patron.Length >= 4) + { + var sinDir = patron[..^2]; + sinDir = sinDir.TrimStart('0'); + if (int.TryParse(sinDir, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n1)) + return n1; + } + + var m = Regex.Match(patron, @"(\d{1,3})"); + if (m.Success && int.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n2)) + return n2; + + return 0; + } +} diff --git a/RutasDBUS/Servicios/RedBus/RedTransporteDbus.cs b/RutasDBUS/Servicios/RedBus/RedTransporteDbus.cs new file mode 100644 index 0000000..22fbcff --- /dev/null +++ b/RutasDBUS/Servicios/RedBus/RedTransporteDbus.cs @@ -0,0 +1,10 @@ +using RutasDBUS.Modelos.RedBus; + +namespace RutasDBUS.Servicios.RedBus; + +public sealed class RedTransporteDbus +{ + public IReadOnlyList Paradas { get; init; } = Array.Empty(); + public IReadOnlyDictionary Trayectos { get; init; } = new Dictionary(); + public IReadOnlyList Lineas { get; init; } = Array.Empty(); +} diff --git a/RutasDBUS/appsettings.Development.json b/RutasDBUS/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/RutasDBUS/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/RutasDBUS/appsettings.json b/RutasDBUS/appsettings.json new file mode 100644 index 0000000..fafe359 --- /dev/null +++ b/RutasDBUS/appsettings.json @@ -0,0 +1,58 @@ +{ + "RedBus": { + "ArchivoDatos": "Modelos/horarios/zip_DBUS_OPENDATA_20260514~0800.zip", + "DistanciaMaximaTransbordoMetros": 250 + }, + + "Mapa": { + "UrlTeselas": "https://osm.tecnosis.net/osm_tiles/{z}/{x}/{y}.png", + "Atribucion": "© OpenStreetMap", + "LatitudInicial": 43.307251, + "LongitudInicial": -1.979497, + "ZoomInicial": 14, + "ZoomMinimo": 3, + "ZoomMaximo": 19 + }, + + "Caminata": { + "Osrm": { + "BaseUrl": "https://osrmw.tecnosis.net/route/v1" + }, + "Valhalla": { + "BaseUrl": "http://192.168.41.12:8002" + } + }, + + "Elevacion": { + "OpenElevation": { + "UrlLookup": "https://openelevation.tecnosis.net/api/v1/lookup" + }, + "Idee": { + "UrlEjecucion": "https://api-processes.idee.es/processes/getElevation/execution" + }, + "MaxPuntosPorTramo": 100, + "DecimalesCache": 6 + }, + + + + "PlanificadorRutas": { + "RadioParadasCandidatasMetros": 1200, + "MaxParadasCandidatasPorLado": 12, + "VelocidadPieKmH": 3.6, + "VelocidadBusKmH": 15.0, + "MinutosPorTransbordo": 5, + "SegundosPorParadaBus": 30, + "MaxAlternativasBusPorPar": 4, + "MaxParesEvaluados": 280 + }, + + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + + "AllowedHosts": "*" +} diff --git a/RutasDBUS/wwwroot/app.css b/RutasDBUS/wwwroot/app.css new file mode 100644 index 0000000..1a2ea84 --- /dev/null +++ b/RutasDBUS/wwwroot/app.css @@ -0,0 +1,2409 @@ +/* ===== BASE GLOBAL ===== */ + +.page, main, .content, article.content { + margin: 0 !important; + padding: 0 !important; +} +.btn-ghost.btn-icon { + width: 35px; + height: 35px; + padding: 0; + display: grid; + place-items: center; + font-size: 1rem; +} + +.history-open-icon { + display: inline-block; + font-size: 0.9rem; + font-weight: 700; + line-height: 1; + letter-spacing: 0.01em; +} + +html, body { + height: 100%; + margin: 0; + padding: 0; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +body { + background: #020617; /* mismo fondo oscuro que el mapa */ +} + +a, .btn-link { + color: #006bb7; +} +/* El bloque del indicador en medio */ +.leaflet-control-zoom.leaflet-bar { + display: flex; + align-items: stretch; +} + +.leaflet-control-zoom a { + float: none; + display: grid; + place-items: center; + margin: 0 !important; + border-radius: 0 !important; +} + +.leaflet-control-zoom a.leaflet-control-zoom-in { + border-top-left-radius: .4rem !important; + border-bottom-left-radius: .4rem !important; +} + +.leaflet-control-zoom a.leaflet-control-zoom-out { + border-top-right-radius: .4rem !important; + border-bottom-right-radius: .4rem !important; + border-left: 0 !important; +} + +.leaflet-control-zoom-indicator-inset { + display: grid; + place-items: center; + width: 30px; /* Leaflet suele usar 30px en los botones */ + height: 30px; + font-weight: 800; + font-size: .78rem; + color: #e5e7eb; + background: rgba(15,23,42,.96); + border-left: 1px solid rgba(148,163,184,.35); + border-right: 1px solid rgba(148,163,184,.35); + border-radius: 0; + user-select: none; + pointer-events: none; +} + +.leaflet-control-zoom a.leaflet-control-zoom-in + .leaflet-control-zoom-indicator-inset { + border-left: 0; +} + + +.search-icon { + position: relative; + display: inline-block; +} + + .search-icon.off::after { + content: ""; + position: absolute; + left: -2px; + right: -2px; + top: 50%; + height: 2px; + background: currentColor; + transform: rotate(-35deg); + color:darkred; + } + + + + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, +.btn:active:focus, +.btn-link.nav-link:focus, +.form-control:focus, +.form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +/* ===== LAYOUT BLAZOR ===== */ + +.page { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +main { + flex: 1; + display: flex; +} + +.content { + padding: 0; + margin: 0; + width: 100%; + max-width: none; +} + +article.content { + padding: 0; + margin: 0; + width: 100% !important; + max-width: 100% !important; +} + + +.page, main, .full-page { + width: 100%; +} + + + +/* Contenedor de páginas que deben ocupar toda la altura disponible */ +.full-page { + display: flex; + flex-direction: column; + height: 100%; +} + +/* ===== VALIDACIONES / ERRORES ===== */ + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDky. . .) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred."; + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +/* ===== HEADER SUPERIOR RUTAS ===== */ + +.rutas-header { + padding: 0.5rem 1rem; + border-bottom: 1px solid rgba(0,0,0,.1); + background: linear-gradient(90deg, #0f172a, #111827); + color: #e5e7eb; + display: flex; + align-items: center; + /* justify-content: space-between; ❌ quitar */ +} + + + .rutas-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + } + + .rutas-header small { + display: block; + font-size: 0.8rem; + color: #9ca3af; + } + +.pill-live { + display: inline-flex; + align-items: center; + gap: .4rem; + padding: .2rem .6rem; + border-radius: 999px; + background: rgba(16, 185, 129, .12); + border: 1px solid rgba(16, 185, 129, .5); + color: #a7f3d0; + font-size: .75rem; +} + +.pill-dot { + width: .45rem; + height: .45rem; + border-radius: 999px; + background: #22c55e; + box-shadow: 0 0 0 3px rgba(34,197,94,.35); +} + +.btn-ghost { + border-radius: 999px; + border: 1px solid rgba(148,163,184,.6); + background: #111827; + color: #e5e7eb; + font-size: .78rem; + padding: .3rem .9rem; + cursor: pointer; +} + + .btn-ghost:hover { + background: #1d4ed8; + color: #fff; + } +.page, main, .full-page, .content, article.content { + overflow: hidden; +} +html, body { + overflow: hidden; +} + +.map-container .leaflet-control-layers { + display: none !important; +} + + +/* ===== MAPA + CONTENEDOR PRINCIPAL ===== */ + +.map-container { + position: relative; + z-index: 0 !important; + overflow: hidden !important; /* ✅ clave */ + /* que rellene todo el espacio bajo el header */ + flex: 1 1 auto; + width: 100%; + height: 100%; +} + + /* Estado normal: mapa sin opacidad */ + .map-container .leaflet-tile-pane { + filter: opacity(1); + transition: filter .25s ease; + } + + /* Nivel 1: un poco más opaco */ + .map-container.opacidad-1 .leaflet-tile-pane { + filter: opacity(0.75); + } + + /* Nivel 2: más oscuro */ + .map-container.opacidad-2 .leaflet-tile-pane { + filter: opacity(0.50); + } + + /* Nivel 3: bastante oscuro */ + .map-container.opacidad-3 .leaflet-tile-pane { + filter: opacity(0.25); + } + /* Nivel 4: bastante oscuro */ + .map-container.opacidad-4 .leaflet-tile-pane { + filter: opacity(0.10); + } + +/* ===== CURSOR DEL MAPA ===== */ + +/* Cursor SIEMPRE flecha */ +.leaflet-container { + cursor: default !important; +} + +/* Las capas interactivas NO deben cambiar el cursor */ +.leaflet-interactive { + cursor: default !important; +} + +/* Evitar cursor de mano en puntos, polilíneas, polígonos de Leaflet */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-clickable { + cursor: default !important; +} + +.rt-endpoint-icon { + background: transparent !important; + border: none !important; +} + +.rt-endpoint-marker { + border-radius: 999px; + background: var(--endpoint-color, #22c55e); + border: 2px solid rgba(255,255,255,.98); + box-shadow: 0 0 0 2px rgba(15,23,42,.85), 0 8px 18px rgba(15,23,42,.4); + cursor: grab !important; +} + +.rt-endpoint-marker:active { + cursor: grabbing !important; +} + +.rt-endpoint-marker--origin { + background: #22c55e; +} + +.rt-endpoint-marker--destination { + background: #ef4444; +} + +/* ===== PANEL DE BÚSQUEDA TIPO GOOGLE MAPS ===== */ + +.gm-search-panel { + position: absolute; + top: 0.6rem; + left: 50%; + transform: translateX(-50%); + background: rgba(15, 23, 42, 0.96); + padding: 0.55rem 0.75rem 0.65rem; + border-radius: 22px; + box-shadow: 0 10px 30px rgba(0, 0, 0, .4); + z-index: 1200; + max-width: 980px; + width: calc(100% - 2rem); +} + +.gm-search-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.gm-search-actions { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.gm-search-inputs { + display: flex; + align-items: center; + gap: 0.35rem; + flex: 1; +} + +.gm-input-group { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 0; /* ? para que Origen y Destino se estiren */ + position: relative; +} + + + .gm-input-group label { + font-size: 0.65rem; + color: #9ca3af; + margin-left: 0.25rem; + } + + .gm-input-group input { + width: 100%; /* ? para que el input rellene el grupo */ + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, .6); + padding: 0.25rem 0.6rem; + font-size: 0.8rem; + background-color: rgba(15, 23, 42, 0.95); + color: #e5e7eb; + min-width: 0; + } + + + .gm-input-group input::placeholder { + color: #6b7280; + } + +.gm-suggestions { + position: absolute; + top: calc(100% + 0.28rem); + left: 0; + right: 0; + display: grid; + gap: 0.18rem; + max-height: min(320px, calc(100vh - 150px)); + overflow: auto; + padding: 0.28rem; + border: 1px solid rgba(148, 163, 184, .36); + border-radius: 10px; + background: rgba(15, 23, 42, .98); + box-shadow: 0 18px 34px rgba(0, 0, 0, .42); + z-index: 1250; +} + +.gm-suggestion { + display: grid; + grid-template-columns: 66px minmax(0, 1fr); + align-items: center; + gap: 0.42rem; + width: 100%; + min-height: 38px; + padding: 0.28rem 0.36rem; + border: 0; + border-radius: 7px; + background: transparent; + color: #e5e7eb; + text-align: left; + cursor: pointer; +} + +.gm-suggestion:hover, +.gm-suggestion:focus-visible { + outline: none; + background: rgba(30, 64, 175, .56); +} + +.gm-suggestion__type { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 0; + height: 22px; + padding: 0 0.34rem; + border: 1px solid rgba(125, 211, 252, .32); + border-radius: 5px; + color: #bae6fd; + background: rgba(14, 116, 144, .24); + font-size: 0.62rem; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gm-suggestion__text { + display: grid; + gap: 0.06rem; + min-width: 0; +} + +.gm-suggestion__title, +.gm-suggestion__subtitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gm-suggestion__title { + color: #f8fafc; + font-size: 0.76rem; + font-weight: 700; +} + +.gm-suggestion__subtitle { + color: #94a3b8; + font-size: 0.68rem; +} + +.gm-swap-btn { + border-radius: 999px; + border: none; + padding: 0.25rem 0.45rem; + background: rgba(30, 64, 175, .9); + color: white; + font-size: 0.75rem; + cursor: pointer; + transform: rotate(90deg); +} + + .gm-swap-btn:hover { + background: rgba(37, 99, 235, 1); + } + +.gm-search-button { + border-radius: 999px; + border: none; + padding: 0.4rem 0.9rem; + background: #22c55e; + color: #022c22; + font-weight: 600; + font-size: 0.8rem; + cursor: pointer; + white-space: nowrap; +} + + .gm-search-button:hover { + background: #4ade80; + } + +.gm-history-row { + display: flex; + gap: 0.6rem; + margin-top: 0.55rem; + flex-wrap: wrap; +} + +.gm-history-group { + flex: 1 1 340px; + min-width: 0; +} + + .gm-history-group label { + display: block; + font-size: 0.65rem; + color: #9ca3af; + margin: 0 0 0.18rem 0.25rem; + } + +.gm-history-controls { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.gm-history-select { + flex: 1 1 auto; + min-width: 0; + height: 34px; + font-size: 0.78rem; +} + +.gm-history-btn { + flex: 0 0 auto; + min-width: 86px; + white-space: nowrap; +} + +.gm-history-feedback { + margin-top: 0.45rem; + font-size: 0.72rem; + color: #93c5fd; + padding-left: 0.25rem; +} + +.gm-history-feedback--error { + color: #fca5a5; +} + +/* ===== PANEL LATERAL DE ESTADO / SELECCIÓN ===== */ + +.map-overlay-card { + position: absolute; + top: .75rem; + left: .75rem; + max-width: 320px; + z-index: 1200; + background: rgba(15,23,42,.96); + color: #e5e7eb; + border-radius: .75rem; + padding: .75rem .9rem; + box-shadow: 0 16px 40px rgba(15,23,42,.9); + border: 1px solid rgba(148,163,184,.35); + font-size: .82rem; +} + + .map-overlay-card strong { + font-size: .78rem; + text-transform: uppercase; + letter-spacing: .12em; + color: #9ca3af; + } + + .map-overlay-card p { + margin: .25rem 0; + font-size: .82rem; + } + + .map-overlay-card .mini { + font-size: .75rem; + color: #9ca3af; + } + +.titulo-app { + display: flex; + align-items: baseline; + gap: .5rem; +} + +.app-version-inline { + font-size: 0.7rem; + color: #9ca3af; + font-weight: 400; +} + +/* ===== POPUP PARADA ===== */ + +.popup-parada { + position: absolute; + top: 5.0rem; + left: 8.5rem; + max-width: 260px; + background: rgba(15,23,42,.96); + color: #e5e7eb; + border-radius: .75rem; + padding: .75rem .8rem .45rem .8rem; /* antes .6rem arriba */ + box-shadow: 0 16px 30px rgba(15,23,42,.9); + border: 1px solid rgba(148,163,184,.4); + font-size: .82rem; + z-index: 1200; +} + +.popup-stack-left { + position: absolute; + top: 12rem; /* antes 5rem */ + left: 1rem; + z-index: auto; + display: flex; + flex-direction: column; + gap: .6rem; + pointer-events: none; + max-width: calc(100vw - 2rem); +} + + /* Cada popup dentro del stack vuelve a "flujo normal" */ + .popup-stack-left .popup-parada { + position: relative !important; /* 🔑 quita el absolute del CSS base */ + top: auto !important; + left: auto !important; + margin: 0 !important; + pointer-events: auto; /* sí se puede clicar */ + width: auto; + max-width: 380px; + } + +/* Slots opcionales (por claridad) */ +.popup-slot { + position: relative; +} + +/* Anchos específicos */ +.popup-stack-left .popup-itinerario { + max-width: 380px; +} + +.popup-stack-left .popup-inspector { + max-width: 280px; +} + +/* Itinerario: bajar texto para que no choque con la X */ +.popup-stack-left .popup-itinerario-body { + padding-right: 2.2rem; + +} +.popup-parada-header { + font-weight: 600; + font-size: .86rem; + margin-bottom: .25rem; +} + +.popup-parada-body { + font-size: .8rem; +} + + .popup-parada-body div { + margin-bottom: .15rem; + } + +.popup-parada-cerrar { + position: absolute; + top: .3rem; + right: .45rem; + border: none; + background: transparent; + color: #9ca3af; + font-size: .9rem; + cursor: pointer; +} + + .popup-parada-cerrar:hover { + color: #e5e7eb; + } + +.draggable-popup-handle { + cursor: grab; + user-select: none; +} + +.draggable-popup--dragging { + box-shadow: 0 24px 55px rgba(0,0,0,.55); + opacity: .98; +} + + .draggable-popup--dragging .draggable-popup-handle { + cursor: grabbing; + } + +.popup-parada.draggable-popup--free, +.popup-stack-left .popup-parada.draggable-popup--free, +.popup-elev-topright.draggable-popup--free, +.route-panel-overlay.draggable-popup--free, +.history-panel.draggable-popup--free, +.config-panel.draggable-popup--free { + position: fixed !important; + left: var(--drag-left) !important; + top: var(--drag-top) !important; + right: auto !important; + bottom: auto !important; + margin: 0 !important; + transform: none !important; +} + +/* ===== PANEL LATERAL DE RUTAS ===== */ + +.route-panel-overlay { + position: absolute; + top: 4.5rem; /* debajo de la barra de búsqueda */ + right: 0.75rem; + bottom: 1rem; + width: 360px; + max-width: 90vw; + pointer-events: none !important; + z-index: 1200; +} + +.route-panel { + pointer-events: auto; + background: rgba(15, 23, 42, 0.96); + border-radius: 1rem; + padding: 0.75rem 0.85rem; + box-shadow: 0 15px 40px rgba(0, 0, 0, .6); + display: flex; + flex-direction: column; + height: 100%; + color: #e5e7eb; +} + +.route-panel-title { + margin: 0; + font-size: .9rem; + font-weight: 600; + color: #e5e7eb; + white-space: nowrap; +} + +.route-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + gap: .5rem; + margin: 0 0 .45rem; +} + +.route-priority-switch { + display: inline-grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 2px; + padding: 2px; + border: 1px solid rgba(148, 163, 184, .45); + border-radius: 8px; + background: rgba(2, 6, 23, .55); + flex: 0 0 auto; +} + +.route-priority-option { + min-width: 62px; + height: 24px; + border: 0; + border-radius: 6px; + padding: 0 .28rem; + background: transparent; + color: #9ca3af; + font-size: .64rem; + font-weight: 700; + cursor: pointer; + pointer-events: auto; +} + +.route-priority-option:hover { + color: #f8fafc; + background: rgba(30, 64, 175, .5); +} + +.route-priority-option-active { + color: #f8fafc; + background: #2563eb; +} + +/* Lista de alternativas de ruta */ + +.route-alt-list { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 0.4rem; +} + +.route-alt-card { + border-radius: 0.8rem; + border: 1px solid rgba(148, 163, 184, .55); + background: rgba(15, 23, 42, 0.9); + padding: 0.35rem 0.5rem; + cursor: pointer; + text-align: left; + width: 100%; + display: flex; + flex-direction: column; + gap: 0.1rem; + font-size: 0.78rem; + color: #e5e7eb; + pointer-events: auto; +} + + .route-alt-card:hover { + border-color: #3b82f6; + background: rgba(30, 64, 175, 0.85); + } + +.route-alt-card-selected { + border-color: #22c55e; + background: rgba(21, 128, 61, 0.85); +} + +.route-alt-main { + display: flex; + align-items: baseline; + gap: 0.35rem; +} + +.route-alt-time { + font-weight: 600; + font-size: 0.85rem; +} + +.route-alt-label { + font-size: 0.75rem; + color: #d1d5db; +} + +.route-alt-sub { + font-size: 0.7rem; + color: #9ca3af; +} + +.route-alt-walk { + white-space: nowrap; +} + +.route-alt-walk-emphasis { + color: #f8fafc; + font-weight: 800; +} + +.route-alt-balance { + color: #bfdbfe; + white-space: nowrap; + font-weight: 700; +} + +/* Separador fino dentro del panel */ + +.route-panel-divider { + border: none; + border-top: 1px solid rgba(148, 163, 184, .4); + margin: 0.45rem 0; +} + +/* Cuerpo con scroll propio */ + +.route-panel-body { + flex: 1; + overflow-y: auto; + padding-right: 0.15rem; +} + +/* TEXTO Y CHIPS DE LA RUTA */ + +.route-main-text { + font-size: .86rem; + margin-bottom: .4rem; +} + + .route-main-text span.highlight { + color: #facc15; + font-weight: 500; + } + +.chips-row { + display: flex; + flex-wrap: wrap; + gap: .35rem; + font-size: .75rem; +} + +.chip { + display: inline-flex; + align-items: center; + gap: .3rem; + padding: .18rem .55rem; + border-radius: 999px; + border: 1px solid rgba(148,163,184,.5); + background: #020617; +} + +.chip-dot { + width: .5rem; + height: .5rem; + border-radius: 999px; +} + +.chip-dot-start { + background: #22c55e; +} + +.chip-dot-end { + background: #ef4444; +} + +.chip-dot-line { + background: #38bdf8; +} + +.chip-dot-walk { + background: #f97316; +} + +.muted { + color: #9ca3af; +} + +/* LISTA DE TRAMOS TIPO “DIRECCIONES” CON NÚMEROS */ + +.lista-tramos-ruta { + list-style: none; + margin: 0.4rem 0 0; + padding: 0; + font-size: .8rem; +} + + .lista-tramos-ruta li { + display: flex; + align-items: flex-start; + gap: 0.6rem; + margin-bottom: 0.35rem; + } + + .lista-tramos-ruta li .indice-tramo-ruta { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 1.25rem; + width: 1.25rem; + height: 1.25rem; + border-radius: 999px; + background: #1d4ed8; + color: #e5e7eb; + font-size: 0.75rem; + font-weight: 700; + line-height: 1; + margin-top: 0.18rem; + } + + .lista-tramos-ruta li .detalle-tramo-ruta-contenido { + flex: 1 1 auto; + min-width: 0; + } + +/* ===== PANEL DE CONFIGURACIÓN ===== */ + +.history-panel { + position: absolute; + top: 7rem; + left: 1rem; + width: 420px; + max-width: calc(100vw - 2rem); + background: rgba(15,23,42,0.96); + border: 1px solid rgba(148,163,184,.35); + border-radius: .85rem; + padding: .85rem 1rem .8rem 1rem; + color: #e5e7eb; + box-shadow: 0 16px 40px rgba(15,23,42,.9); + backdrop-filter: blur(4px); + z-index: 1100; + font-size: .85rem; +} + +.history-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: .5rem; + margin-bottom: .7rem; +} + + .history-panel-header h5 { + margin: 0; + font-size: .92rem; + font-weight: 600; + letter-spacing: .03em; + color: #9ca3af; + text-transform: uppercase; + } + +.history-panel-body { + display: flex; + flex-direction: column; + gap: .75rem; +} + +.history-section { + display: flex; + flex-direction: column; + gap: .25rem; +} + +.history-section-label { + font-size: .92rem; + font-weight: 700; + color: #f8fafc; +} + +.history-list { + display: flex; + flex-direction: column; + gap: .32rem; + max-height: 184px; + overflow-y: auto; + padding-right: .1rem; +} + +.history-item { + display: flex; + align-items: stretch; + gap: .3rem; +} + +.history-item-main { + flex: 1 1 auto; + min-width: 0; + text-align: left; + border: 1px solid rgba(148,163,184,.28); + background: rgba(15,23,42,.92); + color: #e5e7eb; + border-radius: .7rem; + padding: .42rem .58rem; + font-size: .74rem; + line-height: 1.3; + cursor: pointer; +} + +.history-item-main--numbered { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + column-gap: .35rem; +} + +.history-item-number { + display: block; + min-width: 0; + color: #93c5fd; + font-weight: 700; + text-align: left; +} + +.history-item-text { + display: block; + min-width: 0; +} + + .history-item-main:hover { + border-color: rgba(59,130,246,.75); + background: rgba(30,41,59,.98); + } + +.history-item--selected .history-item-main { + border-color: rgba(34,197,94,.7); + background: rgba(21,128,61,.18); +} + +.history-item-action, +.history-item-delete { + flex: 0 0 32px; + width: 32px; + border: 1px solid rgba(148,163,184,.28); + background: rgba(15,23,42,.92); + color: #cbd5e1; + border-radius: .7rem; + cursor: pointer; + font-size: .9rem; + line-height: 1; +} + +.history-item-action:hover { + color: #bfdbfe; + border-color: rgba(59,130,246,.75); + background: rgba(30,64,175,.16); +} + +.history-item-delete:hover { + color: #fecaca; + border-color: rgba(239,68,68,.7); + background: rgba(127,29,29,.24); +} + +.history-empty { + font-size: .75rem; + color: #94a3b8; + padding: .25rem 0 .1rem; +} + +.history-feedback { + font-size: .76rem; + color: #93c5fd; +} + +.history-feedback--error { + color: #fca5a5; +} + +.config-panel { + position: absolute; + bottom: 2.4rem; + left: .85rem; + width: 292px; + max-height: calc(100vh - 4rem); + overflow-y: auto; + box-sizing: border-box; + background: rgba(15,23,42,0.96); + border: 1px solid rgba(148,163,184,.35); + border-radius: .75rem; + padding: .85rem 1rem .8rem 1rem; + color: #e5e7eb; + box-shadow: 0 16px 40px rgba(15,23,42,.9); + backdrop-filter: blur(4px); + z-index: 1000; + font-size: .85rem; +} + + .config-panel h5 { + margin: 0 0 .65rem 0; + font-size: .92rem; + font-weight: 600; + letter-spacing: .03em; + color: #9ca3af; + text-transform: uppercase; + } + +.config-row { + display: grid; + grid-template-columns: 1fr 140px; /* label | campo */ + gap: .75rem; + align-items: center; + margin-bottom: .62rem; +} + + + .config-row label { + font-size: .8rem; + color: #cbd5e1; + } + +.config-input { + width: 90px; + padding: .35rem .5rem; + background: #0f172a; + color: #e2e8f0; + font-size: .8rem; + border-radius: .5rem; + border: 1px solid rgba(148,163,184,.5); + text-align: left; + outline: none; + transition: border-color .15s, background .15s; +} + + .config-input:focus { + border-color: #38bdf8; + background: #1e293b; + } + + .config-input:disabled, + .config-input[readonly] { + opacity: .48; + cursor: not-allowed; + color: #94a3b8; + background: rgba(15,23,42,.45); + border-color: rgba(100,116,139,.28); + filter: saturate(.65) blur(.15px); + } + +.config-panel .btn-ghost { + margin-top: .1rem; + width: 100%; + padding: .42rem .55rem; + border-radius: .75rem; + font-size: .82rem; + background: #1d4ed8; + border: 1px solid rgba(148,163,184,.4); + color: #fff; + text-align: center; + transition: background .2s, box-shadow .2s; +} + + .config-panel .btn-ghost:hover { + background: #2563eb; + box-shadow: 0 0 10px rgba(37,99,235,.4); + } + + .config-panel .btn-ghost.btn-ghost--secondary { + background: #334155; + border-color: rgba(148,163,184,.35); + color: #e5e7eb; + } + + .config-panel .btn-ghost.btn-ghost--secondary:hover { + background: #475569; + box-shadow: 0 0 10px rgba(71,85,105,.35); + } + +.config-actions-row { + display: flex; + align-items: center; + gap: .44rem; + margin-top: .1rem; +} + +.config-actions-row .btn-ghost { + flex: 1 1 0; + width: 0; + margin-top: 0; + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + } + +.custom-modal-backdrop { + position: fixed; + inset: 0; + z-index: 5000; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: rgba(2, 6, 23, .58); + backdrop-filter: blur(6px); +} + +.custom-modal-card { + width: min(420px, calc(100vw - 2rem)); + border-radius: 18px; + border: 1px solid rgba(148,163,184,.26); + background: linear-gradient(180deg, rgba(15,23,42,.98), rgba(17,24,39,.98)); + color: #e5e7eb; + box-shadow: 0 22px 60px rgba(2,6,23,.55); + padding: 1rem 1rem .9rem; +} + +.custom-modal-title { + font-size: 1rem; + font-weight: 700; + color: #f8fafc; + margin-bottom: .45rem; +} + +.custom-modal-text { + font-size: .9rem; + line-height: 1.45; + color: #cbd5e1; + margin-bottom: .9rem; +} + +.custom-modal-actions { + display: flex; + justify-content: flex-end; + gap: .5rem; +} + +.custom-modal-actions .btn-ghost { + min-width: 110px; +} + +/* Botón flotante para abrir la configuración avanzada */ +.config-toggle-btn { + position: absolute; + bottom: 2.4rem; + left: .85rem; + width: 2.5rem; + height: 2.5rem; + border-radius: 999px; + border: 1px solid rgba(148,163,184,.6); + background: #111827; + color: #e5e7eb; + font-size: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 10px 25px rgba(15,23,42,.8); + z-index: 1300; +} + + .config-toggle-btn:hover { + background: #1d4ed8; + color: #fff; + } + +/* Cabecera del panel de configuración con botón de cierre */ +.config-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: .5rem; + margin-bottom: .75rem; +} + + .config-panel-header h5 { + margin: 0; + } + +.config-close-btn { + border: none; + background: transparent; + color: #9ca3af; + font-size: 1.1rem; + cursor: pointer; + padding: 0; + line-height: 1; +} + + .config-close-btn:hover { + color: #e5e7eb; + } + + +.coords-bar { + position: absolute; + bottom: 6px; + left: 10px; + padding: 4px 8px; + background: #000000; /* negro total */ + color: #fff; + font-size: 12px; + border-radius: 4px; + pointer-events: none; /* para no bloquear clics en el mapa */ + z-index: 500; +} + +.rt-arrow-icon { + background: transparent; + border: none; +} + +.rt-arrow { + position: relative; + transform-origin: 50% 50%; +} + + .rt-arrow::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -75%); + width: 0; + height: 0; + /* tu punta */ + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 18px solid var(--arrow-color, rgba(239, 68, 68, 0.95)); + /* ✅ borde negro (simulado) */ + filter: drop-shadow( 2px 0 0 #000) drop-shadow(-2px 0 0 #000) drop-shadow( 0 1px 0 #000) drop-shadow( 0 -1px 0 #000); + } + + + .rt-arrow::after { + content: ""; + position: absolute; + left: 50%; + top: 60%; + transform: translateX(-50%); + /* Cuerpo un poco más largo */ + width: 4px; + height: 10px; + background: var(--arrow-color, rgba(239, 68, 68, 0.95)); + border-radius: 1px; + } + + .rt-arrow--dashed::before { + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 18px solid var(--arrow-color, rgba(239, 68, 68, 0.95)); + filter: drop-shadow( 2px 0 0 #000) drop-shadow(-2px 0 0 #000) drop-shadow( 0 1px 0 #000) drop-shadow( 0 -1px 0 #000); + } + + .rt-arrow--dashed .rt-arrow-dash-head { + display: none; + } + + .rt-arrow--dashed::after { + background: repeating-linear-gradient(180deg, var(--arrow-color, rgba(239, 68, 68, 0.95)) 0 4px, #fff 4px 8px); + box-shadow: 0 0 0 1px #000; + } + + + + + + +/* Ocultar spinners nativos */ +.config-input::-webkit-outer-spin-button, +.config-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.config-input[type="number"] { + -moz-appearance: textfield; +} + +.num-field { + position: relative; + width: 140px; /* igual que la 2ª columna */ + min-width: 140px; +} + + .num-field .config-input { + width: 100% !important; /* pisa el width:90px anterior */ + box-sizing: border-box; + padding-right: 2.4rem; /* espacio para ?? */ + } +.num-inline { + display: grid; + grid-template-columns: 1fr 1.6rem 1.6rem; /* input | ? | ? */ + gap: .25rem; + align-items: center; + min-width: 0; +} +.num-inline .config-input { + width: 100% !important; /* pisa width:90px anterior */ + min-width: 0; + box-sizing: border-box; + } + +.config-dual-inline { + display: grid; + grid-template-columns: 1fr 1fr; + gap: .5rem; + align-items: center; + min-width: 0; +} + + .config-dual-inline .config-input { + width: 100% !important; + min-width: 0; + box-sizing: border-box; + text-align: center; + } + +.config-dual-field { + display: grid; + grid-template-columns: auto minmax(0, 4.5rem); + gap: .3rem; + align-items: center; + min-width: 0; +} + + .config-dual-field span { + font-size: .7rem; + color: #94a3b8; + line-height: 1; + text-align: right; + white-space: nowrap; + } + + .config-dual-field .config-input { + max-width: 4.5rem; + justify-self: end; + } + +.config-dual-inline--bare { + grid-template-columns: repeat(2, minmax(0, 4.2rem)); + justify-content: end; + gap: .35rem; +} + + .config-dual-inline--bare .config-input { + max-width: none; + text-align: center; + padding-left: .35rem; + padding-right: .35rem; + } + + .config-dual-inline--bare .config-input::placeholder { + color: #94a3b8; + opacity: 1; + font-size: .72rem; + } + +.config-dual-labeled { + display: grid; + grid-template-columns: repeat(2, minmax(0, 4.2rem)); + justify-content: end; + gap: .35rem; + min-width: 0; +} + +.config-dual-labeled__field { + display: grid; + gap: .18rem; + min-width: 0; +} + +.config-dual-labeled__field span { + color: #94a3b8; + font-size: .62rem; + line-height: 1; + text-align: center; + white-space: nowrap; +} + +.config-dual-labeled__field .config-input { + width: 100% !important; + min-width: 0; + box-sizing: border-box; + padding-left: .35rem; + padding-right: .35rem; + text-align: center; +} + + + +.num-stepper { + position: absolute; + right: .25rem; /* un pelín más metido */ + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: .2rem; +} + + +.step-btn { + width: 1.5rem; + height: 1.05rem; + border-radius: .3rem; + border: 1px solid rgba(148,163,184,.45); + background: rgba(30,41,59,.85); + color: #e5e7eb; + font-size: .6rem; + line-height: 1; + cursor: pointer; + padding: 0; +} + + .step-btn:hover { + background: rgba(37,99,235, .9); + border-color: rgba(59,130,246,.9); + } + + .step-btn:disabled { + opacity: .42; + cursor: not-allowed; + color: #94a3b8; + background: rgba(30,41,59,.45); + border-color: rgba(100,116,139,.25); + filter: saturate(.6) blur(.15px); + } + + .step-btn:disabled:hover { + background: rgba(30,41,59,.45); + border-color: rgba(100,116,139,.25); + } + + + +/* ✅ Solo esta fila: label arriba + control abajo, ancho completo */ +.config-row.config-row-full { + grid-template-columns: 1fr !important; +} + +.config-row-map-opacity { + margin-top: .7rem !important; + margin-bottom: 0; +} + +/* Asegura que el contenedor derecho pueda encoger y no desborde */ +.config-row > * { + min-width: 0; +} + +/* Ajuste del range: ahora sí, columnas estables */ +.line-filter { + max-width: 100%; + min-width: 0; +} + +.modelos-horarios { + display: grid; + gap: .42rem; + min-width: 0; +} + +.modelos-horarios__toggle { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: .42rem; + width: 100%; + min-height: 34px; + padding: .3rem .46rem; + border: 1px solid rgba(148, 163, 184, .42); + border-radius: 6px; + background: rgba(15, 23, 42, .78); + color: #e5e7eb; + cursor: pointer; + text-align: left; +} + +.modelos-horarios__toggle:hover { + border-color: rgba(56, 189, 248, .75); + background: rgba(30, 41, 59, .9); +} + +.modelos-horarios__toggle-label { + color: #cbd5e1; + font-size: .78rem; + font-weight: 600; + white-space: nowrap; +} + +.modelos-horarios__toggle-action { + align-self: center; + padding: .18rem .45rem; + border-radius: 5px; + background: rgba(37, 99, 235, .86); + color: #f8fafc; + font-size: .72rem; + font-weight: 700; +} + +.modelos-horarios-modal-backdrop { + z-index: 5600; +} + +.custom-modal-card.modelos-horarios-modal { + width: min(540px, calc(100vw - 2rem)); + padding: .9rem; +} + +.modelos-horarios-modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: .75rem; + margin-bottom: .75rem; +} + +.modelos-horarios-modal__activo { + max-width: 420px; + overflow: hidden; + color: #93c5fd; + font-size: .78rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelos-horarios-modal__close { + margin-top: .1rem; + font-size: 1.25rem; +} + +.modelos-horarios__panel { + display: grid; + gap: .5rem; + min-width: 0; + padding: .55rem; + border: 1px solid rgba(148, 163, 184, .28); + border-radius: 6px; + background: rgba(15, 23, 42, .62); +} + +.modelos-horarios__panel--modal { + gap: .65rem; + padding: .7rem; + background: rgba(15, 23, 42, .5); +} + +.modelos-horarios__field { + display: grid; + gap: .22rem; + color: #cbd5e1; + font-size: .74rem; +} + +.modelos-horarios__activo { + min-width: 0; + color: #93c5fd; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelos-horarios__select { + width: 100% !important; + min-width: 0; +} + +.modelos-horarios__meta { + display: grid; + gap: .25rem; + max-height: 78px; + overflow: auto; + margin-top: .35rem; + padding-right: .15rem; +} + +.modelos-horarios__meta--modal { + max-height: 150px; +} + +.modelos-horarios__details { + min-width: 0; + color: #94a3b8; + font-size: .74rem; +} + +.modelos-horarios__details summary { + cursor: pointer; + user-select: none; +} + +.modelos-horarios__item { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: .08rem; + padding: .25rem .35rem; + border-radius: 5px; + color: #94a3b8; + background: rgba(30, 41, 59, .5); + font-size: .72rem; +} + +.modelos-horarios__item span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelos-horarios__item--activo { + color: #e0f2fe; + background: rgba(14, 116, 144, .42); +} + +.modelos-horarios__actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: .4rem; +} + +.modelos-horarios__button { + min-height: 31px; + border-radius: 6px; + border: 1px solid rgba(148, 163, 184, .38); + background: rgba(51, 65, 85, .92); + color: #e5e7eb; + font-size: .78rem; + font-weight: 700; + cursor: pointer; +} + +.modelos-horarios__button:hover { + background: rgba(71, 85, 105, .96); +} + +.modelos-horarios__button--primary { + background: rgba(37, 99, 235, .94); + color: #fff; +} + +.modelos-horarios__button--primary:hover { + background: rgba(59, 130, 246, .98); +} + +.modelos-horarios__maintenance { + display: flex; + justify-content: flex-end; +} + +.modelos-horarios__button--danger { + padding: 0 .7rem; + border-color: rgba(248, 113, 113, .46); + background: rgba(127, 29, 29, .86); + color: #fee2e2; +} + +.modelos-horarios__button--danger:hover { + background: rgba(185, 28, 28, .92); +} + +.modelos-horarios__button:disabled, +.modelos-horarios__toggle:disabled { + opacity: .52; + cursor: not-allowed; +} + +.modelos-horarios__upload { + display: grid; + gap: .25rem; + min-width: 0; + color: #cbd5e1; + font-size: .78rem; +} + +.modelos-horarios__upload input { + max-width: 100%; + color: #cbd5e1; + font-size: .72rem; +} + +.modelos-horarios__upload input::file-selector-button { + margin-right: .45rem; + padding: .24rem .48rem; + border: 1px solid rgba(148, 163, 184, .38); + border-radius: 5px; + background: rgba(51, 65, 85, .92); + color: #e5e7eb; + cursor: pointer; +} + +.modelos-horarios__mensaje { + color: #93c5fd; + font-size: .74rem; + line-height: 1.25; +} + +.line-filter__range { + display: grid; + grid-template-columns: auto 1fr auto 1fr; /* Rango | min | a | max */ + gap: 8px; + align-items: center; + max-width: 100%; + min-width: 0; +} + + .line-filter__range .config-input { + width: 100% !important; /* pisa el 90px */ + min-width: 0; + } + +.line-filter__range--segundos-parada { + grid-template-columns: auto minmax(120px, 150px); + margin-top: .5rem; +} + +.line-filter__selector-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: .45rem; +} + +.line-filter__dropdown { + position: relative; + margin-top: .45rem; +} + +.line-filter__summary { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 34px; + padding: .35rem .55rem; + border: 1px solid rgba(148, 163, 184, .45); + border-radius: 6px; + background: rgba(15, 23, 42, .78); + color: #e5e7eb; + font-size: .84rem; + cursor: pointer; + user-select: none; +} + +.line-filter__summary--readonly { + margin-top: .45rem; + cursor: default; + opacity: .72; +} + +.line-filter__dropdown-panel { + position: absolute; + top: calc(100% + .35rem); + left: 0; + right: 0; + z-index: 30; + padding: .55rem; + border: 1px solid rgba(148, 163, 184, .42); + border-radius: 6px; + background: rgba(15, 23, 42, .94); + box-shadow: 0 14px 28px rgba(15, 23, 42, .26); +} + +.line-filter__list { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 132px; + overflow: auto; + padding: 2px; +} + +.line-option { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 28px; + padding: .22rem .48rem; + border: 1px solid rgba(148, 163, 184, .5); + border-radius: 6px; + background: rgba(15, 23, 42, .72); + color: #e5e7eb; + font-size: .82rem; + line-height: 1; + cursor: pointer; + user-select: none; +} + +.line-option input { + width: 13px; + height: 13px; + margin: 0; +} + +.line-option--active { + border-color: rgba(56, 189, 248, .95); + background: rgba(14, 116, 144, .52); + color: #f8fafc; +} + +.config-mini-button { + min-height: 28px; + padding: .22rem .55rem; + border: 1px solid rgba(148, 163, 184, .45); + border-radius: 6px; + background: rgba(15, 23, 42, .85); + color: #e5e7eb; + font-size: .78rem; + line-height: 1; +} + +.config-mini-button:hover { + border-color: rgba(56, 189, 248, .85); + color: #f8fafc; +} + +/* En muy estrecho, que pase a 2 columnas */ +@media (max-width: 520px) { + .line-filter__range { + grid-template-columns: 1fr 1fr; + } +} + + + + +.rt-elev-label { + background: none; /* 🔑 sin tarjeta */ + color: #facc15; /* amarillo suave */ + font-size: .6rem; + font-weight: 700; + text-shadow: 0 1px 3px rgba(0,0,0,.85); /* legible sobre mapa */ + padding: 0; + border: none; + box-shadow: none; + user-select: none; + pointer-events: none; +} +.rt-elev-label:before { display:none; } + + +/* Tooltip Leaflet sin tarjeta */ +.leaflet-tooltip.rt-elev-label { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; +} + + .leaflet-tooltip.rt-elev-label:before { + display: none !important; + } + +/* Número negro con contorno blanco */ +.rt-elev-label { + color: #020617 !important; /* negro azulado */ + font-size: .6rem !important; + font-weight: 700 !important; + /* contorno blanco */ + text-shadow: -1px -1px 0 rgba(255,255,255,.95), 1px -1px 0 rgba(255,255,255,.95), -1px 1px 0 rgba(255,255,255,.95), 1px 1px 0 rgba(255,255,255,.95); + user-select: none; + pointer-events: none; +} + + + + +.leaflet-control-zoom-indicator { + background: rgba(15,23,42,.96); + color: #e5e7eb; + border: 1px solid rgba(148,163,184,.55); + border-radius: .4rem; + padding: .15rem .45rem; + font-size: .8rem; + font-weight: 700; + line-height: 1.2; + margin: 0; /* se alinea con los botones */ + box-shadow: 0 10px 25px rgba(15,23,42,.6); +} + + + + +.elev-bubble { + position: fixed; + left: 14px; + bottom: 50px; /* MISMA referencia que el popup */ + top: auto; /* anulamos el top antiguo */ + z-index: 9999; + display: inline-flex; + align-items: center; + gap: .5rem; + padding: .55rem .75rem; + border-radius: 999px; + background: rgba(20,20,20,.92); + border: 1px solid rgba(255,255,255,.12); + color: #fff; + font-size: .85rem; + cursor: pointer; + box-shadow: 0 10px 25px rgba(0,0,0,.35); +} + + .elev-bubble:hover { + transform: translateY(-1px); + } + + +.popup-elev-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: .5rem; + margin-bottom: .25rem; +} + +.popup-elev-title { + flex: 1 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.popup-elev-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: .35rem; +} + + .popup-elev-actions .btn-ghost { + white-space: nowrap; + padding: .15rem .45rem; + font-size: .85rem; + line-height: 1; + } + +/* Burbuja de elevación arriba derecha */ +.elev-bubble-topright { + position: fixed; + top: 70px; /* ajusta según altura real del header */ + right: 14px; + z-index: 1200; +} + + + +/* Si tu header es más alto/bajo, sube/baja estos top */ +@media (max-width: 720px) { + .popup-elev-topright { + width: calc(100vw - 28px); + top: 110px; + } +} + + +.rutas-header-linea { + display: grid; + grid-template-columns: minmax(240px, auto) minmax(0, 1fr) auto; + align-items: center; + gap: .75rem; +} + +.header-left { + justify-self: start; + min-width: 0; +} + +.header-left .titulo-app, +.header-left small { + white-space: nowrap; +} + +.header-center { + justify-self: start; + display: flex; + align-items: center; + gap: .45rem; + white-space: nowrap; + min-width: 0; + overflow: hidden; +} + +.header-right { + justify-self: end; + display: flex; + align-items: center; + gap: .4rem; + white-space: nowrap; + flex-wrap: nowrap; +} + +.header-center-input { + width: clamp(148px, 15vw, 220px) !important; + min-width: 0; +} + +.header-center .btn-ghost, +.header-center .line-filter__toggle, +.header-center .nowrap, +.header-right .btn-ghost, +.header-right .pill-live { + flex: 0 0 auto; +} + + +.nowrap { + white-space: nowrap; +} + +/* Estado Punto Z (amarillo) */ +.pill-warn { + background: rgba(250,204,21,.12); + border-color: rgba(250,204,21,.5); + color: #fde68a; +} + + .pill-warn .pill-dot { + background: #facc15; /* AMARILLO */ + box-shadow: 0 0 0 3px rgba(250,204,21,.35); + } + + +.popup-elev-topright { + --elev-table-width: 412px; + position: fixed; + left: 14px; + bottom:40px; /* mismo “offset” pero abajo */ + right: auto; + top: auto; + transform: none; + width: max-content; + min-width: 0; + max-width: calc(100vw - 28px); + z-index: 1200; +} + +.popup-elev-topright .popup-elev-title { + max-width: 150px; +} + +.popup-elev-topright .popup-parada-body { + max-width: none; +} + +.popup-elev-table-wrap { + width: var(--elev-table-width); + max-width: calc(100vw - 56px); + max-height: 300px; + overflow-y: auto; + overflow-x: hidden; +} + +.popup-elev-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: #111827; + box-shadow: 0 1px 0 rgba(148,163,184,.35); +} + +.popup-elev-table thead th:first-child { + z-index: 3; +} +/* ===== SLIDER (range) limpio para el panel ===== */ +.config-range { + width: 100%; + margin: 0; + padding: 0; /* 🔑 evita que “no llegue” */ + background: transparent; + border: none; + outline: none; + -webkit-appearance: none; + appearance: none; + height: 18px; /* área cómoda de click */ +} + + /* TRACK (Chrome/Edge/Safari) */ + .config-range::-webkit-slider-runnable-track { + height: 6px; + border-radius: 999px; + background: rgba(148,163,184,.35); + border: 1px solid rgba(148,163,184,.35); + } + + /* THUMB (Chrome/Edge/Safari) */ + .config-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 999px; + background: #38bdf8; + border: 2px solid #0f172a; + margin-top: -4px; /* centra sobre track de 6px */ + } + + /* TRACK (Firefox) */ + .config-range::-moz-range-track { + height: 6px; + border-radius: 999px; + background: rgba(148,163,184,.35); + border: 1px solid rgba(148,163,184,.35); + } + + /* THUMB (Firefox) */ + .config-range::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 999px; + background: #38bdf8; + border: 2px solid #0f172a; + } + + /* evita borde raro en firefox */ + .config-range::-moz-focus-outer { + border: 0; + } + +.pill-clickable { + cursor: pointer; + border: 0; +} + +/* Visible => verde */ +.pill-ok .pill-dot { + background: #22c55e; +} +/* verde */ +.pill-ok { + border-color: rgba(34,197,94,.6); +} + +/* Oculto => rojo */ +.pill-bad .pill-dot { + background: #ef4444; +} +/* rojo */ +.pill-bad { + border-color: rgba(239,68,68,.6); +} +.pill-ok { + background: rgba(34,197,94,.12); +} + +.pill-bad { + background: rgba(239,68,68,.12); +} + + + +.gm-search-panel { + position: absolute; /* si ya lo tienes, deja tu estilo actual */ + /* mantén tus estilos actuales */ +} + +.gm-search-panel-actions { + display: flex; + justify-content: flex-end; + margin-bottom: .35rem; +} + +.gm-search-hide-btn { + padding: .2rem .45rem; + line-height: 1; + font-size: .95rem; +} + +/* botón flotante cuando el buscador está oculto */ +.gm-search-fab { + position: absolute; + top: 14px; + right: 14px; /* ✅ derecha */ + left: auto; /* ✅ anulamos izquierda si estaba */ + z-index: 1000; + border: 1px solid rgba(255,255,255,.18); + background: rgba(15, 23, 42, .92); + color: #fff; + border-radius: 999px; + width: 42px; + height: 42px; + display: grid; + place-items: center; + cursor: pointer; + box-shadow: 0 8px 20px rgba(0,0,0,.35); +} + + .gm-search-fab:hover { + transform: translateY(-1px); + } + + + +/* 3 columnas: "Fecha:" | date | time (igual idea que Rango: min/max) */ +.line-filter__range--fecha { + grid-template-columns: minmax(0, 1.12fr) minmax(0, 0.78fr) auto !important; + gap: 6px; + align-items: center; +} + +/* Igualar inputs date/time al look de .config-input */ +.config-input[type="date"], +.config-input[type="time"] { + width: 100% !important; /* como los selects dentro de line-filter__range */ + min-width: 0; + box-sizing: border-box; + height: 32px; /* ajusta a tu “alto estándar” */ + padding: .35rem .5rem; /* igual que .config-input */ + font-size: .8rem; + line-height: 1.1; + background: #0f172a; + color: #e2e8f0; + border: 1px solid rgba(148,163,184,.5); + border-radius: .5rem; + -webkit-appearance: none; + appearance: none; + color-scheme: dark; +} + + /* En iOS/Safari a veces mete padding raro: lo neutralizamos */ + .config-input[type="date"]::-webkit-datetime-edit, + .config-input[type="time"]::-webkit-datetime-edit { + padding: 0; + } + + .config-input[type="date"]::-webkit-calendar-picker-indicator, + .config-input[type="time"]::-webkit-calendar-picker-indicator, + .config-input[type="time"]::-webkit-time-picker-indicator { + filter: brightness(0) invert(1); + opacity: 1; + cursor: pointer; + } + +.config-input-date { + min-width: 0 !important; +} + +.config-input-time { + min-width: 0 !important; +} + +.btn-ghost--tiny { + min-width: 0; + padding: .35rem .55rem; + font-size: .78rem; + line-height: 1; + white-space: nowrap; +} diff --git a/RutasDBUS/wwwroot/app.js b/RutasDBUS/wwwroot/app.js new file mode 100644 index 0000000..37b2a4a --- /dev/null +++ b/RutasDBUS/wwwroot/app.js @@ -0,0 +1,1140 @@ +window.initBrilloMapa = function (dotNetHelper) { + document.addEventListener("keydown", function (e) { + if (e.key && e.key.toLowerCase() === "m") { + dotNetHelper.invokeMethodAsync("AlternarBrillo"); + } + }); +}; + +window.initAltInspector = function (dotNetHelper) { + document.addEventListener("keydown", function (e) { + if (e.key === "Z" || e.key === "z") dotNetHelper.invokeMethodAsync("SetInspectorActivo", true); + }, true); + + document.addEventListener("keyup", function (e) { + if (e.key === "Z" || e.key === "z") dotNetHelper.invokeMethodAsync("SetInspectorActivo", false); + }, true); + + window.addEventListener("blur", function () { + dotNetHelper.invokeMethodAsync("SetInspectorActivo", false); + }); +}; + + + + + + +// ----------------------------------------------------------------------------- +// Leaflet helpers (sin flechas) para RealTimeMap +// ----------------------------------------------------------------------------- +(function () { + function hookLeaflet(L) { + if (!L || window.__leafletHooked) return; + + // Hook SUPER temprano: constructor del mapa + const originalInitialize = L.Map && L.Map.prototype && L.Map.prototype.initialize; + if (typeof originalInitialize === "function") { + L.Map.prototype.initialize = function (...args) { + const res = originalInitialize.apply(this, args); + window.__rtLeafletMap = this; // guardamos el mapa creado + return res; + }; + } + + // Hook adicional: factory L.map (por si acaso) + const originalMapFactory = L.map; + if (typeof originalMapFactory === "function") { + L.map = function (...args) { + const map = originalMapFactory.apply(this, args); + window.__rtLeafletMap = map; + return map; + }; + } + + window.__leafletHooked = true; + console.log("[rt] Leaflet hook OK"); + } + + // Si Leaflet ya existe: + if (window.L) { + hookLeaflet(window.L); + return; + } + + // Si Leaflet se asigna más tarde: definimos setter + let _L = null; + Object.defineProperty(window, "L", { + configurable: true, + enumerable: true, + get() { return _L; }, + set(v) { + _L = v; + // restauramos propiedad normal + try { + delete window.L; + window.L = v; + } catch { /* ignore */ } + + hookLeaflet(v); + } + }); +})(); + + +window.rt = window.rt || {}; + +window.rt.ensureRouteLayer = function () { + const map = window.__rtLeafletMap; + if (!map || !window.L) { + // ayuda para diagnosticar + // console.log("[rt] no map yet", { hasL: !!window.L, hasMap: !!map }); + return false; + } + + if (!window.rt._routeLayer) { + window.rt._routeLayer = window.L.layerGroup().addTo(map); + } + if (!window.rt._routeLines) window.rt._routeLines = {}; + return true; +}; + +window.rt.clearRoutes = function () { + if (!window.rt.ensureRouteLayer()) return; + window.rt._routeLayer.clearLayers(); + window.rt._routeLines = {}; + + if (window.rt._routeStopsLayer) { + window.rt._routeStopsLayer.clearLayers(); + window.rt._routeStopGroups = {}; + } +}; +window.rt.drawRoute = function (id, coords, options) { + if (!window.rt.ensureRouteLayer()) return; + + // borrar línea anterior si existe + if (window.rt._routeLines[id]) { + window.rt._routeLayer.removeLayer(window.rt._routeLines[id]); + delete window.rt._routeLines[id]; + } + + // borrar flecha anterior si existe + const arrowId = id + "__arrow"; + if (window.rt._routeLines[arrowId]) { + window.rt._routeLayer.removeLayer(window.rt._routeLines[arrowId]); + delete window.rt._routeLines[arrowId]; + } + + const color = (options && options.color) || "#000"; + const weight = (options && options.weight) || 4; + const opacity = (options && options.opacity) || 0.9; + + const dashArray = options && options.dashArray; + const lineCap = options && options.lineCap; + const arrowEnd = !!(options && options.arrowEnd); + const arrowClass = dashArray ? "rt-arrow rt-arrow--dashed" : "rt-arrow"; + const arrowInnerHtml = dashArray ? '' : ''; + + const lineOpts = { color, weight, opacity }; + if (dashArray) lineOpts.dashArray = dashArray; + if (lineCap) lineOpts.lineCap = lineCap; + + const line = window.L.polyline(coords, lineOpts); + + // ✅ Hacer la línea clicable y avisar a Blazor (igual que drawPreviewLine) + line.on("click", function (e) { + try { + if (e && e.originalEvent) { + if (e.originalEvent.preventDefault) e.originalEvent.preventDefault(); + if (e.originalEvent.stopPropagation) e.originalEvent.stopPropagation(); + if (e.originalEvent.stopImmediatePropagation) e.originalEvent.stopImmediatePropagation(); + } + + if (window.rt._dotNetHelper) { + window.rt._dotNetHelper.invokeMethodAsync("OnPreviewItinerarioClick", id); + } + } catch (e) { } + }); + + line.addTo(window.rt._routeLayer); + window.rt._routeLines[id] = line; + + // Flecha SOLO al final + if (arrowEnd && coords && coords.length >= 2) { + const a = coords[coords.length - 2]; + const b = coords[coords.length - 1]; + + // bearing aprox (en grados) + const lat1 = a[0] * Math.PI / 180, lon1 = a[1] * Math.PI / 180; + const lat2 = b[0] * Math.PI / 180, lon2 = b[1] * Math.PI / 180; + const y = Math.sin(lon2 - lon1) * Math.cos(lat2); + const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1); + let brng = Math.atan2(y, x) * 180 / Math.PI; + brng = (brng + 360) % 360; + + const size = Math.max(14, Math.min(28, weight * 4)); // tamaño ligado al grosor + + const icon = window.L.divIcon({ + className: "rt-arrow-icon", + html: `
${arrowInnerHtml}
`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2] + }); + + const marker = window.L.marker(b, { icon, interactive: false }); + marker.addTo(window.rt._routeLayer); + window.rt._routeLines[arrowId] = marker; + } +}; + +window.rt.fitRoutes = function () { + const map = window.__rtLeafletMap; + if (!map || !window.rt._routeLayer) return; + + const bounds = window.rt._routeLayer.getBounds(); + if (bounds && bounds.isValid()) { + map.fitBounds(bounds, { padding: [30, 30] }); + } +}; + +window.rt = window.rt || {}; + +window.rt.setZoomLimits = function (minZoom, maxZoom) { + const map = window.__rtLeafletMap; + if (!map || !window.L) return; + + map.options.minZoom = minZoom; + map.options.maxZoom = maxZoom; + map.setMinZoom(minZoom); + map.setMaxZoom(maxZoom); + + map.eachLayer(l => { + if (!l) return; + + // límites generales + if (typeof l.setMaxZoom === "function") l.setMaxZoom(maxZoom); + if (typeof l.setMinZoom === "function") l.setMinZoom(minZoom); + + // ✅ SOLO TileLayer + if (l instanceof window.L.TileLayer) { + l.options.maxNativeZoom = 18; // tiles reales hasta 18 + l.options.maxZoom = maxZoom; // permitimos 19 + try { l.redraw(); } catch (e) { } + } + }); + + try { map.invalidateSize(); } catch (e) { } + + if (window.rt.addZoomIndicator) window.rt.addZoomIndicator(); +}; + + +window.rt = window.rt || {}; + +// guardar referencia a Blazor para callbacks JS -> .NET +window.rt.setDotNetHelper = function (dotNetHelper) { + window.rt._dotNetHelper = dotNetHelper; +}; + + + + +// ----------------------------------------------------------------------------- +// Elev points layer (puntos de elevación + número) +// ----------------------------------------------------------------------------- +window.rt = window.rt || {}; + +window.rt.ensureElevLayer = function () { + const map = window.__rtLeafletMap; + if (!map || !window.L) return false; + + if (!window.rt._elevLayer) { + window.rt._elevLayer = window.L.layerGroup().addTo(map); + } + if (!window.rt._elevGroups) window.rt._elevGroups = {}; + return true; +}; + + +window.rt.clearElevPoints = function (id) { + if (!window.rt.ensureElevLayer()) return; + + if (id) { + const g = window.rt._elevGroups && window.rt._elevGroups[id]; + if (g) { + window.rt._elevLayer.removeLayer(g); + delete window.rt._elevGroups[id]; + } + return; + } + + // sin id => limpiar todo + window.rt._elevLayer.clearLayers(); + window.rt._elevGroups = {}; +}; + +// points: [{ lat, lon, label }] +// options: { radius, opacity, fillOpacity, showLabels, labelEvery } +window.rt.drawElevPoints = function (id, points, options) { + if (!window.rt.ensureElevLayer()) return; + + // borrar lo anterior con ese id + if (window.rt._elevGroups[id]) { + window.rt._elevLayer.removeLayer(window.rt._elevGroups[id]); + delete window.rt._elevGroups[id]; + } + + const radius = (options && options.radius) || 3; + const opacity = (options && options.opacity) || 0.9; + const fillOpacity = (options && options.fillOpacity) || 0.9; + + const showLabels = options && options.showLabels !== false; // default true + const every = (options && options.labelEvery) || 1; // default 1 + + const group = window.L.layerGroup(); + + if (points && points.length) { + for (let i = 0; i < points.length; i++) { + const p = points[i]; + + const m = window.L.circleMarker([p.lat, p.lon], { + radius: radius, + weight: 1, + opacity: opacity, + fillOpacity: fillOpacity + }); + + if (showLabels && every > 0 && (i % every === 0)) { + const txt = (p.label != null ? String(p.label) : String(i)); + + // ------------------------------------------------------- + // ✅ Evitar que el número pise la ruta: + // si el tramo local es "vertical" -> etiqueta a un lado + // si es "horizontal" -> arriba + // ------------------------------------------------------- + let dir = "top"; + let off = [0, -3]; + + const prev = (i > 0) ? points[i - 1] : null; + const next = (i < points.length - 1) ? points[i + 1] : null; + + const a = prev || p; + const b = next || p; + + const dLat = Math.abs((b.lat) - (a.lat)); + const dLon = Math.abs((b.lon) - (a.lon)); + + // tramo más vertical => poner a la derecha + if (dLat > dLon) { + dir = "right"; // puedes cambiar a "left" si prefieres + off = [5, 0]; // separación lateral + } + + m.bindTooltip(txt, { + permanent: true, + direction: dir, + offset: off, + opacity: 0.95, + className: "rt-elev-label" + }); + } + + m.addTo(group); + } + } + + group.addTo(window.rt._elevLayer); + window.rt._elevGroups[id] = group; +}; +window.rt = window.rt || {}; + +window.rt = window.rt || {}; + +window.rt.addZoomIndicator = function () { + const map = window.__rtLeafletMap; + if (!map || !window.L) return; + + // evitar duplicados + if (window.__rtZoomIndicator) return; + + const zoomContainer = map.zoomControl && map.zoomControl._container; + if (!zoomContainer) return; + + // botones nativos + const btnIn = zoomContainer.querySelector(".leaflet-control-zoom-in"); + const btnOut = zoomContainer.querySelector(".leaflet-control-zoom-out"); + if (!btnIn || !btnOut) return; + + // indicador + const div = window.L.DomUtil.create("div", "leaflet-control-zoom-indicator-inset"); + div.innerText = map.getZoom(); + + // que no robe clicks + window.L.DomEvent.disableClickPropagation(div); + window.L.DomEvent.disableScrollPropagation(div); + + // ✅ insertarlo ENTRE + y - + zoomContainer.insertBefore(div, btnOut); + + map.on("zoomend", () => { + div.innerText = map.getZoom(); + }); + + window.__rtZoomIndicator = div; +}; + + + + + + + +window.rt = window.rt || {}; + +window.rt.blockLeafletDoubleClick = function () { + if (window.__rtDblClickBlocked) return; // evitar duplicados + window.__rtDblClickBlocked = true; + + document.addEventListener("dblclick", function (e) { + // si el doble click ocurre dentro de un mapa Leaflet, lo anulamos + const isOnMap = e.target && e.target.closest && e.target.closest(".leaflet-container"); + if (!isOnMap) return; + + e.preventDefault(); + e.stopPropagation(); + // clave: corta el evento incluso para listeners “antes” + if (e.stopImmediatePropagation) e.stopImmediatePropagation(); + return false; + }, true); // CAPTURING: lo pillamos antes que Leaflet +}; + + + + +// ----------------------------------------------------------------------------- +// Preview layer (línea completa) - NO toca la ruta actual +// ----------------------------------------------------------------------------- +window.rt = window.rt || {}; + +window.rt.ensurePreviewLayer = function () { + const map = window.__rtLeafletMap; + if (!map || !window.L) return false; + + if (!window.rt._previewLayer) { + window.rt._previewLayer = window.L.layerGroup().addTo(map); + } + if (!window.rt._previewLines) window.rt._previewLines = {}; + return true; +}; + +window.rt.clearPreview = function () { + if (!window.rt.ensurePreviewLayer()) return; + window.rt._previewLayer.clearLayers(); + window.rt._previewLines = {}; + + // ✅ también limpiar paradas destacadas + if (window.rt._itStopsLayer) { + window.rt._itStopsLayer.clearLayers(); + window.rt._itStopGroups = {}; + } +}; + +window.rt.drawPreviewLine = function (id, coords, options) { + if (!window.rt.ensurePreviewLayer()) return; + + const empty = !coords || coords.length === 0; + const arrowId = id + "__arrow"; + + // toggle: si ya existe, lo quitamos (línea + flecha(s)) + if (window.rt._previewLines[id]) { + window.rt._previewLayer.removeLayer(window.rt._previewLines[id]); + delete window.rt._previewLines[id]; + + if (window.rt._previewLines[arrowId]) { + window.rt._previewLayer.removeLayer(window.rt._previewLines[arrowId]); + delete window.rt._previewLines[arrowId]; + } + + return; + } + + if (empty) return; + + const map = window.__rtLeafletMap; + + const color = (options && options.color) || "#60a5fa"; + const weight = (options && options.weight) || 4; + const opacity = (options && options.opacity) || 0.6; + const dashArray = options && options.dashArray; + const arrowEnd = !!(options && options.arrowEnd); + const arrowClass = dashArray ? "rt-arrow rt-arrow--dashed" : "rt-arrow"; + const arrowInnerHtml = dashArray ? '' : ''; + + const lineOpts = { color, weight, opacity }; + if (dashArray) lineOpts.dashArray = dashArray; + + const line = window.L.polyline(coords, lineOpts); + + // ✅ Hacer la línea clicable y avisar a Blazor + line.on("click", function (e) { + try { + if (e && e.originalEvent) { + if (e.originalEvent.preventDefault) e.originalEvent.preventDefault(); + if (e.originalEvent.stopPropagation) e.originalEvent.stopPropagation(); + if (e.originalEvent.stopImmediatePropagation) e.originalEvent.stopImmediatePropagation(); + } + + if (window.rt._dotNetHelper) { + window.rt._dotNetHelper.invokeMethodAsync("OnPreviewItinerarioClick", id); + } + } catch (e) { } + }); + + line.addTo(window.rt._previewLayer); + window.rt._previewLines[id] = line; + + // ✅ Flecha al FINAL + if (arrowEnd && coords.length >= 2 && map) { + // pane por encima de las paradas + if (!map.getPane("rtArrowPane")) { + const pane = map.createPane("rtArrowPane"); + pane.style.zIndex = 760; // > rtStopsPane (750) + pane.style.pointerEvents = "none"; // no roba clicks + } + + const size = Math.max(14, Math.min(28, weight * 4)); + + function addArrow(from, to, storeId) { + const lat1 = from[0] * Math.PI / 180, lon1 = from[1] * Math.PI / 180; + const lat2 = to[0] * Math.PI / 180, lon2 = to[1] * Math.PI / 180; + + const y = Math.sin(lon2 - lon1) * Math.cos(lat2); + const x = Math.cos(lat1) * Math.sin(lat2) - + Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1); + + let brng = Math.atan2(y, x) * 180 / Math.PI; + brng = (brng + 360) % 360; + + const icon = window.L.divIcon({ + className: "rt-arrow-icon", + html: `
${arrowInnerHtml}
`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2] + }); + + const marker = window.L.marker(to, { + icon, + pane: "rtArrowPane", + interactive: false, + zIndexOffset: 1000 + }); + + marker.addTo(window.rt._previewLayer); + window.rt._previewLines[storeId] = marker; + } + + addArrow(coords[coords.length - 2], coords[coords.length - 1], arrowId); + } +}; + + + +window.rt = window.rt || {}; + +window.rt.getZoom = function () { + const map = window.__rtLeafletMap; + return map ? map.getZoom() : 14; +}; + + + + +window.rt = window.rt || {}; + +// Devuelve true si el click está dentro del círculo (en píxeles) +window.rt.isClickInsideCirclePx = function (clickLat, clickLon, targetLat, targetLon, radiusPx) { + const map = window.__rtLeafletMap; + if (!map || !window.L) return false; + + const pClick = map.latLngToContainerPoint([clickLat, clickLon]); + const pTarget = map.latLngToContainerPoint([targetLat, targetLon]); + + const dx = pClick.x - pTarget.x; + const dy = pClick.y - pTarget.y; + + return (dx * dx + dy * dy) <= (radiusPx * radiusPx); +}; + + + + + +// ----------------------------------------------------------------------------- +// Itinerario stops layer (paradas destacadas) - toggle por id +// ----------------------------------------------------------------------------- +window.rt = window.rt || {}; + +window.rt.ensureItStopsLayer = function () { + const map = window.__rtLeafletMap; + if (!map || !window.L) return false; + + // pane por encima para que queden SOBRE las líneas + if (!map.getPane("rtStopsPane")) { + const pane = map.createPane("rtStopsPane"); + pane.style.zIndex = 750; + } + + if (!window.rt._itStopsLayer) { + window.rt._itStopsLayer = window.L.layerGroup().addTo(map); + } + if (!window.rt._itStopGroups) window.rt._itStopGroups = {}; + return true; +}; + +// stops: [{lat, lon}] +window.rt.toggleItStops = function (id, stops, options) { + if (!window.rt.ensureItStopsLayer()) return; + + // toggle: si existe => quitar + if (window.rt._itStopGroups[id]) { + window.rt._itStopsLayer.removeLayer(window.rt._itStopGroups[id]); + delete window.rt._itStopGroups[id]; + return; + } + + const radius = (options && options.radius) || 7; + const color = (options && options.color) || "#0f172a"; + const fillColor = (options && options.fillColor) || "#22c55e"; + const weight = (options && options.weight) || 2; + const opacity = (options && options.opacity) || 1.0; + const fillOpacity = (options && options.fillOpacity) || 1.0; + + const group = window.L.layerGroup(); + + if (stops && stops.length) { + for (let i = 0; i < stops.length; i++) { + const s = stops[i]; + + const m = window.L.circleMarker([s.lat, s.lon], { + pane: "rtStopsPane", + radius, + color, + weight, + opacity, + fillColor, + fillOpacity, + interactive: true + }); + + if (s.code && window.rt._dotNetHelper) { + m.on("click", function (e) { + try { + if (e && e.originalEvent) { + if (e.originalEvent.preventDefault) e.originalEvent.preventDefault(); + if (e.originalEvent.stopPropagation) e.originalEvent.stopPropagation(); + if (e.originalEvent.stopImmediatePropagation) e.originalEvent.stopImmediatePropagation(); + } + + window.rt._dotNetHelper.invokeMethodAsync("OnHighlightedStopClick", s.code); + } catch (err) { } + }); + } + + m.addTo(group); + } + } + + group.addTo(window.rt._itStopsLayer); + window.rt._itStopGroups[id] = group; +}; + +// (opcional) limpiar todo, por si quieres usarlo en algún sitio +window.rt.clearItStops = function () { + if (!window.rt.ensureItStopsLayer()) return; + window.rt._itStopsLayer.clearLayers(); + window.rt._itStopGroups = {}; +}; + + +// ----------------------------------------------------------------------------- +// Route solution stops layer (paradas destacadas de la solución seleccionada) +// ----------------------------------------------------------------------------- +window.rt = window.rt || {}; + +window.rt.ensureRouteStopsLayer = function () { + const map = window.__rtLeafletMap; + if (!map || !window.L) return false; + + if (!map.getPane("rtRouteStopsPane")) { + const pane = map.createPane("rtRouteStopsPane"); + pane.style.zIndex = 752; + pane.style.pointerEvents = "none"; + } + + if (!window.rt._routeStopsLayer) { + window.rt._routeStopsLayer = window.L.layerGroup().addTo(map); + } + if (!window.rt._routeStopGroups) window.rt._routeStopGroups = {}; + return true; +}; + +window.rt.drawRouteStops = function (id, stops, options) { + if (!window.rt.ensureRouteStopsLayer()) return; + + if (window.rt._routeStopGroups[id]) { + window.rt._routeStopsLayer.removeLayer(window.rt._routeStopGroups[id]); + delete window.rt._routeStopGroups[id]; + } + + if (!stops || !stops.length) return; + + const radius = (options && options.radius) || 8; + const color = (options && options.color) || "#0f172a"; + const fillColor = (options && options.fillColor) || "#22c55e"; + const weight = (options && options.weight) || 2; + const opacity = (options && options.opacity) || 1.0; + const fillOpacity = (options && options.fillOpacity) || 1.0; + + const group = window.L.layerGroup(); + + for (let i = 0; i < stops.length; i++) { + const s = stops[i]; + + const m = window.L.circleMarker([s.lat, s.lon], { + pane: "rtRouteStopsPane", + radius, + color, + weight, + opacity, + fillColor, + fillOpacity, + interactive: true + }); + + if (s.code && window.rt._dotNetHelper) { + m.on("click", function (e) { + try { + if (e && e.originalEvent) { + if (e.originalEvent.preventDefault) e.originalEvent.preventDefault(); + if (e.originalEvent.stopPropagation) e.originalEvent.stopPropagation(); + if (e.originalEvent.stopImmediatePropagation) e.originalEvent.stopImmediatePropagation(); + } + + window.rt._dotNetHelper.invokeMethodAsync("OnHighlightedStopClick", s.code); + } catch (err) { } + }); + } + + m.addTo(group); + } + + group.addTo(window.rt._routeStopsLayer); + window.rt._routeStopGroups[id] = group; +}; + +window.rt.clearRouteStops = function (id) { + if (!window.rt.ensureRouteStopsLayer()) return; + + if (id) { + const group = window.rt._routeStopGroups && window.rt._routeStopGroups[id]; + if (group) { + window.rt._routeStopsLayer.removeLayer(group); + delete window.rt._routeStopGroups[id]; + } + return; + } + + window.rt._routeStopsLayer.clearLayers(); + window.rt._routeStopGroups = {}; +}; + + +// ----------------------------------------------------------------------------- +// Origin / destination draggable markers +// ----------------------------------------------------------------------------- +window.rt = window.rt || {}; + +window.rt.ensureEndpointLayer = function () { + const map = window.__rtLeafletMap; + if (!map || !window.L) return false; + + if (!map.getPane("rtEndpointPane")) { + const pane = map.createPane("rtEndpointPane"); + pane.style.zIndex = 780; + } + + if (!window.rt._endpointLayer) { + window.rt._endpointLayer = window.L.layerGroup().addTo(map); + } + if (!window.rt._endpointMarkers) window.rt._endpointMarkers = {}; + return true; +}; + +window.rt.setEndpointMarker = function (kind, lat, lon, options) { + if (!window.rt.ensureEndpointLayer()) return; + + if (window.rt._endpointMarkers[kind]) { + window.rt._endpointLayer.removeLayer(window.rt._endpointMarkers[kind]); + delete window.rt._endpointMarkers[kind]; + } + + if (typeof lat !== "number" || typeof lon !== "number") return; + + const color = (options && options.color) || "#22c55e"; + const title = (options && options.title) || ""; + const cssKind = kind === "destination" ? "destination" : "origin"; + const size = (options && options.size) || 18; + + const icon = window.L.divIcon({ + className: "rt-endpoint-icon", + html: `
`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2] + }); + + const marker = window.L.marker([lat, lon], { + pane: "rtEndpointPane", + draggable: true, + autoPan: true, + icon, + title + }); + + const stopEvent = function (e) { + if (!e || !e.originalEvent) return; + if (e.originalEvent.preventDefault) e.originalEvent.preventDefault(); + if (e.originalEvent.stopPropagation) e.originalEvent.stopPropagation(); + if (e.originalEvent.stopImmediatePropagation) e.originalEvent.stopImmediatePropagation(); + }; + + marker.on("click", stopEvent); + marker.on("mousedown", stopEvent); + + marker.on("dragend", function (e) { + try { + const pos = e.target.getLatLng(); + if (window.rt._dotNetHelper) { + window.rt._dotNetHelper.invokeMethodAsync("OnEndpointDragged", kind, pos.lat, pos.lng); + } + } catch (err) { } + }); + + marker.addTo(window.rt._endpointLayer); + window.rt._endpointMarkers[kind] = marker; +}; + +window.rt.clearEndpointMarkers = function (kind) { + if (!window.rt.ensureEndpointLayer()) return; + + if (kind) { + const marker = window.rt._endpointMarkers && window.rt._endpointMarkers[kind]; + if (marker) { + window.rt._endpointLayer.removeLayer(marker); + delete window.rt._endpointMarkers[kind]; + } + return; + } + + window.rt._endpointLayer.clearLayers(); + window.rt._endpointMarkers = {}; +}; + + +window.rt = window.rt || {}; + +window.rt.scrollToHorarioProximo = function () { + const el = document.getElementById("horario-proximo-anchor"); + if (!el) return; + + el.scrollIntoView({ + block: "center", + behavior: "auto" + }); +}; + +window.rt.initDraggablePopups = function () { + if (window.rt._draggablePopupsReady) return; + + window.rt._draggablePopupsReady = true; + window.rt._draggablePopupPositions = window.rt._draggablePopupPositions || {}; + window.rt._draggablePopupZCounter = Number.isFinite(window.rt._draggablePopupZCounter) + ? window.rt._draggablePopupZCounter + : 1200; + + const padding = 8; + const edgePeekX = 28; + const edgePeekY = 42; + + function dragKey(popup) { + return popup && (popup.dataset.dragKey || popup.id || ""); + } + + function clamp(value, min, max) { + if (max < min) return min; + return Math.min(Math.max(value, min), max); + } + + function parseZ(value) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; + } + + function ensurePopupZIndex(popup) { + if (!popup) return; + + const inlineZ = parseZ(popup.style.zIndex); + if (inlineZ != null) { + window.rt._draggablePopupZCounter = Math.max(window.rt._draggablePopupZCounter, inlineZ); + return; + } + + const computedZ = parseZ(window.getComputedStyle(popup).zIndex); + if (computedZ != null) { + window.rt._draggablePopupZCounter = Math.max(window.rt._draggablePopupZCounter, computedZ); + } + + window.rt._draggablePopupZCounter += 1; + popup.style.zIndex = String(window.rt._draggablePopupZCounter); + } + + function bringPopupToFront(popup) { + if (!popup) return; + + ensurePopupZIndex(popup); + window.rt._draggablePopupZCounter += 1; + popup.style.zIndex = String(window.rt._draggablePopupZCounter); + } + + function clampPosition(left, top, width, height) { + return { + left: clamp(left, edgePeekX - width, window.innerWidth - edgePeekX), + top: clamp(top, edgePeekY - height, window.innerHeight - edgePeekY) + }; + } + + function constrainFreeHeight(popup, height) { + if (!popup || !popup.classList.contains("route-panel-overlay")) return null; + + const maxHeight = Math.max(260, window.innerHeight - (padding * 2)); + return Math.min(height || maxHeight, maxHeight); + } + + function isHandleRecoverablyHidden(popup) { + if (!popup) return false; + + const handle = popup.querySelector(".draggable-popup-handle"); + if (!handle) return false; + + const rect = handle.getBoundingClientRect(); + const visibleTop = Math.max(rect.top, 0); + const visibleBottom = Math.min(rect.bottom, window.innerHeight); + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + + return visibleHeight < 18; + } + + function setFreePosition(popup, left, top, width, height) { + popup.classList.add("draggable-popup--free"); + popup.style.setProperty("--drag-left", `${left}px`); + popup.style.setProperty("--drag-top", `${top}px`); + popup.style.right = "auto"; + popup.style.bottom = "auto"; + popup.style.width = `${width}px`; + + const constrainedHeight = constrainFreeHeight(popup, height); + if (constrainedHeight != null) { + popup.style.height = `${constrainedHeight}px`; + popup.style.maxHeight = `${constrainedHeight}px`; + } else { + popup.style.removeProperty("height"); + popup.style.removeProperty("max-height"); + } + } + + function savePosition(popup) { + const key = dragKey(popup); + if (!key) return; + + const rect = popup.getBoundingClientRect(); + window.rt._draggablePopupPositions[key] = { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height + }; + } + + function applySavedPositions() { + document.querySelectorAll(".draggable-popup").forEach((popup) => { + const key = dragKey(popup); + const saved = key ? window.rt._draggablePopupPositions[key] : null; + + if (saved && !popup.classList.contains("draggable-popup--free")) { + const rect = popup.getBoundingClientRect(); + const width = saved.width || rect.width; + const height = saved.height || rect.height; + const pos = clampPosition(saved.left, saved.top, width, height); + setFreePosition(popup, pos.left, pos.top, width, height); + } + + ensurePopupZIndex(popup); + }); + } + + document.addEventListener("pointerdown", function (event) { + const popup = event.target && event.target.closest + ? event.target.closest(".draggable-popup") + : null; + if (!popup) return; + + bringPopupToFront(popup); + + if (event.button !== 0) return; + if (event.target.closest("button,a,input,textarea,select,label")) return; + + const handle = event.target && event.target.closest + ? event.target.closest(".draggable-popup-handle") + : null; + + const fallbackWholePopup = + !handle && + popup.classList.contains("draggable-popup--free") && + isHandleRecoverablyHidden(popup); + + if (!handle && !fallbackWholePopup) return; + + const dragSurface = handle || popup; + + event.preventDefault(); + + const rect = popup.getBoundingClientRect(); + const width = rect.width; + const height = rect.height; + const startX = event.clientX; + const startY = event.clientY; + const startLeft = rect.left; + const startTop = rect.top; + + const initial = clampPosition(startLeft, startTop, width, height); + setFreePosition(popup, initial.left, initial.top, width, height); + popup.classList.add("draggable-popup--dragging"); + + try { dragSurface.setPointerCapture(event.pointerId); } catch { } + + function onMove(moveEvent) { + const pos = clampPosition( + startLeft + moveEvent.clientX - startX, + startTop + moveEvent.clientY - startY, + width, + height); + + popup.style.setProperty("--drag-left", `${pos.left}px`); + popup.style.setProperty("--drag-top", `${pos.top}px`); + } + + function onUp(upEvent) { + document.removeEventListener("pointermove", onMove, true); + document.removeEventListener("pointerup", onUp, true); + document.removeEventListener("pointercancel", onUp, true); + + popup.classList.remove("draggable-popup--dragging"); + savePosition(popup); + + try { dragSurface.releasePointerCapture(upEvent.pointerId); } catch { } + } + + document.addEventListener("pointermove", onMove, true); + document.addEventListener("pointerup", onUp, true); + document.addEventListener("pointercancel", onUp, true); + }, true); + + document.addEventListener("focusin", function (event) { + const popup = event.target && event.target.closest + ? event.target.closest(".draggable-popup") + : null; + if (!popup) return; + bringPopupToFront(popup); + }, true); + + window.addEventListener("resize", function () { + document.querySelectorAll(".draggable-popup--free").forEach((popup) => { + const rect = popup.getBoundingClientRect(); + const pos = clampPosition(rect.left, rect.top, rect.width, rect.height); + setFreePosition(popup, pos.left, pos.top, rect.width, rect.height); + savePosition(popup); + }); + }); + + const observer = new MutationObserver(applySavedPositions); + observer.observe(document.body, { childList: true, subtree: true }); + applySavedPositions(); +}; + +window.rt.saveLocalValue = function (key, value) { + try { + if (!window.localStorage) return; + window.localStorage.setItem(key, value ?? ""); + } catch { } +}; + +window.rt.loadLocalValue = function (key) { + try { + if (!window.localStorage) return null; + return window.localStorage.getItem(key); + } catch { + return null; + } +}; + +window.rt.clearLocalBrowserData = async function () { + const deleteIndexedDb = function (name) { + return new Promise((resolve) => { + try { + const req = window.indexedDB.deleteDatabase(name); + req.onsuccess = resolve; + req.onerror = resolve; + req.onblocked = resolve; + } catch { + resolve(); + } + }); + }; + + try { window.localStorage?.clear(); } catch { } + try { window.sessionStorage?.clear(); } catch { } + + try { + if (window.caches?.keys) { + const keys = await window.caches.keys(); + await Promise.all(keys.map((key) => window.caches.delete(key))); + } + } catch { } + + try { + if (navigator.serviceWorker?.getRegistrations) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((registration) => registration.unregister())); + } + } catch { } + + try { + if (window.indexedDB?.databases) { + const databases = await window.indexedDB.databases(); + await Promise.all((databases || []) + .filter((database) => database && database.name) + .map((database) => deleteIndexedDb(database.name))); + } + } catch { } +}; + +window.rt.reloadPage = function () { + window.location.reload(); +}; diff --git a/RutasDBUS/wwwroot/bootstrap/bootstrap.min.css b/RutasDBUS/wwwroot/bootstrap/bootstrap.min.css new file mode 100644 index 0000000..02ae65b --- /dev/null +++ b/RutasDBUS/wwwroot/bootstrap/bootstrap.min.css @@ -0,0 +1,7 @@ +@charset "UTF-8";/*! + * Bootstrap v5.1.0 (https://getbootstrap.com/) + * Copyright 2011-2021 The Bootstrap Authors + * Copyright 2011-2021 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-rgb:33,37,41;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{width:100%;padding-right:var(--bs-gutter-x,.75rem);padding-left:var(--bs-gutter-x,.75rem);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(var(--bs-gutter-y) * -1);margin-right:calc(var(--bs-gutter-x) * -.5);margin-left:calc(var(--bs-gutter-x) * -.5)}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-bg:transparent;--bs-table-accent-bg:transparent;--bs-table-striped-color:#212529;--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:#212529;--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:#212529;--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:#212529;vertical-align:top;border-color:#dee2e6}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table>:not(:last-child)>:last-child>*{border-bottom-color:currentColor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-striped>tbody>tr:nth-of-type(odd){--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-bg:#cfe2ff;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:#000;border-color:#bacbe6}.table-secondary{--bs-table-bg:#e2e3e5;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:#000;border-color:#cbccce}.table-success{--bs-table-bg:#d1e7dd;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:#000;border-color:#bcd0c7}.table-info{--bs-table-bg:#cff4fc;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:#000;border-color:#badce3}.table-warning{--bs-table-bg:#fff3cd;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:#000;border-color:#e6dbb9}.table-danger{--bs-table-bg:#f8d7da;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:#000;border-color:#dfc2c4}.table-light{--bs-table-bg:#f8f9fa;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:#000;border-color:#dfe0e1}.table-dark{--bs-table-bg:#212529;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:#fff;border-color:#373b3e}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:auto;padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{height:1.5em;border-radius:.25rem}.form-control-color::-webkit-color-swatch{height:1.5em;border-radius:.25rem}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;height:100%;padding:1rem .75rem;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control{padding:1rem .75rem}.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus{z-index:3}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:3}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group .form-control.is-valid,.input-group .form-select.is-valid,.was-validated .input-group .form-control:valid,.was-validated .input-group .form-select:valid{z-index:1}.input-group .form-control.is-valid:focus,.input-group .form-select.is-valid:focus,.was-validated .input-group .form-control:valid:focus,.was-validated .input-group .form-select:valid:focus{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group .form-control.is-invalid,.input-group .form-select.is-invalid,.was-validated .input-group .form-control:invalid,.was-validated .input-group .form-select:invalid{z-index:2}.input-group .form-control.is-invalid:focus,.input-group .form-select.is-invalid:focus,.was-validated .input-group .form-control:invalid:focus,.was-validated .input-group .form-select:invalid:focus{z-index:3}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529}.btn-check:focus+.btn,.btn:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-primary:hover{color:#fff;background-color:#0b5ed7;border-color:#0a58ca}.btn-check:focus+.btn-primary,.btn-primary:focus{color:#fff;background-color:#0b5ed7;border-color:#0a58ca;box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-check:active+.btn-primary,.btn-check:checked+.btn-primary,.btn-primary.active,.btn-primary:active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0a58ca;border-color:#0a53be}.btn-check:active+.btn-primary:focus,.btn-check:checked+.btn-primary:focus,.btn-primary.active:focus,.btn-primary:active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5c636a;border-color:#565e64}.btn-check:focus+.btn-secondary,.btn-secondary:focus{color:#fff;background-color:#5c636a;border-color:#565e64;box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-check:active+.btn-secondary,.btn-check:checked+.btn-secondary,.btn-secondary.active,.btn-secondary:active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#565e64;border-color:#51585e}.btn-check:active+.btn-secondary:focus,.btn-check:checked+.btn-secondary:focus,.btn-secondary.active:focus,.btn-secondary:active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-success{color:#fff;background-color:#198754;border-color:#198754}.btn-success:hover{color:#fff;background-color:#157347;border-color:#146c43}.btn-check:focus+.btn-success,.btn-success:focus{color:#fff;background-color:#157347;border-color:#146c43;box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-check:active+.btn-success,.btn-check:checked+.btn-success,.btn-success.active,.btn-success:active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#146c43;border-color:#13653f}.btn-check:active+.btn-success:focus,.btn-check:checked+.btn-success:focus,.btn-success.active:focus,.btn-success:active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#198754;border-color:#198754}.btn-info{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-info:hover{color:#000;background-color:#31d2f2;border-color:#25cff2}.btn-check:focus+.btn-info,.btn-info:focus{color:#000;background-color:#31d2f2;border-color:#25cff2;box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-check:active+.btn-info,.btn-check:checked+.btn-info,.btn-info.active,.btn-info:active,.show>.btn-info.dropdown-toggle{color:#000;background-color:#3dd5f3;border-color:#25cff2}.btn-check:active+.btn-info:focus,.btn-check:checked+.btn-info:focus,.btn-info.active:focus,.btn-info:active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-info.disabled,.btn-info:disabled{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-warning{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#000;background-color:#ffca2c;border-color:#ffc720}.btn-check:focus+.btn-warning,.btn-warning:focus{color:#000;background-color:#ffca2c;border-color:#ffc720;box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-check:active+.btn-warning,.btn-check:checked+.btn-warning,.btn-warning.active,.btn-warning:active,.show>.btn-warning.dropdown-toggle{color:#000;background-color:#ffcd39;border-color:#ffc720}.btn-check:active+.btn-warning:focus,.btn-check:checked+.btn-warning:focus,.btn-warning.active:focus,.btn-warning:active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#bb2d3b;border-color:#b02a37}.btn-check:focus+.btn-danger,.btn-danger:focus{color:#fff;background-color:#bb2d3b;border-color:#b02a37;box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-check:active+.btn-danger,.btn-check:checked+.btn-danger,.btn-danger.active,.btn-danger:active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#b02a37;border-color:#a52834}.btn-check:active+.btn-danger:focus,.btn-check:checked+.btn-danger:focus,.btn-danger.active:focus,.btn-danger:active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-light{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:focus+.btn-light,.btn-light:focus{color:#000;background-color:#f9fafb;border-color:#f9fafb;box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-check:active+.btn-light,.btn-check:checked+.btn-light,.btn-light.active,.btn-light:active,.show>.btn-light.dropdown-toggle{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:active+.btn-light:focus,.btn-check:checked+.btn-light:focus,.btn-light.active:focus,.btn-light:active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-light.disabled,.btn-light:disabled{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{color:#fff;background-color:#1c1f23;border-color:#1a1e21}.btn-check:focus+.btn-dark,.btn-dark:focus{color:#fff;background-color:#1c1f23;border-color:#1a1e21;box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-check:active+.btn-dark,.btn-check:checked+.btn-dark,.btn-dark.active,.btn-dark:active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1a1e21;border-color:#191c1f}.btn-check:active+.btn-dark:focus,.btn-check:checked+.btn-dark:focus,.btn-dark.active:focus,.btn-dark:active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#212529;border-color:#212529}.btn-outline-primary{color:#0d6efd;border-color:#0d6efd}.btn-outline-primary:hover{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:focus+.btn-outline-primary,.btn-outline-primary:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-check:active+.btn-outline-primary,.btn-check:checked+.btn-outline-primary,.btn-outline-primary.active,.btn-outline-primary.dropdown-toggle.show,.btn-outline-primary:active{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:active+.btn-outline-primary:focus,.btn-check:checked+.btn-outline-primary:focus,.btn-outline-primary.active:focus,.btn-outline-primary.dropdown-toggle.show:focus,.btn-outline-primary:active:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#0d6efd;background-color:transparent}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:focus+.btn-outline-secondary,.btn-outline-secondary:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-check:active+.btn-outline-secondary,.btn-check:checked+.btn-outline-secondary,.btn-outline-secondary.active,.btn-outline-secondary.dropdown-toggle.show,.btn-outline-secondary:active{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:active+.btn-outline-secondary:focus,.btn-check:checked+.btn-outline-secondary:focus,.btn-outline-secondary.active:focus,.btn-outline-secondary.dropdown-toggle.show:focus,.btn-outline-secondary:active:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-success{color:#198754;border-color:#198754}.btn-outline-success:hover{color:#fff;background-color:#198754;border-color:#198754}.btn-check:focus+.btn-outline-success,.btn-outline-success:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-check:active+.btn-outline-success,.btn-check:checked+.btn-outline-success,.btn-outline-success.active,.btn-outline-success.dropdown-toggle.show,.btn-outline-success:active{color:#fff;background-color:#198754;border-color:#198754}.btn-check:active+.btn-outline-success:focus,.btn-check:checked+.btn-outline-success:focus,.btn-outline-success.active:focus,.btn-outline-success.dropdown-toggle.show:focus,.btn-outline-success:active:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#198754;background-color:transparent}.btn-outline-info{color:#0dcaf0;border-color:#0dcaf0}.btn-outline-info:hover{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:focus+.btn-outline-info,.btn-outline-info:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-check:active+.btn-outline-info,.btn-check:checked+.btn-outline-info,.btn-outline-info.active,.btn-outline-info.dropdown-toggle.show,.btn-outline-info:active{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:active+.btn-outline-info:focus,.btn-check:checked+.btn-outline-info:focus,.btn-outline-info.active:focus,.btn-outline-info.dropdown-toggle.show:focus,.btn-outline-info:active:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#0dcaf0;background-color:transparent}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:focus+.btn-outline-warning,.btn-outline-warning:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-check:active+.btn-outline-warning,.btn-check:checked+.btn-outline-warning,.btn-outline-warning.active,.btn-outline-warning.dropdown-toggle.show,.btn-outline-warning:active{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:active+.btn-outline-warning:focus,.btn-check:checked+.btn-outline-warning:focus,.btn-outline-warning.active:focus,.btn-outline-warning.dropdown-toggle.show:focus,.btn-outline-warning:active:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:focus+.btn-outline-danger,.btn-outline-danger:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-check:active+.btn-outline-danger,.btn-check:checked+.btn-outline-danger,.btn-outline-danger.active,.btn-outline-danger.dropdown-toggle.show,.btn-outline-danger:active{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:active+.btn-outline-danger:focus,.btn-check:checked+.btn-outline-danger:focus,.btn-outline-danger.active:focus,.btn-outline-danger.dropdown-toggle.show:focus,.btn-outline-danger:active:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:focus+.btn-outline-light,.btn-outline-light:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-check:active+.btn-outline-light,.btn-check:checked+.btn-outline-light,.btn-outline-light.active,.btn-outline-light.dropdown-toggle.show,.btn-outline-light:active{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:active+.btn-outline-light:focus,.btn-check:checked+.btn-outline-light:focus,.btn-outline-light.active:focus,.btn-outline-light.dropdown-toggle.show:focus,.btn-outline-light:active:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-dark{color:#212529;border-color:#212529}.btn-outline-dark:hover{color:#fff;background-color:#212529;border-color:#212529}.btn-check:focus+.btn-outline-dark,.btn-outline-dark:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-check:active+.btn-outline-dark,.btn-check:checked+.btn-outline-dark,.btn-outline-dark.active,.btn-outline-dark.dropdown-toggle.show,.btn-outline-dark:active{color:#fff;background-color:#212529;border-color:#212529}.btn-check:active+.btn-outline-dark:focus,.btn-check:checked+.btn-outline-dark:focus,.btn-outline-dark.active:focus,.btn-outline-dark.dropdown-toggle.show:focus,.btn-outline-dark:active:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#212529;background-color:transparent}.btn-link{font-weight:400;color:#0d6efd;text-decoration:underline}.btn-link:hover{color:#0a58ca}.btn-link.disabled,.btn-link:disabled{color:#6c757d}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropend,.dropstart,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;z-index:1000;display:none;min-width:10rem;padding:.5rem 0;margin:0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:.125rem}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid rgba(0,0,0,.15)}.dropdown-item{display:block;width:100%;padding:.25rem 1rem;clear:both;font-weight:400;color:#212529;text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#1e2125;background-color:#e9ecef}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#0d6efd}.dropdown-item.disabled,.dropdown-item:disabled{color:#adb5bd;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1rem;color:#212529}.dropdown-menu-dark{color:#dee2e6;background-color:#343a40;border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item{color:#dee2e6}.dropdown-menu-dark .dropdown-item:focus,.dropdown-menu-dark .dropdown-item:hover{color:#fff;background-color:rgba(255,255,255,.15)}.dropdown-menu-dark .dropdown-item.active,.dropdown-menu-dark .dropdown-item:active{color:#fff;background-color:#0d6efd}.dropdown-menu-dark .dropdown-item.disabled,.dropdown-menu-dark .dropdown-item:disabled{color:#adb5bd}.dropdown-menu-dark .dropdown-divider{border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item-text{color:#dee2e6}.dropdown-menu-dark .dropdown-header{color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#0d6efd;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:#0a58ca}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-link{margin-bottom:-1px;background:0 0;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6;isolation:isolate}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{background:0 0;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#0d6efd}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding-top:.5rem;padding-bottom:.5rem}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem;transition:box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 .25rem}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas-header{display:none}.navbar-expand-sm .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-sm .offcanvas-bottom,.navbar-expand-sm .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas-header{display:none}.navbar-expand-md .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-md .offcanvas-bottom,.navbar-expand-md .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas-header{display:none}.navbar-expand-lg .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-lg .offcanvas-bottom,.navbar-expand-lg .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas-header{display:none}.navbar-expand-xl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xl .offcanvas-bottom,.navbar-expand-xl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xxl .offcanvas-bottom,.navbar-expand-xxl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas-header{display:none}.navbar-expand .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand .offcanvas-bottom,.navbar-expand .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.55)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.55);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.55)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.55)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.55);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.55)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:1rem 1rem}.card-title{margin-bottom:.5rem}.card-subtitle{margin-top:-.25rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:1rem}.card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.5rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.5rem;margin-bottom:-.5rem;margin-left:-.5rem;border-bottom:0}.card-header-pills{margin-right:-.5rem;margin-left:-.5rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-group>.card{margin-bottom:.75rem}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:1rem 1.25rem;font-size:1rem;color:#212529;text-align:left;background-color:#fff;border:0;border-radius:0;overflow-anchor:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,border-radius .15s ease}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:#0c63e4;background-color:#e7f1ff;box-shadow:inset 0 -1px 0 rgba(0,0,0,.125)}.accordion-button:not(.collapsed)::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");transform:rotate(-180deg)}.accordion-button::after{flex-shrink:0;width:1.25rem;height:1.25rem;margin-left:auto;content:"";background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:1.25rem;transition:transform .2s ease-in-out}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.accordion-header{margin-bottom:0}.accordion-item{background-color:#fff;border:1px solid rgba(0,0,0,.125)}.accordion-item:first-of-type{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.accordion-item:first-of-type .accordion-button{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-body{padding:1rem 1.25rem}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button{border-radius:0}.breadcrumb{display:flex;flex-wrap:wrap;padding:0 0;margin-bottom:1rem;list-style:none}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:.5rem;color:#6c757d;content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:#6c757d}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;color:#0d6efd;text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:#0a58ca;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;color:#0a58ca;background-color:#e9ecef;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item.active .page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}.page-link{padding:.375rem .75rem}.page-item:first-child .page-link{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{position:relative;padding:1rem 1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{color:#084298;background-color:#cfe2ff;border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{color:#41464b;background-color:#e2e3e5;border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{color:#0f5132;background-color:#d1e7dd;border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{color:#055160;background-color:#cff4fc;border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{color:#664d03;background-color:#fff3cd;border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{color:#636464;background-color:#fefefe;border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{color:#141619;background-color:#d3d3d4;border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#0d6efd;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.list-group{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>li::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.5rem 1rem;color:#212529;text-decoration:none;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{width:350px;max-width:100%;font-size:.875rem;pointer-events:auto;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .5rem 1rem rgba(0,0,0,.15);border-radius:.25rem}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:.75rem}.toast-header{display:flex;align-items:center;padding:.5rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-header .btn-close{margin-right:-.375rem;margin-left:.75rem}.toast-body{padding:.75rem;word-wrap:break-word}.modal{position:fixed;top:0;left:0;z-index:1055;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1050;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .btn-close{padding:.5rem .5rem;margin:-.5rem -.5rem -.5rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-wrap:wrap;flex-shrink:0;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{height:calc(100% - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}.modal-fullscreen .modal-footer{border-radius:0}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}.modal-fullscreen-sm-down .modal-footer{border-radius:0}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}.modal-fullscreen-md-down .modal-footer{border-radius:0}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}.modal-fullscreen-lg-down .modal-footer{border-radius:0}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}.modal-fullscreen-xl-down .modal-footer{border-radius:0}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}.modal-fullscreen-xxl-down .modal-footer{border-radius:0}}.tooltip{position:absolute;z-index:1080;display:block;margin:0;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .tooltip-arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[data-popper-placement^=right],.bs-tooltip-end{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[data-popper-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[data-popper-placement^=left],.bs-tooltip-start{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1070;display:block;max-width:276px;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .popover-arrow{position:absolute;display:block;width:1rem;height:.5rem}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f0f0f0}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem 1rem;margin-bottom:0;font-size:1rem;background-color:#f0f0f0;border-bottom:1px solid rgba(0,0,0,.2);border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:1rem 1rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}.spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}.offcanvas{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;visibility:hidden;background-color:#fff;background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem}.offcanvas-header .btn-close{padding:.5rem .5rem;margin-top:-.5rem;margin-right:-.5rem;margin-bottom:-.5rem}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:1rem 1rem;overflow-y:auto}.offcanvas-start{top:0;left:0;width:400px;border-right:1px solid rgba(0,0,0,.2);transform:translateX(-100%)}.offcanvas-end{top:0;right:0;width:400px;border-left:1px solid rgba(0,0,0,.2);transform:translateX(100%)}.offcanvas-top{top:0;right:0;left:0;height:30vh;max-height:100%;border-bottom:1px solid rgba(0,0,0,.2);transform:translateY(-100%)}.offcanvas-bottom{right:0;left:0;height:30vh;max-height:100%;border-top:1px solid rgba(0,0,0,.2);transform:translateY(100%)}.offcanvas.show{transform:none}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentColor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{-webkit-animation:placeholder-glow 2s ease-in-out infinite;animation:placeholder-glow 2s ease-in-out infinite}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;-webkit-animation:placeholder-wave 2s linear infinite;animation:placeholder-wave 2s linear infinite}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.link-primary{color:#0d6efd}.link-primary:focus,.link-primary:hover{color:#0a58ca}.link-secondary{color:#6c757d}.link-secondary:focus,.link-secondary:hover{color:#565e64}.link-success{color:#198754}.link-success:focus,.link-success:hover{color:#146c43}.link-info{color:#0dcaf0}.link-info:focus,.link-info:hover{color:#3dd5f3}.link-warning{color:#ffc107}.link-warning:focus,.link-warning:hover{color:#ffcd39}.link-danger{color:#dc3545}.link-danger:focus,.link-danger:hover{color:#b02a37}.link-light{color:#f8f9fa}.link-light:focus,.link-light:hover{color:#f9fafb}.link-dark{color:#212529}.link-dark:focus,.link-dark:hover{color:#1a1e21}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:calc(3 / 4 * 100%)}.ratio-16x9{--bs-aspect-ratio:calc(9 / 16 * 100%)}.ratio-21x9{--bs-aspect-ratio:calc(9 / 21 * 100%)}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentColor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:1px solid #dee2e6!important}.border-0{border:0!important}.border-top{border-top:1px solid #dee2e6!important}.border-top-0{border-top:0!important}.border-end{border-right:1px solid #dee2e6!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:1px solid #dee2e6!important}.border-start-0{border-left:0!important}.border-primary{border-color:#0d6efd!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#198754!important}.border-info{border-color:#0dcaf0!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#212529!important}.border-white{border-color:#fff!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:.25rem!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:.2rem!important}.rounded-2{border-radius:.25rem!important}.rounded-3{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-end{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-start{border-bottom-left-radius:.25rem!important;border-top-left-radius:.25rem!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/RutasDBUS/wwwroot/bootstrap/bootstrap.min.css.map b/RutasDBUS/wwwroot/bootstrap/bootstrap.min.css.map new file mode 100644 index 0000000..afcd9e3 --- /dev/null +++ b/RutasDBUS/wwwroot/bootstrap/bootstrap.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_border-radius.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/_tables.scss","../../scss/mixins/_table-variants.scss","../../scss/forms/_labels.scss","../../scss/forms/_form-text.scss","../../scss/forms/_form-control.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_gradients.scss","../../scss/forms/_form-select.scss","../../scss/forms/_form-check.scss","../../scss/forms/_form-range.scss","../../scss/forms/_floating-labels.scss","../../scss/forms/_input-group.scss","../../scss/mixins/_forms.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/_button-group.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_accordion.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/mixins/_backdrop.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/_offcanvas.scss","../../scss/_placeholders.scss","../../scss/helpers/_colored-links.scss","../../scss/helpers/_ratio.scss","../../scss/helpers/_position.scss","../../scss/helpers/_stacks.scss","../../scss/helpers/_visually-hidden.scss","../../scss/mixins/_visually-hidden.scss","../../scss/helpers/_stretched-link.scss","../../scss/helpers/_text-truncation.scss","../../scss/mixins/_text-truncate.scss","../../scss/helpers/_vr.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"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","sourcesContent":["/*!\n * Bootstrap v5.1.0 (https://getbootstrap.com/)\n * Copyright 2011-2021 The Bootstrap Authors\n * Copyright 2011-2021 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n\n// scss-docs-start import-stack\n// Configuration\n@import \"functions\";\n@import \"variables\";\n@import \"mixins\";\n@import \"utilities\";\n\n// Layout & components\n@import \"root\";\n@import \"reboot\";\n@import \"type\";\n@import \"images\";\n@import \"containers\";\n@import \"grid\";\n@import \"tables\";\n@import \"forms\";\n@import \"buttons\";\n@import \"transitions\";\n@import \"dropdown\";\n@import \"button-group\";\n@import \"nav\";\n@import \"navbar\";\n@import \"card\";\n@import \"accordion\";\n@import \"breadcrumb\";\n@import \"pagination\";\n@import \"badge\";\n@import \"alert\";\n@import \"progress\";\n@import \"list-group\";\n@import \"close\";\n@import \"toasts\";\n@import \"modal\";\n@import \"tooltip\";\n@import \"popover\";\n@import \"carousel\";\n@import \"spinners\";\n@import \"offcanvas\";\n@import \"placeholders\";\n\n// Helpers\n@import \"helpers\";\n\n// Utilities\n@import \"utilities/api\";\n// scss-docs-end import-stack\n",":root {\n // Note: Custom variable values only support SassScript inside `#{}`.\n\n // Colors\n //\n // Generate palettes for full colors, grays, and theme colors.\n\n @each $color, $value in $colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$variable-prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$variable-prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$variable-prefix}#{$color}-rgb: #{$value};\n }\n\n --#{$variable-prefix}white-rgb: #{to-rgb($white)};\n --#{$variable-prefix}black-rgb: #{to-rgb($black)};\n --#{$variable-prefix}body-rgb: #{to-rgb($body-color)};\n\n // Fonts\n\n // Note: Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --#{$variable-prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$variable-prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$variable-prefix}gradient: #{$gradient};\n\n // Root and body\n // stylelint-disable custom-property-empty-line-before\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$variable-prefix}root-font-size: #{$font-size-root};\n }\n --#{$variable-prefix}body-font-family: #{$font-family-base};\n --#{$variable-prefix}body-font-size: #{$font-size-base};\n --#{$variable-prefix}body-font-weight: #{$font-weight-base};\n --#{$variable-prefix}body-line-height: #{$line-height-base};\n --#{$variable-prefix}body-color: #{$body-color};\n @if $body-text-align != null {\n --#{$variable-prefix}body-text-align: #{$body-text-align};\n }\n --#{$variable-prefix}body-bg: #{$body-bg};\n // scss-docs-end root-body-variables\n // stylelint-enable custom-property-empty-line-before\n}\n","// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n\n// Root\n//\n// Ability to the value of the root font sizes, affecting the value of `rem`.\n// null by default, thus nothing is generated.\n\n:root {\n @if $font-size-root != null {\n font-size: var(--#{$variable-prefix}-root-font-size);\n }\n\n @if $enable-smooth-scroll {\n @media (prefers-reduced-motion: no-preference) {\n scroll-behavior: smooth;\n }\n }\n}\n\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Prevent adjustments of font size after orientation changes in iOS.\n// 4. Change the default tap highlight to be completely transparent in iOS.\n\n// scss-docs-start reboot-body-rules\nbody {\n margin: 0; // 1\n font-family: var(--#{$variable-prefix}body-font-family);\n @include font-size(var(--#{$variable-prefix}body-font-size));\n font-weight: var(--#{$variable-prefix}body-font-weight);\n line-height: var(--#{$variable-prefix}body-line-height);\n color: var(--#{$variable-prefix}body-color);\n text-align: var(--#{$variable-prefix}body-text-align);\n background-color: var(--#{$variable-prefix}body-bg); // 2\n -webkit-text-size-adjust: 100%; // 3\n -webkit-tap-highlight-color: rgba($black, 0); // 4\n}\n// scss-docs-end reboot-body-rules\n\n\n// Content grouping\n//\n// 1. Reset Firefox's gray color\n// 2. Set correct height and prevent the `size` attribute to make the `hr` look like an input field\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n background-color: currentColor;\n border: 0;\n opacity: $hr-opacity;\n}\n\nhr:not([size]) {\n height: $hr-height; // 2\n}\n\n\n// Typography\n//\n// 1. Remove top margins from headings\n// By default, `

`-`

` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n\n%heading {\n margin-top: 0; // 1\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-style: $headings-font-style;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: $headings-color;\n}\n\nh1 {\n @extend %heading;\n @include font-size($h1-font-size);\n}\n\nh2 {\n @extend %heading;\n @include font-size($h2-font-size);\n}\n\nh3 {\n @extend %heading;\n @include font-size($h3-font-size);\n}\n\nh4 {\n @extend %heading;\n @include font-size($h4-font-size);\n}\n\nh5 {\n @extend %heading;\n @include font-size($h5-font-size);\n}\n\nh6 {\n @extend %heading;\n @include font-size($h6-font-size);\n}\n\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Duplicate behavior to the data-bs-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-bs-original-title] { // 1\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n text-decoration-skip-ink: none; // 4\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n background-color: $mark-bg;\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n\n &:hover {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n direction: ltr #{\"/* rtl:ignore */\"};\n unicode-bidi: bidi-override;\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: $code-color;\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\n\n// Forms\n//\n// 1. Allow labels to use `margin` for spacing.\n\nlabel {\n display: inline-block; // 1\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n// See https://github.com/twbs/bootstrap/issues/24093\n\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\n// 1. Remove the margin in Firefox and Safari\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // 1\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\n// Remove the inheritance of text transform in Firefox\nbutton,\nselect {\n text-transform: none;\n}\n// Set the cursor for non-`