Initial clean RutasDBUS app

This commit is contained in:
2026-07-07 14:18:51 +02:00
commit 8b106a8319
76 changed files with 28216 additions and 0 deletions

3
.github/copilot-instructions.md vendored Normal file
View File

@@ -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.

402
.gitignore vendored Normal file
View File

@@ -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

7
NuGet.Config Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>

34
RutasDBUS.sln Normal file
View File

@@ -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

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="RutasDBUS.styles.css" />
<link rel="stylesheet" href="app.css?v=20260626b" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
<body>
<Routes />
<script src="app.js?v=20260626b" defer></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
@inherits LayoutComponentBase
<div class="page">
@* <div class="sidebar">
<NavMenu />
</div> *@
<main>
@* <div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
*@
<article class="content">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>

View File

@@ -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;
}

View File

@@ -0,0 +1,118 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">RutasDBUS</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="weather">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
</NavLink>
</div>
</nav>
</div>
@* <div class="map-overlay-card">
<strong>Flujo de selección</strong>
<p>@TextoPasoActual</p>
<div class="mini">
<div>
<b>Origen:</b>
@if (_paradaOrigenSeleccionada is not null)
{
<span class="muted">
@_paradaOrigenSeleccionada.Nombre (@_paradaOrigenSeleccionada.Codigo)
</span>
}
else if (_coordenadasOrigen is not null)
{
<span class="muted">Seleccionado en el mapa</span>
}
else
{
<span class="muted">Pendiente</span>
}
</div>
<div>
<b>Destino:</b>
@if (_paradaDestinoSeleccionada is not null)
{
<span class="muted">
@_paradaDestinoSeleccionada.Nombre (@_paradaDestinoSeleccionada.Codigo)
</span>
}
else if (_coordenadasDestino is not null)
{
<span class="muted">Seleccionado en el mapa</span>
}
else
{
<span class="muted">Pendiente</span>
}
</div>
@if (_primerTransbordoseleccionado is not null)
{
<div>
<b>1er Transbordo:</b>
<span class="muted">
@_primerTransbordoseleccionado.Nombre (@_primerTransbordoseleccionado.Codigo)
</span>
</div>
}
@* Info de la última parada clicada *@
@* @if (_paradaClicada is not null)
{
<hr style="border-color:rgba(55,65,81,.6);margin:.35rem 0;" />
<div>
<b>Parada clicada:</b>
<span class="muted">
@_paradaClicada.Nombre (@_paradaClicada.Codigo)
</span>
</div>
<div class="mini">
@if (!string.IsNullOrWhiteSpace(_paradaClicada.Abreviatura))
{
<span>Abrev.: @_paradaClicada.Abreviatura</span>
}
@if (!string.IsNullOrWhiteSpace(_paradaClicada.NombreCorto))
{
<span> · Corto: @_paradaClicada.NombreCorto</span>
}
@if (_paradaClicada.Altura > 0)
{
<span> · Altura: @_paradaClicada.Altura.ToString("0") m</span>
}
</div>
}
</div>
<div class="mt-2">
<button class="btn-ghost btn-sm"
style="padding:.15rem .6rem;font-size:.72rem"
@onclick="ReiniciarSeleccion">
Limpiar selección
</button>
</div>
</div> *@

View File

@@ -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;
}
}

View File

@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@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;
}

View File

@@ -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<PlanificadorRutas>? _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";
/// <summary>
/// Gestiona after render.
/// </summary>
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<string> _itPreviewActivos = new(StringComparer.OrdinalIgnoreCase);
// Mapa: clave lógica "26.12-I" -> ids reales pintados (pueden ser 1 o varios)
private readonly Dictionary<string, HashSet<string>> _itPreviewIdsPorClave =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gestiona clave.
/// </summary>
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}";
}
/// <summary>
/// Gestiona vista previa.
/// </summary>
private void RegistrarPreview(string clave, string subId)
{
_itPreviewActivos.Add(subId);
if (!_itPreviewIdsPorClave.TryGetValue(clave, out var set))
{
set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_itPreviewIdsPorClave[clave] = set;
}
set.Add(subId);
}
/// <summary>
/// Obtiene seleccionada it.
/// </summary>
private ElementoItinerario? GetSelectedIt()
=> _itItems.FirstOrDefault(x => x.Id.Equals(_itSeleccionadoId, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Pinta it seleccionado.
/// </summary>
private async Task PintarItSeleccionado()
{
var it = GetSelectedIt();
if (it is null) return;
await PintarItItemAsync(it);
}
/// <summary>
/// Quita it seleccionado.
/// </summary>
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<string>();
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<double[]>(), new { });
await JS.InvokeVoidAsync("rt.toggleItStops", id, new List<object>(), 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);
}
}
/// <summary>
/// Gestiona todas las vistas previas.
/// </summary>
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;
/// <summary>
/// Gestiona vista previa itinerario clic.
/// </summary>
[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<InfoVariante> { info }
};
}
}
}
_itinerarioClicado = encontrado;
await InvokeAsync(StateHasChanged);
}
catch
{
// silencioso
}
}
/// <summary>
/// Abre la información de una parada destacada pulsada directamente sobre la solución o el itinerario.
/// </summary>
[JSInvokable]
public async Task OnHighlightedStopClick(string codigoParada)
{
if (string.IsNullOrWhiteSpace(codigoParada))
return;
if (!_paradaByCode.TryGetValue(codigoParada, out var parada))
return;
await MostrarPopupParadaAsync(parada);
}
/// <summary>
/// Gestiona inspector activo.
/// </summary>
[JSInvokable]
public void SetInspectorActivo(bool activo)
{
_inspectorActivo = activo;
StateHasChanged();
}
/// <summary>
/// Gestiona raton move mapa.
/// </summary>
private void OnMouseMoveMapa(RealTimeMap.ClicksMapArgs args)
{
_coordsMouse = (args.location.latitude, args.location.longitude);
StateHasChanged();
}
/// <summary>
/// Gestiona brillo.
/// </summary>
[JSInvokable]
public async Task AlternarBrillo()
{
_nivelBrilloMapa++;
if (_nivelBrilloMapa > 4) _nivelBrilloMapa = 0;
await PersistirConfiguracionAsync();
StateHasChanged();
}
/// <summary>
/// Dibuja paradas.
/// </summary>
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);
}
}
/// <summary>
/// Gestiona linea recta origen destino.
/// </summary>
private double DistanciaLineaRectaOrigenDestino()
{
if (_coordenadasOrigen is null || _coordenadasDestino is null) return 0;
return CalcularDistanciaMetros(
_coordenadasOrigen[0], _coordenadasOrigen[1],
_coordenadasDestino[0], _coordenadasDestino[1]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<SugerenciaBusquedaMapa> _sugerenciasBusquedaOrigen = new();
private readonly List<SugerenciaBusquedaMapa> _sugerenciasBusquedaDestino = new();
private CampoBusquedaMapa? _campoBusquedaActivo;
private SugerenciaBusquedaMapa? _ubicacionOrigenSeleccionada;
private SugerenciaBusquedaMapa? _ubicacionDestinoSeleccionada;
private int _versionSugerenciasBusqueda;
private CancellationTokenSource? _ctsSugerenciasBusqueda;
private IReadOnlyList<SugerenciaBusquedaMapa> SugerenciasOrigenVisibles =>
_campoBusquedaActivo == CampoBusquedaMapa.Origen
? _sugerenciasBusquedaOrigen
: Array.Empty<SugerenciaBusquedaMapa>();
private IReadOnlyList<SugerenciaBusquedaMapa> SugerenciasDestinoVisibles =>
_campoBusquedaActivo == CampoBusquedaMapa.Destino
? _sugerenciasBusquedaDestino
: Array.Empty<SugerenciaBusquedaMapa>();
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<SugerenciaBusquedaMapa>());
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<SugerenciaBusquedaMapa>());
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<SugerenciaBusquedaMapa?> 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<IReadOnlyList<SugerenciaBusquedaMapa>> 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<SugerenciaBusquedaMapa> BuscarSugerenciasParadas(string texto, int maxResultados)
{
var consulta = NormalizeForSearch(texto);
var tokens = consulta
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (tokens.Length == 0)
return Array.Empty<SugerenciaBusquedaMapa>();
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<string> 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<SugerenciaBusquedaMapa> 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}";
}
}

View File

@@ -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<RegistroHistorialRuta> _historialLocal = new();
private readonly List<RegistroHistorialRuta> _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<string?>("rt.loadLocalValue", ClaveHistorialLocal);
if (string.IsNullOrWhiteSpace(json))
return;
var items = JsonSerializer.Deserialize<List<RegistroHistorialRuta>>(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<string> { 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<int> NormalizarLineasHistorial(IEnumerable<int>? lineas)
=> (lineas ?? Array.Empty<int>())
.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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<ModeloHorarioDbusInfo> _modelosHorarios = Array.Empty<ModeloHorarioDbusInfo>();
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<ModeloHorarioDbusInfo>();
_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<bool>(
"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);
}

View File

@@ -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;
/// <summary>
/// Instantánea persistida de la configuración del planificador.
/// </summary>
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<int>? 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; }
}
/// <summary>
/// Carga la configuración persistida del navegador y la aplica al estado actual.
/// </summary>
private async Task CargarConfiguracionLocalAsync()
{
try
{
var json = await JS.InvokeAsync<string?>("rt.loadLocalValue", ClaveConfiguracionLocal);
if (string.IsNullOrWhiteSpace(json))
return;
var config = JsonSerializer.Deserialize<ConfiguracionLocalPlanificador>(json);
if (config is null)
return;
AplicarConfiguracionLocal(config);
}
catch (JSException)
{
}
catch (JsonException)
{
}
finally
{
_configuracionLocalCargada = true;
}
}
/// <summary>
/// Guarda la configuración actual del planificador en el navegador.
/// </summary>
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)
{
}
}
/// <summary>
/// Sincroniza el valor manual de segundos por parada y persiste la configuración.
/// </summary>
private async Task OnSegundosPorParadaBusChanged()
{
_segundosPorParadaBus = Math.Max(0, _segundosPorParadaBus);
_segundosPorParadaBusManual = _segundosPorParadaBus;
await PersistirConfiguracionAsync();
}
/// <summary>
/// Construye la instantánea persistible a partir del estado actual.
/// </summary>
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
};
/// <summary>
/// Aplica al componente una configuración previamente persistida.
/// </summary>
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<ProveedorCaminata>(config.WalkProvider, ignoreCase: true, out var walkProvider))
_walkProvider = walkProvider;
SincronizarTextosPenalizacionPie();
SincronizarSegundosPorParadaConHorarios();
}
private static IReadOnlyList<int> NormalizarLineasPermitidas(
IReadOnlyList<int>? 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<int>();
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<int>();
return Enumerable.Range(min, max - min + 1).ToList();
}
/// <summary>
/// Abre el cuadro de confirmación para restablecer valores.
/// </summary>
private void PedirConfirmacionRestablecer()
{
_mostrarConfirmacionRestablecer = true;
}
/// <summary>
/// Cierra el cuadro de confirmación de restablecer.
/// </summary>
private void CancelarConfirmacionRestablecer()
{
_mostrarConfirmacionRestablecer = false;
}
/// <summary>
/// Restablece la configuracion avanzada a los valores por defecto de la aplicacion.
/// </summary>
private async Task RestablecerConfiguracionPorDefectoAsync()
{
_mostrarConfirmacionRestablecer = false;
bool confirmado = true;
try
{
confirmado = await JS.InvokeAsync<bool>("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();
}
/// <summary>
/// Restablece la configuración desde el modal personalizado centrado.
/// </summary>
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();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
{
/// <summary>
/// Asegura que elevacion tramo este disponible.
/// </summary>
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<MuestraElevacion>();
}
finally
{
tramo.ElevacionCargando = false;
StateHasChanged();
}
}
/// <summary>
/// Pinta opcion ruta completa.
/// </summary>
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();
}
}
/// <summary>
/// Pinta las paradas recorridas por la solución seleccionada con el color de cada tramo de bus.
/// </summary>
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);
}
}
/// <summary>
/// Pinta ruta solo a pie.
/// </summary>
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();
}
/// <summary>
/// Pinta ruta desde alternativa.
/// </summary>
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<string>(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();
}
/// <summary>
/// Recalcula con parametros.
/// </summary>
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();
}
/// <summary>
/// Reinicia seleccion.
/// </summary>
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
// =========================================================
/// <summary>
/// Calcula distancia metros.
/// </summary>
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;
}
}

View File

@@ -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
{
/// <summary>
/// Gestiona hmm.
/// </summary>
private static string HHmm(DateTime dt) => dt.ToString("HH:mm", CultureInfo.InvariantCulture);
/// <summary>
///
/// </summary>
private static DateTime Compose(DateOnly d, TimeOnly t)
=> d.ToDateTime(t);
/// <summary>
/// Añade seconds safe.
/// </summary>
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
/// <summary>
/// Gestiona codigo str.
/// </summary>
private static string LineaCodigoStr(InfoVariante v) => v?.Linea?.Codigo?.ToString() ?? "?";
// =======================
// HORARIOS (ZIP)
// =======================
private bool _usarHorariosTeoricos = true;
private bool _horariosOk = false;
private string? _horariosError = null;
/// <summary>
/// Sincroniza la penalizacion manual por parada con el modo horario activo.
/// </summary>
private void SincronizarSegundosPorParadaConHorarios()
{
if (_usarHorariosTeoricos)
{
_segundosPorParadaBus = 0;
return;
}
if (_segundosPorParadaBus == 0 && _segundosPorParadaBusManual >= 0)
_segundosPorParadaBus = _segundosPorParadaBusManual;
}
/// <summary>
/// Gestiona lista horarios con actual.
/// </summary>
private List<ProximaSalidaVm> 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<ProximaSalidaVm>(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;
}
/// <summary>
/// Calcula el orden lógico de una línea visible del popup, respetando TB y enviando las líneas B al bloque 100.
/// </summary>
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;
}
/// <summary>
/// Extrae el orden lógico visible de línea, itinerario y sentido para los horarios del popup de parada.
/// </summary>
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,
@"^(?<linea>[^.\s]+)\.(?<itin>\d+)-(?<sentido>[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<ProximaSalidaVm> 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);
}
}
/// <summary>
/// Alterna orden horarios.
/// </summary>
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
/// <summary>
/// Inicializa una nueva instancia de la clase ProximaSalidaVm.
/// </summary>
private sealed record ProximaSalidaVm(
string Hora,
string LineaTxt,
string RutaId,
string ExpId,
bool EsProximo,
string AnchorId,
string ItinerarioClave);
private List<ProximaSalidaVm> _nextDeps = new();
/// <summary>
/// Gestiona txt desde ruta id.
/// </summary>
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}";
}
/// <summary>
/// Gestiona toggle hora simulada.
/// </summary>
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);
/// <summary>
/// Gestiona local.
/// </summary>
private DateTime AhoraLocal()
{
if (_usarHoraSimulada)
return _fechaSimulada.ToDateTime(_horaSimulada);
return DateTime.Now;
}
/// <summary>
/// Inicializa la hora simulada con la hora real actual y la deja parada.
/// </summary>
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));
}
/// <summary>
/// Gestiona change fecha simulada.
/// </summary>
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();
}
/// <summary>
/// Gestiona change hora simulada.
/// </summary>
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();
}
/// <summary>
/// Ajusta la fecha y la hora simuladas al momento actual.
/// </summary>
private async Task IrAAhoraSimuladoAsync()
{
var ahora = DateTime.Now;
_fechaSimulada = DateOnly.FromDateTime(ahora);
_horaSimulada = TimeOnly.FromDateTime(ahora);
if (_usarHoraSimulada)
await RefrescarHoraUIAsync();
await PersistirConfiguracionAsync();
StateHasChanged();
}
/// <summary>
/// Restablece hora pruebas.
/// </summary>
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;
/// <summary>
/// Gestiona clock.
/// </summary>
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 */ }
});
}
/// <summary>
/// Gestiona clock.
/// </summary>
private void StopClock()
{
_clockCts?.Cancel();
_clockTimer?.Dispose();
_clockCts?.Dispose();
_clockTimer = null;
_clockCts = null;
}
/// <summary>
/// Actualiza hora ui.
/// </summary>
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);
}
/// <summary>
///
/// </summary>
public void Dispose()
{
_clockCts?.Cancel();
_clockTimer?.Dispose();
_clockCts?.Dispose();
StopRefrescoHistorialCompartido();
_dotRef?.Dispose();
}
/// <summary>
///
/// </summary>
private static IEnumerable<int> 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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!;
}

View File

@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

View File

@@ -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

Binary file not shown.

View File

@@ -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; }
}

View File

@@ -0,0 +1,25 @@
namespace RutasDBUS.Modelos.Historial;
/// <summary>
/// Escenario guardado de origen/destino para reutilizar pruebas de rutas.
/// </summary>
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<int> LineasPermitidas { get; set; } = new();
public DateTime FechaCreacionUtc { get; set; } = DateTime.UtcNow;
public DateTime FechaActualizacionUtc { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,31 @@
namespace RutasDBUS.Modelos.Importacion;
public sealed class ArchivoImportacionDbus
{
public string RutaZip { get; init; } = "";
public DateTime FechaArchivoUtc { get; init; }
public IReadOnlyList<FilaCalendarioCsv> Calendarios { get; init; } = Array.Empty<FilaCalendarioCsv>();
public IReadOnlyList<FilaCalendarioExcepcionCsv> CalendarioExcepciones { get; init; } = Array.Empty<FilaCalendarioExcepcionCsv>();
public IReadOnlyList<FilaExpedicionCsv> Expediciones { get; init; } = Array.Empty<FilaExpedicionCsv>();
public IReadOnlyList<FilaOperadorCsv> Operadores { get; init; } = Array.Empty<FilaOperadorCsv>();
public IReadOnlyList<FilaParadaCsv> Paradas { get; init; } = Array.Empty<FilaParadaCsv>();
public IReadOnlyList<FilaPatronCsv> Patrones { get; init; } = Array.Empty<FilaPatronCsv>();
public IReadOnlyList<FilaRutaCsv> Rutas { get; init; } = Array.Empty<FilaRutaCsv>();
public IReadOnlyList<FilaTarifaCsv> Tarifas { get; init; } = Array.Empty<FilaTarifaCsv>();
public IReadOnlyList<FilaTiempoParadaCsv> TiemposParada { get; init; } = Array.Empty<FilaTiempoParadaCsv>();
public IReadOnlyList<FilaTiempoRealCsv> TiemposReales { get; init; } = Array.Empty<FilaTiempoRealCsv>();
public IReadOnlyList<FilaTransbordoCsv> Transbordos { get; init; } = Array.Empty<FilaTransbordoCsv>();
public IReadOnlyList<FilaVehiculoCsv> Vehiculos { get; init; } = Array.Empty<FilaVehiculoCsv>();
public IReadOnlyDictionary<int, FilaParadaCsv> ParadasPorId { get; init; } = new Dictionary<int, FilaParadaCsv>();
public IReadOnlyDictionary<string, FilaRutaCsv> RutasPorId { get; init; } =
new Dictionary<string, FilaRutaCsv>(StringComparer.OrdinalIgnoreCase);
public IReadOnlyDictionary<string, FilaExpedicionCsv> ExpedicionesPorId { get; init; } =
new Dictionary<string, FilaExpedicionCsv>(StringComparer.OrdinalIgnoreCase);
public IReadOnlyDictionary<string, List<FilaTiempoParadaCsv>> TiemposParadaPorExpedicion { get; init; } =
new Dictionary<string, List<FilaTiempoParadaCsv>>(StringComparer.OrdinalIgnoreCase);
public IReadOnlyDictionary<string, List<FilaPatronCsv>> PatronesPorId { get; init; } =
new Dictionary<string, List<FilaPatronCsv>>(StringComparer.OrdinalIgnoreCase);
public IReadOnlyDictionary<string, List<FilaTransbordoCsv>> TransbordosPorRuta { get; init; } =
new Dictionary<string, List<FilaTransbordoCsv>>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,149 @@
namespace RutasDBUS.Modelos.Importacion;
/// <summary>
/// Inicializa una nueva instancia de la clase FilaCalendarioCsv.
/// </summary>
public sealed record FilaCalendarioCsv(
string Id,
int Lunes,
int Martes,
int Miercoles,
int Jueves,
int Viernes,
int Sabado,
int Domingo,
DateOnly FechaInicio,
DateOnly FechaFin);
/// <summary>
/// Inicializa una nueva instancia de la clase FilaCalendarioExcepcionCsv.
/// </summary>
public sealed record FilaCalendarioExcepcionCsv(
string Id,
DateOnly FechaEx,
int TipoExcepcion);
/// <summary>
/// Inicializa una nueva instancia de la clase FilaExpedicionCsv.
/// </summary>
public sealed record FilaExpedicionCsv(
string Id,
string IdRuta,
string DirDestino,
int Direccion,
string CambioExp,
string IdPatronExp,
TimeOnly? HSalida,
string IdCal);
/// <summary>
/// Inicializa una nueva instancia de la clase FilaOperadorCsv.
/// </summary>
public sealed record FilaOperadorCsv(
string Id,
string Nombre,
string Telefono,
string Url,
string Descripcion);
/// <summary>
/// Inicializa una nueva instancia de la clase FilaParadaCsv.
/// </summary>
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);
/// <summary>
/// Inicializa una nueva instancia de la clase FilaPatronCsv.
/// </summary>
public sealed record FilaPatronCsv(
string Id,
double PtoLatitudY,
double PtoLongitudX,
int PtoSecuencia,
double DistanciaRecorrida);
/// <summary>
/// Inicializa una nueva instancia de la clase FilaRutaCsv.
/// </summary>
public sealed record FilaRutaCsv(
string Id,
string Abreviatura,
string Nombre,
string Descripcion,
string TipoVehiculo,
string Url,
string Color);
/// <summary>
/// Inicializa una nueva instancia de la clase FilaTarifaCsv.
/// </summary>
public sealed record FilaTarifaCsv(
string Id,
string IdTarRu,
string IdParadaOrigen,
string IdParadaDestino,
string IdZonaOrigen,
string IdZonaDestino,
decimal? Precio,
string TipoPago,
int? TiempoTransbordo,
int? Transbordos);
/// <summary>
/// Inicializa una nueva instancia de la clase FilaTiempoParadaCsv.
/// </summary>
public sealed record FilaTiempoParadaCsv(
string IdExp,
TimeOnly? HoraLlegada,
TimeOnly? HoraSalida,
double DistanciaSiguienteParada,
int IdTiempoPar,
int SecPar,
string TipoSubidaPar,
string TipoBajadaPar,
string NuevaDirDestinoPar);
/// <summary>
/// Inicializa una nueva instancia de la clase FilaTiempoRealCsv.
/// </summary>
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);
/// <summary>
/// Inicializa una nueva instancia de la clase FilaTransbordoCsv.
/// </summary>
public sealed record FilaTransbordoCsv(
string IdRuta1,
string IdRuta2);
/// <summary>
/// Inicializa una nueva instancia de la clase FilaVehiculoCsv.
/// </summary>
public sealed record FilaVehiculoCsv(
string Id,
string IdTipo,
string DescripcionTipo,
int? CapacidadMaxima,
int? Asientos,
int? NumPuertas,
int? AforoActual);

View File

@@ -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);

View File

@@ -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; }
}

View File

@@ -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<MuestraElevacion>? MuestrasElevacion { get; set; }
public bool ElevacionCargando { get; set; }
public bool ElevacionLista => MuestrasElevacion is not null && MuestrasElevacion.Count > 0;
public InfoVariante? VarianteBus { get; set; }
}

View File

@@ -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; }
}

View File

@@ -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<int> 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<AlternativaRuta> Alternativas { get; set; } = new();
public int IndicePreferido { get; set; } = 0;
}

View File

@@ -0,0 +1,67 @@
namespace RutasDBUS.Modelos.RedBus;
/// <summary>
/// Información precalculada de una variante con la secuencia de paradas.
/// </summary>
public class InfoVariante
{
/// <summary>Línea a la que pertenece la variante.</summary>
public LineaBus Linea { get; set; } = default!;
/// <summary>Sentido dentro del itinerario.</summary>
public Sentido Sentido { get; set; } = default!;
/// <summary>Variante concreta de la línea.</summary>
public Variante Variante { get; set; } = default!;
public int CodigoItinerario { get; set; }
/// <summary>
/// Lista ordenada de códigos de parada que recorre la variante.
/// </summary>
public List<string> CodigosParadas { get; set; } = new();
}
/// <summary>
/// Indica en qué variante y en qué posición aparece una parada.
/// </summary>
public class AparicionParada
{
/// <summary>Variante en la que aparece la parada.</summary>
public InfoVariante Variante { get; set; } = default!;
/// <summary>Índice de la parada dentro de <see cref="InfoVariante.CodigosParadas"/>.</summary>
public int IndiceParada { get; set; }
}
public class TramoBus
{
public InfoVariante Variante { get; set; } = default!;
/// <summary>
/// Índice de parada inicial (incluida) dentro de la secuencia de paradas.
/// </summary>
public int IndiceInicio { get; set; }
/// <summary>
/// Índice de parada final (incluida) dentro de la secuencia de paradas.
/// OJO: al construir geometría se recorren trayectos: i = IndiceInicio .. IndiceFin-1.
/// </summary>
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;
}

View File

@@ -0,0 +1,59 @@
namespace RutasDBUS.Modelos.RedBus;
/// <summary>
/// Representa una línea de autobús con sus itinerarios.
/// </summary>
public class LineaBus
{
/// <summary>Identificador original de la ruta en ruta.csv.</summary>
public string IdRuta { get; set; } = "";
/// <summary>Código interno de la línea.</summary>
public string Codigo { get; set; } = "";
/// <summary>Nombre descriptivo de la línea.</summary>
public string Nombre { get; set; } = "";
/// <summary>Color de fondo en formato ARGB (ej. FF0000FF).</summary>
public string ColorArgb { get; set; } = "FF0000FF";
/// <summary>Código del itinerario por defecto.</summary>
public int CodigoItinerarioPorDefecto { get; set; }
/// <summary>Lista de itinerarios definidos para esta línea.</summary>
public List<Itinerario> Itinerarios { get; set; } = new();
}
/// <summary>Itinerario de una línea (por ejemplo ida / vuelta).</summary>
public class Itinerario
{
/// <summary>Código interno del itinerario.</summary>
public int Codigo { get; set; }
/// <summary>Sentidos configurados dentro de este itinerario.</summary>
public List<Sentido> Sentidos { get; set; } = new();
}
/// <summary>Sentido de un itinerario (A→B, B→A, etc.).</summary>
public class Sentido
{
/// <summary>Código del sentido.</summary>
public string Codigo { get; set; } = "";
/// <summary>Variantes existentes en este sentido.</summary>
public List<Variante> Variantes { get; set; } = new();
}
/// <summary>
/// Variante de un sentido, definida por la secuencia de trayectos que recorre.
/// </summary>
public class Variante
{
/// <summary>Código interno de la variante.</summary>
public int Codigo { get; set; }
/// <summary>
/// Identificadores de trayectos que se recorren en esta variante, en orden.
/// </summary>
public List<int> IdsTrayectos { get; set; } = new();
}

View File

@@ -0,0 +1,43 @@
namespace RutasDBUS.Modelos.RedBus;
/// <summary>
/// Representa una parada de autobús con código, nombre y coordenadas.
/// </summary>
public class Parada
{
/// <summary>Código interno de la parada (por ejemplo 34.1).</summary>
public string Codigo { get; set; } = "";
/// <summary>Nombre descriptivo completo de la parada.</summary>
public string Nombre { get; set; } = "";
/// <summary>
/// Nombre corto de la parada (campo NombreCorto del fichero de líneas).
/// </summary>
public string NombreCorto { get; set; } = "";
/// <summary>
/// Abreviatura de la parada (campo Abreviatura).
/// </summary>
public string Abreviatura { get; set; } = "";
/// <summary>
/// Altura de la parada en metros (campo Altura).
/// </summary>
public double Altura { get; set; }
/// <summary>Latitud en grados decimales.</summary>
public double Latitud { get; set; }
/// <summary>Longitud en grados decimales.</summary>
public double Longitud { get; set; }
/// <summary>Zona previa de la parada (si se utiliza en el fichero).</summary>
public string PreZona { get; set; } = "";
/// <summary>Zona posterior de la parada (si se utiliza en el fichero).</summary>
public string PostZona { get; set; } = "";
/// <summary>Zona de salida de la parada (si se utiliza en el fichero).</summary>
public string ZonaSalida { get; set; } = "";
}

View File

@@ -0,0 +1,20 @@
 using RutasDBUS.Modelos.RedBus;
/// <summary>
/// Representa una posible ruta de bus entre dos paradas,
/// formada por varios tramos de bus consecutivos.
/// </summary>
public class RutaBusAlternativa
{
/// <summary>Tramos de bus que componen la ruta.</summary>
public List<TramoBus> Tramos { get; set; } = new();
/// <summary>Número de Transbordos (cambios de línea/variante).</summary>
public int NumeroTransbordos { get; set; }
/// <summary>Distancia total estimada en bus (metros).</summary>
public double DistanciaTotalBus { get; set; }
/// <summary>Código de paradas de Transbordo intermedias.</summary>
public List<string> CodigosParadasTransbordo { get; set; } = new();
}

View File

@@ -0,0 +1,28 @@
namespace RutasDBUS.Modelos.RedBus;
/// <summary>
/// Representa un tramo de línea entre dos paradas con una lista de puntos intermedios.
/// </summary>
public class Trayecto
{
/// <summary>Identificador interno del trayecto.</summary>
public int Id { get; set; }
/// <summary>Código de la parada origen de este trayecto.</summary>
public string CodigoParadaOrigen { get; set; } = "";
/// <summary>Código de la parada destino de este trayecto.</summary>
public string CodigoParadaDestino { get; set; } = "";
/// <summary>
/// Lista de coordenadas [latitud, longitud] que describen la geometría del trayecto.
/// </summary>
public List<double[]> Posiciones { get; set; } = new();
}
public class OpcionesRedBus
{
public string ArchivoDatos { get; set; } = "";
public double DistanciaMaximaTransbordoMetros { get; set; } = 250;
}

79
RutasDBUS/Program.cs Normal file
View File

@@ -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<OpcionesRedBus>(
builder.Configuration.GetSection("RedBus"));
// (Opcional) Si luego quieres opciones de Mapa/Elevación/Planificador, se hace igual:
// builder.Services.Configure<OpcionesMapa>(builder.Configuration.GetSection("Mapa"));
// builder.Services.Configure<OpcionesElevacion>(builder.Configuration.GetSection("Elevacion"));
// builder.Services.Configure<OpcionesPlanificadorRutas>(builder.Configuration.GetSection("PlanificadorRutas"));
// =====================================================
// Servicios propios
// =====================================================
// Servicio de red de autobuses (paradas, líneas, trayectos...)
builder.Services.AddSingleton<IServicioArchivoImportacionDbus, ServicioArchivoImportacionDbusMonitorizado>();
builder.Services.AddSingleton<CreadorRedTransporteDbus>();
builder.Services.AddSingleton<IRedBusServicio, RedBusServicio>();
builder.Services.AddSingleton<IHistorialRutasCompartidoService, ServicioHistorialRutasCompartido>();
builder.Services.AddSingleton<IGeocodificadorLocalServicio, GeocodificadorLocalServicio>();
builder.Services.AddSingleton<ServicioHorariosDbus>();
builder.Services.AddSingleton<CreadorIndiceTransitoDbus>();
builder.Services.AddSingleton<ServicioIndiceTransitoDbus>();
builder.Services.AddSingleton<BuscadorRutasTemporalesDbus>();
builder.Services.AddSingleton<IPlanificadorRutas, PlanificadorRutasServicio>();
// HttpClient genérico (lo usas en el Razor: @inject HttpClient Http)
builder.Services.AddHttpClient();
// Clientes HTTP tipados
builder.Services.AddHttpClient<IClienteOsrm, ClienteOsrm>();
builder.Services.AddHttpClient<IClienteValhalla, ClienteValhalla>();
var app = builder.Build();
// =====================================================
// Pipeline
// =====================================================
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();

View File

@@ -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"
}
}
}
}

View File

@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<None Remove="Modelos\Horarios\zip_Importacion_20260213~0900.zip" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="LeafletForBlazor" Version="2.2.8.4" />
</ItemGroup>
<ItemGroup>
<Content Include="Modelos\Horarios\**\*.zip">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="Modelos\Callejero\**\*.zip">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\app.js">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</Content>
<Content Update="wwwroot\favicon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -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;
/// <summary>
/// Implementación del cliente OSRM para calcular rutas a pie.
/// </summary>
public class ClienteOsrm : IClienteOsrm
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
/// <summary>
/// Inicializa una nueva instancia de la clase ClienteOsrm.
/// </summary>
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('/');
}
/// <inheritdoc />
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<RespuestaOsrm>(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);
}
/// <summary>
/// Fuerza que la geometria empiece y termine exactamente en los puntos solicitados.
/// </summary>
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<RutaOsrm>? 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<System.Collections.Generic.List<double>>? Coordenadas { get; set; }
}
}

View File

@@ -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;
/// <summary>
/// Inicializa una nueva instancia de la clase ClienteValhalla.
/// </summary>
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<LocalizacionValhalla>
{
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<RespuestaRutaValhalla>();
}
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<LocalizacionValhalla> 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<TramoValhalla?> 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
}
/// <summary>
///
/// </summary>
private readonly record struct Coordenada(double Latitud, double Longitud);
/// <summary>
/// Decodifica polyline6 (factor 1e6).
/// </summary>
private static List<Coordenada> DecodificarPolyline6(string encoded)
{
var coords = new List<Coordenada>();
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<Coordenada>();
}
return coords;
}
/// <summary>
/// Lee siguiente valor.
/// </summary>
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);
}
/// <summary>
/// Fuerza que la geometria empiece y termine exactamente en los puntos solicitados.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,28 @@
using System.Threading.Tasks;
namespace RutasDBUS.Servicios.Caminata;
/// <summary>
/// Cliente para consumir el servicio OSRM y obtener rutas a pie.
/// </summary>
public interface IClienteOsrm
{
/// <summary>
/// Obtiene una ruta a pie entre dos coordenadas.
/// </summary>
/// <param name="latitudDesde">Latitud de origen.</param>
/// <param name="longitudDesde">Longitud de origen.</param>
/// <param name="latitudHasta">Latitud de destino.</param>
/// <param name="longitudHasta">Longitud de destino.</param>
/// <returns>
/// 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.
/// </returns>
Task<(double[,]? Linea, double DistanciaMetros, double DuracionSegundos)> ObtenerRutaAPieAsync(
double latitudDesde,
double longitudDesde,
double latitudHasta,
double longitudHasta);
}

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
namespace RutasDBUS.Servicios.Caminata;
public interface IClienteValhalla
{
/// <summary>
/// Calcula una ruta entre dos puntos usando Valhalla.
/// Devuelve la línea (lat,lon), la distancia (m) y la duración (s).
/// </summary>
Task<(double[,]? Linea, double DistanciaMetros, double DuracionSegundos)> ObtenerRutaAsync(
double latitudDesde,
double longitudDesde,
double latitudHasta,
double longitudHasta,
string costing = "pedestrian"); // "pedestrian", "bicycle", etc.
}

View File

@@ -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<EntradaGeocodificada>? _indice;
public GeocodificadorLocalServicio(IWebHostEnvironment entorno)
{
_entorno = entorno;
}
public async Task<IReadOnlyList<LugarGeocodificado>> BuscarAsync(
string texto,
int maxResultados = 8,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(texto))
return Array.Empty<LugarGeocodificado>();
var indice = await ObtenerIndiceAsync(cancellationToken);
if (indice.Count == 0)
return Array.Empty<LugarGeocodificado>();
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<LugarGeocodificado>();
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<int> ContarLugaresAsync(CancellationToken cancellationToken = default)
{
var indice = await ObtenerIndiceAsync(cancellationToken);
return indice.Count;
}
private async Task<IReadOnlyList<EntradaGeocodificada>> 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<EntradaGeocodificada> CargarIndice(CancellationToken cancellationToken)
{
var rutaZip = Path.Combine(
_entorno.ContentRootPath,
RutaCallejeroZipRelativa.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(rutaZip))
return Array.Empty<EntradaGeocodificada>();
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<EntradaGeocodificada>();
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<EntradaGeocodificada>();
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<EntradaGeocodificada>(70000);
var vistos = new HashSet<string>(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<string> campos,
IReadOnlyDictionary<string, int> indices,
ISet<string> 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<string> 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<string>();
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<string> campos, IReadOnlyDictionary<string, int> 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<string> ParsearCsv(string linea)
{
var campos = new List<string>();
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);
}

View File

@@ -0,0 +1,13 @@
using RutasDBUS.Modelos.Geocodificacion;
namespace RutasDBUS.Servicios.Geocodificacion;
public interface IGeocodificadorLocalServicio
{
Task<IReadOnlyList<LugarGeocodificado>> BuscarAsync(
string texto,
int maxResultados = 8,
CancellationToken cancellationToken = default);
Task<int> ContarLugaresAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
using RutasDBUS.Modelos.Historial;
namespace RutasDBUS.Servicios.Historial;
public interface IHistorialRutasCompartidoService
{
Task<IReadOnlyList<RegistroHistorialRuta>> ObtenerAsync(CancellationToken ct = default);
Task<RegistroHistorialRuta> GuardarAsync(RegistroHistorialRuta registro, CancellationToken ct = default);
Task<bool> EliminarAsync(string id, CancellationToken ct = default);
}

View File

@@ -0,0 +1,343 @@
using System.Globalization;
using System.Text.Json;
using RutasDBUS.Modelos.Historial;
namespace RutasDBUS.Servicios.Historial;
/// <summary>
/// Mantiene un historial compartido de escenarios de origen/destino para toda la app.
/// </summary>
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<IReadOnlyList<RegistroHistorialRuta>> 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<RegistroHistorialRuta> 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<bool> 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<List<RegistroHistorialRuta>> LeerInternoAsync(CancellationToken ct)
{
if (!File.Exists(_rutaFichero))
return new List<RegistroHistorialRuta>();
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<List<RegistroHistorialRuta>>(stream, JsonOptions, ct);
return items ?? new List<RegistroHistorialRuta>();
}, ct);
}
private async Task EscribirInternoAsync(List<RegistroHistorialRuta> 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<T> EjecutarConReintentosAsync<T>(Func<Task<T>> 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<Task> 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<string> 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<int> NormalizarLineas(IEnumerable<int>? lineas)
=> (lineas ?? Array.Empty<int>())
.Where(linea => linea > 0)
.Distinct()
.OrderBy(linea => linea)
.ToList();
private static string NormalizarTextoClave(string? texto)
=> texto?.Trim().ToUpperInvariant() ?? string.Empty;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
using RutasDBUS.Modelos.Importacion;
namespace RutasDBUS.Servicios.Importacion;
public interface IServicioArchivoImportacionDbus
{
Task<ArchivoImportacionDbus> ObtenerAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<ModeloHorarioDbusInfo>> ListarModelosAsync(CancellationToken cancellationToken = default);
Task<ModeloHorarioDbusInfo?> ObtenerModeloActivoAsync(CancellationToken cancellationToken = default);
Task SeleccionarModeloAsync(string idModelo, CancellationToken cancellationToken = default);
Task<ModeloHorarioDbusInfo> GuardarModeloSubidoAsync(
string nombreArchivo,
Stream contenido,
CancellationToken cancellationToken = default);
}

View File

@@ -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;
/// <summary>
/// Inicializa una nueva instancia de la clase ServicioArchivoImportacionDbus.
/// </summary>
public ServicioArchivoImportacionDbus(
IWebHostEnvironment entorno,
IOptions<OpcionesRedBus> opciones)
{
_entorno = entorno;
_opciones = opciones.Value ?? new OpcionesRedBus();
}
/// <summary>
///
/// </summary>
public async Task<ArchivoImportacionDbus> 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<ArchivoImportacionDbus> 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);
}
/// <summary>
/// Resuelve zip camino.
/// </summary>
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;
}
/// <summary>
/// Carga el archivo.
/// </summary>
private async Task<ArchivoImportacionDbus> 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<string, FilaTransbordoCsv>(x.IdRuta1, x),
new KeyValuePair<string, FilaTransbordoCsv>(x.IdRuta2, x)
})
.GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Select(x => x.Value).ToList(),
StringComparer.OrdinalIgnoreCase)
};
}
/// <summary>
/// Busca entrada.
/// </summary>
private static ZipArchiveEntry? FindEntry(ZipArchive zip, string fileName)
=> zip.Entries.FirstOrDefault(entry =>
entry.FullName.EndsWith("/" + fileName, StringComparison.OrdinalIgnoreCase) ||
entry.FullName.Equals(fileName, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Lee csv rows.
/// </summary>
private static async Task<List<string[]>> ReadCsvRowsAsync(ZipArchiveEntry? entry, CancellationToken ct)
{
var rows = new List<string[]>();
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;
}
/// <summary>
/// Lee el contenido de un CSV intentando UTF-8 y cayendo a Windows-1252 cuando el archivo viene en esa codificación.
/// </summary>
private static async Task<string> 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);
}
}
/// <summary>
/// Gestiona csv linea.
/// </summary>
private static string[] SplitCsvLine(string line)
{
var values = new List<string>(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();
}
/// <summary>
/// Gestiona of.
/// </summary>
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;
}
/// <summary>
///
/// </summary>
private static string Get(string[] row, int index)
=> index >= 0 && index < row.Length ? row[index] : "";
/// <summary>
/// Analiza int.
/// </summary>
private static int ParseInt(string[] row, int index)
=> int.TryParse(Get(row, index), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : 0;
/// <summary>
/// Analiza nullable int.
/// </summary>
private static int? ParseNullableInt(string[] row, int index)
=> int.TryParse(Get(row, index), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null;
/// <summary>
/// Analiza double.
/// </summary>
private static double ParseDouble(string[] row, int index)
=> double.TryParse(Get(row, index), NumberStyles.Any, CultureInfo.InvariantCulture, out var value) ? value : 0;
/// <summary>
/// Analiza nullable decimal.
/// </summary>
private static decimal? ParseNullableDecimal(string[] row, int index)
=> decimal.TryParse(Get(row, index), NumberStyles.Any, CultureInfo.InvariantCulture, out var value) ? value : null;
/// <summary>
/// Analiza date.
/// </summary>
private static DateOnly ParseDate(string value)
=> DateOnly.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
? date
: default;
/// <summary>
/// Analiza tiempo.
/// </summary>
private static TimeOnly? ParseTime(string value)
=> TimeOnly.TryParseExact(value, "HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var time)
? time
: null;
/// <summary>
/// Lee calendarios.
/// </summary>
private static async Task<List<FilaCalendarioCsv>> 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<FilaCalendarioCsv>(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;
}
/// <summary>
/// Lee calendario excepciones.
/// </summary>
private static async Task<List<FilaCalendarioExcepcionCsv>> 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<FilaCalendarioExcepcionCsv>(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;
}
/// <summary>
/// Lee expediciones.
/// </summary>
private static async Task<List<FilaExpedicionCsv>> 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<FilaExpedicionCsv>(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;
}
/// <summary>
/// Lee operadores.
/// </summary>
private static async Task<List<FilaOperadorCsv>> 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<FilaOperadorCsv>(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;
}
/// <summary>
/// Lee paradas.
/// </summary>
private static async Task<List<FilaParadaCsv>> 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<FilaParadaCsv>(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;
}
/// <summary>
/// Lee patrones.
/// </summary>
private static async Task<List<FilaPatronCsv>> 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<FilaPatronCsv>(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;
}
/// <summary>
/// Lee rutas.
/// </summary>
private static async Task<List<FilaRutaCsv>> 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<FilaRutaCsv>(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;
}
/// <summary>
/// Lee tarifas.
/// </summary>
private static async Task<List<FilaTarifaCsv>> 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<FilaTarifaCsv>(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;
}
/// <summary>
/// Lee tiempos parada.
/// </summary>
private static async Task<List<FilaTiempoParadaCsv>> 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<FilaTiempoParadaCsv>(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;
}
/// <summary>
/// Lee tiempos reales.
/// </summary>
private static async Task<List<FilaTiempoRealCsv>> 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<FilaTiempoRealCsv>(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;
}
/// <summary>
/// Lee transbordos.
/// </summary>
private static async Task<List<FilaTransbordoCsv>> 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<FilaTransbordoCsv>(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;
}
/// <summary>
/// Lee vehiculos.
/// </summary>
private static async Task<List<FilaVehiculoCsv>> 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<FilaVehiculoCsv>(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;
}
}

View File

@@ -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<OpcionesRedBus> _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<OpcionesRedBus> opciones)
{
_entorno = entorno;
_opciones = opciones;
}
public async Task<ArchivoImportacionDbus> 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<IReadOnlyList<ModeloHorarioDbusInfo>> 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<IReadOnlyList<ModeloHorarioDbusInfo>>(modelos);
}
public async Task<ModeloHorarioDbusInfo?> 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<ModeloHorarioDbusInfo> 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<string>(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<string> 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<EstadoModeloHorario>(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; }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,407 @@
using RutasDBUS.Modelos.Importacion;
using RutasDBUS.Modelos.RedBus;
using RutasDBUS.Servicios.RedBus;
namespace RutasDBUS.Servicios.Planificacion;
/// <summary>
/// Construye el indice temporal compartido a partir del ZIP y de la red visual.
/// </summary>
public sealed class CreadorIndiceTransitoDbus
{
private const int EscalaGrid = 400;
/// <summary>
/// Construye el indice temporal de la red.
/// </summary>
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<string, PatronTransitoDbus>(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<string, List<ReferenciaPatronParadaDbus>>(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<ReferenciaPatronParadaDbus>(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);
}
/// <summary>
/// Construye el mapa de stop id interno a codigo de parada.
/// </summary>
private static Dictionary<int, string> ConstruirCodigosPorIdParada(
ArchivoImportacionDbus archivo,
IReadOnlyDictionary<string, Parada> paradasPorCodigo)
{
var resultado = new Dictionary<int, string>();
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;
}
/// <summary>
/// Construye las variantes enriquecidas de la red visual.
/// </summary>
private static List<InfoVariante> ConstruirVariantes(IRedBusServicio redBusServicio)
{
var variantes = new List<InfoVariante>(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;
}
/// <summary>
/// Construye una expedicion programada alineada con el patron.
/// </summary>
private static ExpedicionTransitoDbus? ConstruirExpedicion(
FilaExpedicionCsv expedicion,
IReadOnlyList<FilaTiempoParadaCsv> tiemposOrdenados,
PatronTransitoDbus patron,
IReadOnlyDictionary<int, string> 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
};
}
/// <summary>
/// Calcula la distancia acumulada por parada de una variante.
/// </summary>
private static IReadOnlyList<double> ConstruirDistanciaAcumulada(
InfoVariante variante,
IRedBusServicio redBusServicio,
IReadOnlyDictionary<string, Parada> 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;
}
/// <summary>
/// Construye el grid espacial de paradas.
/// </summary>
private static Dictionary<(int cx, int cy), List<Parada>> ConstruirGridParadas(IReadOnlyList<Parada> paradas)
{
var resultado = new Dictionary<(int cx, int cy), List<Parada>>();
foreach (var parada in paradas)
{
var celda = Celda(parada.Latitud, parada.Longitud, EscalaGrid);
if (!resultado.TryGetValue(celda, out var lista))
{
lista = new List<Parada>(16);
resultado[celda] = lista;
}
lista.Add(parada);
}
return resultado;
}
/// <summary>
/// Construye las transferencias a pie entre paradas cercanas.
/// </summary>
private static Dictionary<string, List<TransferenciaPeatonalDbus>> ConstruirTransferencias(
IReadOnlyList<Parada> paradas,
IReadOnlyDictionary<(int cx, int cy), List<Parada>> gridParadas,
int distanciaMaximaTransferenciaMetros)
{
var transferencias = new Dictionary<string, List<TransferenciaPeatonalDbus>>(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<TransferenciaPeatonalDbus>(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;
}
/// <summary>
/// Crea la clave de respaldo para una variante.
/// </summary>
private static string CrearClaveFallbackVariante(InfoVariante variante)
=> CrearClaveFallback(
UtilidadesTransitoDbus.LineaCodigoStr(variante),
variante.CodigoItinerario,
UtilidadesTransitoDbus.NormalizarSentidoCodigo(variante.Sentido?.Codigo),
variante.CodigosParadas);
/// <summary>
/// Crea una clave de respaldo por extremos y longitud.
/// </summary>
private static string CrearClaveFallback(string codigoLinea, int codigoItinerario, string sentido, IReadOnlyList<string> 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));
}

View File

@@ -0,0 +1,14 @@
using RutasDBUS.Modelos.Planificacion;
namespace RutasDBUS.Servicios.Planificacion;
public interface IPlanificadorRutas
{
void InvalidarCachesModelo();
Task<ResultadoPlanificadorRuta> CalcularAsync(
double[] origenLatLon,
double[] destinoLatLon,
ParametrosPlanificadorRuta parametros,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,200 @@
using RutasDBUS.Modelos.RedBus;
namespace RutasDBUS.Servicios.Planificacion;
/// <summary>
/// Indice compartido de paradas, patrones y transferencias del motor de transito.
/// </summary>
public sealed class IndiceTransitoDbus
{
private readonly Dictionary<(int cx, int cy), List<Parada>> _gridParadas;
private readonly int _escalaGrid;
/// <summary>
/// Inicializa una nueva instancia del indice.
/// </summary>
public IndiceTransitoDbus(
IReadOnlyDictionary<string, Parada> paradasPorCodigo,
IReadOnlyList<PatronTransitoDbus> patrones,
IReadOnlyDictionary<string, List<ReferenciaPatronParadaDbus>> patronesPorParada,
IReadOnlyDictionary<string, List<TransferenciaPeatonalDbus>> transferenciasPorParada,
Dictionary<(int cx, int cy), List<Parada>> gridParadas,
int escalaGrid)
{
ParadasPorCodigo = paradasPorCodigo;
Patrones = patrones;
PatronesPorParada = patronesPorParada;
TransferenciasPorParada = transferenciasPorParada;
_gridParadas = gridParadas;
_escalaGrid = escalaGrid;
}
/// <summary>
/// Paradas cargadas por codigo.
/// </summary>
public IReadOnlyDictionary<string, Parada> ParadasPorCodigo { get; }
/// <summary>
/// Patrones transitables construidos desde expediciones reales.
/// </summary>
public IReadOnlyList<PatronTransitoDbus> Patrones { get; }
/// <summary>
/// Patrones que pasan por cada parada.
/// </summary>
public IReadOnlyDictionary<string, List<ReferenciaPatronParadaDbus>> PatronesPorParada { get; }
/// <summary>
/// Transferencias a pie entre paradas cercanas.
/// </summary>
public IReadOnlyDictionary<string, List<TransferenciaPeatonalDbus>> TransferenciasPorParada { get; }
/// <summary>
/// Devuelve las paradas cercanas a unas coordenadas.
/// </summary>
public List<Parada> 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<Parada>(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));
}
/// <summary>
/// Patron de viaje con su secuencia de paradas y sus expediciones.
/// </summary>
public sealed class PatronTransitoDbus
{
/// <summary>
/// Identificador estable del patron.
/// </summary>
public string Id { get; init; } = "";
/// <summary>
/// Identificador del patron de expedicion del ZIP.
/// </summary>
public string IdPatronExp { get; init; } = "";
/// <summary>
/// Identificador bruto de la ruta del ZIP.
/// </summary>
public string IdRuta { get; init; } = "";
/// <summary>
/// Codigo visible de la linea.
/// </summary>
public string CodigoLinea { get; init; } = "";
/// <summary>
/// Codigo numerico de la linea si existe.
/// </summary>
public int? NumeroLinea { get; init; }
/// <summary>
/// Codigo de itinerario.
/// </summary>
public int CodigoItinerario { get; init; }
/// <summary>
/// Sentido normalizado.
/// </summary>
public string Sentido { get; init; } = "";
/// <summary>
/// Destino descriptivo.
/// </summary>
public string Destino { get; init; } = "";
/// <summary>
/// Variante de la red visual asociada al patron.
/// </summary>
public InfoVariante Variante { get; init; } = default!;
/// <summary>
/// Secuencia ordenada de codigos de parada.
/// </summary>
public IReadOnlyList<string> CodigosParada { get; init; } = Array.Empty<string>();
/// <summary>
/// Distancia acumulada por parada para calculos aproximados.
/// </summary>
public IReadOnlyList<double> DistanciaAcumuladaPorParadaMetros { get; init; } = Array.Empty<double>();
/// <summary>
/// Expediciones que circulan por este patron.
/// </summary>
public List<ExpedicionTransitoDbus> Expediciones { get; } = new();
}
/// <summary>
/// Expedicion concreta de un patron.
/// </summary>
public sealed class ExpedicionTransitoDbus
{
/// <summary>
/// Identificador de la expedicion.
/// </summary>
public string Id { get; init; } = "";
/// <summary>
/// Calendario asociado.
/// </summary>
public string IdCalendario { get; init; } = "";
/// <summary>
/// Tiempos de llegada absolutos respecto al inicio del dia de servicio.
/// </summary>
public IReadOnlyList<int> LlegadasSegundos { get; init; } = Array.Empty<int>();
/// <summary>
/// Tiempos de salida absolutos respecto al inicio del dia de servicio.
/// </summary>
public IReadOnlyList<int> SalidasSegundos { get; init; } = Array.Empty<int>();
}
/// <summary>
/// Referencia de una parada dentro de un patron.
/// </summary>
public sealed class ReferenciaPatronParadaDbus
{
/// <summary>
/// Patron al que pertenece la parada.
/// </summary>
public PatronTransitoDbus Patron { get; init; } = default!;
/// <summary>
/// Indice de la parada dentro del patron.
/// </summary>
public int IndiceParada { get; init; }
}
/// <summary>
/// Conexion peatonal entre dos paradas.
/// </summary>
public sealed class TransferenciaPeatonalDbus
{
/// <summary>
/// Codigo de la parada destino.
/// </summary>
public string CodigoParadaDestino { get; init; } = "";
/// <summary>
/// Distancia aproximada de la transferencia.
/// </summary>
public double DistanciaMetros { get; init; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
using RutasDBUS.Servicios.Importacion;
using RutasDBUS.Servicios.RedBus;
namespace RutasDBUS.Servicios.Planificacion;
/// <summary>
/// Proporciona un indice temporal compartido y cacheado para toda la app.
/// </summary>
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;
/// <summary>
/// Inicializa una nueva instancia del servicio.
/// </summary>
public ServicioIndiceTransitoDbus(
IRedBusServicio redBusServicio,
IServicioArchivoImportacionDbus servicioArchivoImportacion,
CreadorIndiceTransitoDbus creadorIndice)
{
_redBusServicio = redBusServicio;
_servicioArchivoImportacion = servicioArchivoImportacion;
_creadorIndice = creadorIndice;
}
/// <summary>
/// Devuelve el indice temporal, construyendolo una sola vez.
/// </summary>
public async Task<IndiceTransitoDbus> 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();
}
}
}

View File

@@ -0,0 +1,405 @@
using System.Globalization;
using System.Text.RegularExpressions;
using RutasDBUS.Modelos.Planificacion;
using RutasDBUS.Modelos.RedBus;
namespace RutasDBUS.Servicios.Planificacion;
/// <summary>
/// Agrupa utilidades comunes del motor de planificacion.
/// </summary>
internal static class UtilidadesTransitoDbus
{
/// <summary>
/// Quita prefijos y decimales de un codigo numerico.
/// </summary>
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();
}
/// <summary>
/// Normaliza el codigo de parada al formato usado por la app.
/// </summary>
public static string NormalizarCodigoParada(string? codigo)
=> CodigoSinDecimal(codigo);
/// <summary>
/// Convierte la direccion del CSV a un sentido normalizado.
/// </summary>
public static string DireccionZipASentido(int direccion)
=> direccion switch
{
1 => "I",
2 => "V",
0 => "I",
_ => "?"
};
/// <summary>
/// Normaliza un codigo de sentido.
/// </summary>
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",
_ => "?"
};
}
/// <summary>
/// Extrae el codigo de itinerario a partir de la ruta y del patron.
/// </summary>
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;
}
/// <summary>
/// Extrae el codigo de variante desde el identificador del patron.
/// </summary>
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;
}
/// <summary>
/// Resuelve el instante efectivo de salida.
/// </summary>
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);
}
/// <summary>
/// Suma segundos evitando cambios inutiles cuando la duracion es cero.
/// </summary>
public static DateTime AddSecondsSafe(DateTime fecha, double segundos)
=> segundos <= 0 ? fecha : fecha.AddSeconds(segundos);
/// <summary>
/// Obtiene el codigo textual de una linea.
/// </summary>
public static string LineaCodigoStr(InfoVariante variante)
=> variante.Linea.Codigo?.ToString() ?? "?";
/// <summary>
/// Indica si pasar de un tramo al siguiente exige cambiar realmente de bus.
/// </summary>
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;
}
/// <summary>
/// Cuenta solo los cambios que obligan a bajarse y subir a otro bus.
/// </summary>
public static int ContarTransbordosReales(IReadOnlyList<TramoBus>? 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;
}
/// <summary>
/// Detecta rutas que encadenan demasiadas continuidades de la misma linea sin transbordo real.
/// </summary>
public static bool TieneContinuidadesNoRealesConsecutivasExcesivas(
IReadOnlyList<TramoBus>? 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;
}
/// <summary>
/// Extrae el numero de linea desde codigos numericos o alfanumericos como TB6.
/// </summary>
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;
}
/// <summary>
/// Comprueba si un destino descriptivo merece mostrarse tal cual al usuario.
/// </summary>
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);
}
/// <summary>
/// Resuelve un destino visible usando el texto original o, si no sirve, la ultima parada conocida.
/// </summary>
public static string ResolverDestinoDescriptivo(
string? destinoOriginal,
IReadOnlyList<string> codigosParada,
IReadOnlyDictionary<string, Parada> 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 "";
}
/// <summary>
/// Calcula la distancia entre dos coordenadas con Haversine.
/// </summary>
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;
}
/// <summary>
/// Estima la duracion andando.
/// </summary>
public static double EstimarDuracionPieSegundos(double distanciaMetros, ParametrosPlanificadorRuta parametros)
{
var velocidad = parametros.VelocidadPieKmH * 1000.0 / 3600.0;
return distanciaMetros / Math.Max(0.1, velocidad);
}
/// <summary>
/// Calcula la distancia peatonal corregida para coste interno sin reducir nunca la distancia real.
/// </summary>
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);
}
/// <summary>
/// Calcula un coste interno que penaliza de forma progresiva la caminata sin cambiar el tiempo real.
/// </summary>
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;
}
/// <summary>
/// Calcula la penalizacion interna por transbordos reales sin cambiar el tiempo visible.
/// </summary>
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;
}
/// <summary>
/// Calcula el coste interno aplicando preferencias de caminata y transbordos reales.
/// </summary>
public static double CalcularCosteConPenalizacionesRankingSegundos(
double duracionRealSegundos,
ParametrosPlanificadorRuta parametros,
int numeroTransbordosReales,
params double[] distanciasPieMetros)
{
return CalcularCosteConPenalizacionPieSegundos(duracionRealSegundos, parametros, distanciasPieMetros)
+ CalcularPenalizacionTransbordosRankingSegundos(numeroTransbordosReales, parametros);
}
/// <summary>
/// Estima la duracion en bus.
/// </summary>
public static double EstimarDuracionBusSegundos(double distanciaMetros, ParametrosPlanificadorRuta parametros)
{
var velocidad = parametros.VelocidadBusKmH * 1000.0 / 3600.0;
return distanciaMetros / Math.Max(0.1, velocidad);
}
/// <summary>
/// Crea una firma estable de una variante.
/// </summary>
public static string FirmaVariante(string codigoLinea, int codigoItinerario, string sentido, IReadOnlyList<string> codigosParada)
=> $"{codigoLinea}|{codigoItinerario}|{sentido}|{string.Join(">", codigosParada)}";
/// <summary>
/// Crea una firma estable de una ruta de bus.
/// </summary>
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}"));
}

View File

@@ -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
{
/// <summary>
///
/// </summary>
public RedTransporteDbus Build(ArchivoImportacionDbus archive)
{
var paradas = new List<Parada>(archive.Paradas.Count);
var trayectos = new Dictionary<int, Trayecto>();
var lineas = new List<LineaBus>(archive.Rutas.Count);
var paradaById = new Dictionary<int, Parada>();
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<string, LineaBus>(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<int>()
})
.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<string, VarianteTemporal>(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<double[]>();
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<double[]>
{
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<int> StopIds { get; set; } = new();
}
/// <summary>
/// Gestiona segmentos del patron.
/// </summary>
private static List<List<double[]>> ConstruirSegmentosDelPatron(
List<double[]> stopLatLon,
List<double[]> patronLatLon)
{
var res = new List<List<double[]>>();
if (stopLatLon.Count < 2)
return res;
if (patronLatLon.Count < 2)
{
for (var i = 0; i < stopLatLon.Count - 1; i++)
{
res.Add(new List<double[]>
{
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<double[]>();
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;
}
/// <summary>
/// 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.
/// </summary>
private static List<int> CalcularIndicesParadasEnPatron(
List<double[]> stopLatLon,
List<double[]> patronLatLon)
{
var indices = new List<int>(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;
}
/// <summary>
/// Busca el punto del patron mas cercano a una parada dentro de un rango valido.
/// </summary>
private static int BuscarIndicePatronMasCercano(
double[] paradaLatLon,
List<double[]> 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;
}
/// <summary>
/// Quita duplicados consecutivos.
/// </summary>
private static List<double[]> QuitarDuplicadosConsecutivos(List<double[]> puntos)
{
var res = new List<double[]>(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;
}
/// <summary>
///
/// </summary>
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;
}
/// <summary>
/// Gestiona lat lon valida.
/// </summary>
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;
/// <summary>
///
/// </summary>
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);
}
/// <summary>
///
/// </summary>
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);
}
/// <summary>
/// Gestiona sin decimal.
/// </summary>
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();
}
/// <summary>
/// Normaliza codigo parada.
/// </summary>
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();
}
/// <summary>
/// Normaliza color argb.
/// </summary>
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";
}
/// <summary>
/// Gestiona zip a sentido.
/// </summary>
private static string DireccionZipASentido(int direccion) =>
direccion switch
{
1 => "I",
2 => "V",
0 => "I",
_ => "?"
};
/// <summary>
/// Normaliza sentido codigo.
/// </summary>
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",
_ => "?"
};
}
/// <summary>
/// Gestiona codigo variante.
/// </summary>
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;
}
/// <summary>
/// Gestiona codigo itinerario.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,69 @@
using RutasDBUS.Modelos.RedBus;
namespace RutasDBUS.Servicios.RedBus;
/// <summary>
/// Define las operaciones sobre la red de autobuses.
/// </summary>
public interface IRedBusServicio
{
/// <summary>Paradas cargadas en la red.</summary>
IReadOnlyList<Parada> Paradas { get; }
/// <summary>Trayectos cargados en la red.</summary>
IReadOnlyDictionary<int, Trayecto> Trayectos { get; }
/// <summary>Líneas de bus cargadas en la red.</summary>
IReadOnlyList<LineaBus> Lineas { get; }
/// <summary>
/// Carga la red (paradas, trayectos, líneas) desde el fichero XML.
/// </summary>
Task CargarRedAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Calcula la distancia aproximada en metros entre dos coordenadas.
/// </summary>
double CalcularDistanciaMetros(double lat1, double lon1, double lat2, double lon2);
/// <summary>
/// Devuelve la parada más cercana a las coordenadas indicadas.
/// </summary>
Parada? BuscarParadaMasCercana(double latitud, double longitud);
/// <summary>
/// Devuelve la parada más cercana dentro de un radio máximo. Si no hay ninguna dentro de ese radio, devuelve null.
/// </summary>
Parada? BuscarParadaCercanaEnRango(double latitud, double longitud, double maxDistMetros);
/// <summary>
/// Convierte un color ARGB (8 caracteres hex) al formato CSS "#RRGGBB".
/// </summary>
string ConvertirColorArgbACss(string argb);
/// <summary>
/// Busca una ruta de bus (una sola alternativa) entre dos paradas, devolviendo tramos y paradas de Transbordo.
/// </summary>
List<TramoBus>? BuscarRutaBus(
string codigoParadaOrigen,
string codigoParadaDestino,
out List<string> codigosParadasTransbordo);
/// <summary>
/// Construye las polilíneas de los tramos de bus y calcula la distancia total recorrida.
/// </summary>
List<double[,]> ConstruirLineasBus(
IReadOnlyList<TramoBus> tramos,
out double distanciaTotalBus);
/// <summary>
/// Busca varias rutas alternativas de bus entre dos paradas.
/// De momento, la implementación devuelve al menos la ruta "clásica" encontrada por <see cref="BuscarRutaBus"/>.
/// </summary>
IReadOnlyList<RutaBusAlternativa> BuscarRutasBusAlternativas(
string codigoParadaOrigen,
string codigoParadaDestino,
int maxAlternativas = 3);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
using RutasDBUS.Modelos.RedBus;
namespace RutasDBUS.Servicios.RedBus;
public sealed class RedTransporteDbus
{
public IReadOnlyList<Parada> Paradas { get; init; } = Array.Empty<Parada>();
public IReadOnlyDictionary<int, Trayecto> Trayectos { get; init; } = new Dictionary<int, Trayecto>();
public IReadOnlyList<LineaBus> Lineas { get; init; } = Array.Empty<LineaBus>();
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -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": "*"
}

2409
RutasDBUS/wwwroot/app.css Normal file

File diff suppressed because it is too large Load Diff

1140
RutasDBUS/wwwroot/app.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,38 @@
const TILE_CACHE = 'tile-cache-v1';
const TILE_PREFIX = 'https://osm.tecnosis.net/osm_tiles/';
self.addEventListener('install', event => {
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', event => {
const req = event.request;
const url = req.url;
// Sólo tiles de tu servidor OSM
if (!url.startsWith(TILE_PREFIX)) {
return;
}
event.respondWith(
caches.open(TILE_CACHE).then(async cache => {
const cached = await cache.match(req);
if (cached) {
// 💾 Tile ya guardado -> no hay descarga nueva
return cached;
}
const resp = await fetch(req);
if (resp && (resp.status === 200 || resp.type === 'opaque')) {
cache.put(req, resp.clone());
}
return resp;
})
);
});