Initial clean RutasDBUS app
This commit is contained in:
3
.github/copilot-instructions.md
vendored
Normal file
3
.github/copilot-instructions.md
vendored
Normal 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
402
.gitignore
vendored
Normal 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
7
NuGet.Config
Normal 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
34
RutasDBUS.sln
Normal 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
|
||||
22
RutasDBUS/Components/App.razor
Normal file
22
RutasDBUS/Components/App.razor
Normal 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>
|
||||
23
RutasDBUS/Components/Layout/MainLayout.razor
Normal file
23
RutasDBUS/Components/Layout/MainLayout.razor
Normal 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>
|
||||
96
RutasDBUS/Components/Layout/MainLayout.razor.css
Normal file
96
RutasDBUS/Components/Layout/MainLayout.razor.css
Normal 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;
|
||||
}
|
||||
118
RutasDBUS/Components/Layout/NavMenu.razor
Normal file
118
RutasDBUS/Components/Layout/NavMenu.razor
Normal 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> *@
|
||||
105
RutasDBUS/Components/Layout/NavMenu.razor.css
Normal file
105
RutasDBUS/Components/Layout/NavMenu.razor.css
Normal 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;
|
||||
}
|
||||
}
|
||||
36
RutasDBUS/Components/Pages/Error.razor
Normal file
36
RutasDBUS/Components/Pages/Error.razor
Normal 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;
|
||||
}
|
||||
496
RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs
Normal file
496
RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs
Normal 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]);
|
||||
}
|
||||
|
||||
}
|
||||
1633
RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs
Normal file
1633
RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs
Normal file
File diff suppressed because it is too large
Load Diff
299
RutasDBUS/Components/Pages/PlanificadorRutas.Geocodificacion.cs
Normal file
299
RutasDBUS/Components/Pages/PlanificadorRutas.Geocodificacion.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
593
RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs
Normal file
593
RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
1565
RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs
Normal file
1565
RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs
Normal file
File diff suppressed because it is too large
Load Diff
245
RutasDBUS/Components/Pages/PlanificadorRutas.ModelosHorarios.cs
Normal file
245
RutasDBUS/Components/Pages/PlanificadorRutas.ModelosHorarios.cs
Normal 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);
|
||||
}
|
||||
360
RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs
Normal file
360
RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
2285
RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs
Normal file
2285
RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs
Normal file
File diff suppressed because it is too large
Load Diff
844
RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs
Normal file
844
RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
470
RutasDBUS/Components/Pages/PlanificadorRutas.TiempoYHorarios.cs
Normal file
470
RutasDBUS/Components/Pages/PlanificadorRutas.TiempoYHorarios.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
1598
RutasDBUS/Components/Pages/PlanificadorRutas.razor
Normal file
1598
RutasDBUS/Components/Pages/PlanificadorRutas.razor
Normal file
File diff suppressed because it is too large
Load Diff
26
RutasDBUS/Components/Pages/PlanificadorRutas.razor.cs
Normal file
26
RutasDBUS/Components/Pages/PlanificadorRutas.razor.cs
Normal 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!;
|
||||
}
|
||||
6
RutasDBUS/Components/Routes.razor
Normal file
6
RutasDBUS/Components/Routes.razor
Normal 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>
|
||||
12
RutasDBUS/Components/_Imports.razor
Normal file
12
RutasDBUS/Components/_Imports.razor
Normal 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
|
||||
|
||||
BIN
RutasDBUS/Modelos/Callejero/GFA_DSET_AD_CSV.zip
Normal file
BIN
RutasDBUS/Modelos/Callejero/GFA_DSET_AD_CSV.zip
Normal file
Binary file not shown.
13
RutasDBUS/Modelos/Geocodificacion/LugarGeocodificado.cs
Normal file
13
RutasDBUS/Modelos/Geocodificacion/LugarGeocodificado.cs
Normal 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; }
|
||||
}
|
||||
25
RutasDBUS/Modelos/Historial/RegistroHistorialRuta.cs
Normal file
25
RutasDBUS/Modelos/Historial/RegistroHistorialRuta.cs
Normal 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;
|
||||
}
|
||||
BIN
RutasDBUS/Modelos/Horarios/zip_DBUS_OPENDATA_20260408~0940.zip
Normal file
BIN
RutasDBUS/Modelos/Horarios/zip_DBUS_OPENDATA_20260408~0940.zip
Normal file
Binary file not shown.
BIN
RutasDBUS/Modelos/Horarios/zip_DBUS_OPENDATA_20260514~0800.zip
Normal file
BIN
RutasDBUS/Modelos/Horarios/zip_DBUS_OPENDATA_20260514~0800.zip
Normal file
Binary file not shown.
BIN
RutasDBUS/Modelos/Horarios/zip_Importacion_20260213~0900.zip
Normal file
BIN
RutasDBUS/Modelos/Horarios/zip_Importacion_20260213~0900.zip
Normal file
Binary file not shown.
31
RutasDBUS/Modelos/Importacion/ArchivoImportacionDbus.cs
Normal file
31
RutasDBUS/Modelos/Importacion/ArchivoImportacionDbus.cs
Normal 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);
|
||||
}
|
||||
149
RutasDBUS/Modelos/Importacion/FilasCsvDbus.cs
Normal file
149
RutasDBUS/Modelos/Importacion/FilasCsvDbus.cs
Normal 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);
|
||||
10
RutasDBUS/Modelos/Importacion/ModeloHorarioDbusInfo.cs
Normal file
10
RutasDBUS/Modelos/Importacion/ModeloHorarioDbusInfo.cs
Normal 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);
|
||||
38
RutasDBUS/Modelos/Planificacion/AlternativaRuta.cs
Normal file
38
RutasDBUS/Modelos/Planificacion/AlternativaRuta.cs
Normal 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; }
|
||||
}
|
||||
20
RutasDBUS/Modelos/Planificacion/DetalleTramoRuta.cs
Normal file
20
RutasDBUS/Modelos/Planificacion/DetalleTramoRuta.cs
Normal 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; }
|
||||
}
|
||||
10
RutasDBUS/Modelos/Planificacion/MuestraElevacion.cs
Normal file
10
RutasDBUS/Modelos/Planificacion/MuestraElevacion.cs
Normal 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; }
|
||||
}
|
||||
74
RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs
Normal file
74
RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs
Normal 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;
|
||||
}
|
||||
67
RutasDBUS/Modelos/RedBus/InfoRutaBus.cs
Normal file
67
RutasDBUS/Modelos/RedBus/InfoRutaBus.cs
Normal 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;
|
||||
}
|
||||
|
||||
59
RutasDBUS/Modelos/RedBus/LineaBus.cs
Normal file
59
RutasDBUS/Modelos/RedBus/LineaBus.cs
Normal 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();
|
||||
}
|
||||
43
RutasDBUS/Modelos/RedBus/Parada.cs
Normal file
43
RutasDBUS/Modelos/RedBus/Parada.cs
Normal 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; } = "";
|
||||
}
|
||||
20
RutasDBUS/Modelos/RedBus/RutaBusAlternativa.cs
Normal file
20
RutasDBUS/Modelos/RedBus/RutaBusAlternativa.cs
Normal 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();
|
||||
}
|
||||
28
RutasDBUS/Modelos/RedBus/Trayecto.cs
Normal file
28
RutasDBUS/Modelos/RedBus/Trayecto.cs
Normal 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
79
RutasDBUS/Program.cs
Normal 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();
|
||||
|
||||
38
RutasDBUS/Properties/launchSettings.json
Normal file
38
RutasDBUS/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
RutasDBUS/RutasDBUS.csproj
Normal file
37
RutasDBUS/RutasDBUS.csproj
Normal 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>
|
||||
152
RutasDBUS/Servicios/Caminata/ClienteOsrm.cs
Normal file
152
RutasDBUS/Servicios/Caminata/ClienteOsrm.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
275
RutasDBUS/Servicios/Caminata/ClienteValhalla.cs
Normal file
275
RutasDBUS/Servicios/Caminata/ClienteValhalla.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
28
RutasDBUS/Servicios/Caminata/IClienteOsrm.cs
Normal file
28
RutasDBUS/Servicios/Caminata/IClienteOsrm.cs
Normal 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);
|
||||
}
|
||||
17
RutasDBUS/Servicios/Caminata/IClienteValhalla.cs
Normal file
17
RutasDBUS/Servicios/Caminata/IClienteValhalla.cs
Normal 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.
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
1792
RutasDBUS/Servicios/Horarios/ServicioHorariosDbus.cs
Normal file
1792
RutasDBUS/Servicios/Horarios/ServicioHorariosDbus.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
1313
RutasDBUS/Servicios/Planificacion/BuscadorRutasTemporalesDbus.cs
Normal file
1313
RutasDBUS/Servicios/Planificacion/BuscadorRutasTemporalesDbus.cs
Normal file
File diff suppressed because it is too large
Load Diff
407
RutasDBUS/Servicios/Planificacion/CreadorIndiceTransitoDbus.cs
Normal file
407
RutasDBUS/Servicios/Planificacion/CreadorIndiceTransitoDbus.cs
Normal 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));
|
||||
}
|
||||
14
RutasDBUS/Servicios/Planificacion/IPlanificadorRutas.cs
Normal file
14
RutasDBUS/Servicios/Planificacion/IPlanificadorRutas.cs
Normal 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);
|
||||
}
|
||||
200
RutasDBUS/Servicios/Planificacion/IndiceTransitoDbus.cs
Normal file
200
RutasDBUS/Servicios/Planificacion/IndiceTransitoDbus.cs
Normal 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; }
|
||||
}
|
||||
3422
RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs
Normal file
3422
RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
405
RutasDBUS/Servicios/Planificacion/UtilidadesTransitoDbus.cs
Normal file
405
RutasDBUS/Servicios/Planificacion/UtilidadesTransitoDbus.cs
Normal 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}"));
|
||||
}
|
||||
582
RutasDBUS/Servicios/RedBus/CreadorRedTransporteDbus.cs
Normal file
582
RutasDBUS/Servicios/RedBus/CreadorRedTransporteDbus.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
69
RutasDBUS/Servicios/RedBus/IRedBusServicio.cs
Normal file
69
RutasDBUS/Servicios/RedBus/IRedBusServicio.cs
Normal 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);
|
||||
|
||||
|
||||
}
|
||||
1805
RutasDBUS/Servicios/RedBus/RedBusServicio.cs
Normal file
1805
RutasDBUS/Servicios/RedBus/RedBusServicio.cs
Normal file
File diff suppressed because it is too large
Load Diff
10
RutasDBUS/Servicios/RedBus/RedTransporteDbus.cs
Normal file
10
RutasDBUS/Servicios/RedBus/RedTransporteDbus.cs
Normal 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>();
|
||||
}
|
||||
8
RutasDBUS/appsettings.Development.json
Normal file
8
RutasDBUS/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
58
RutasDBUS/appsettings.json
Normal file
58
RutasDBUS/appsettings.json
Normal 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
2409
RutasDBUS/wwwroot/app.css
Normal file
File diff suppressed because it is too large
Load Diff
1140
RutasDBUS/wwwroot/app.js
Normal file
1140
RutasDBUS/wwwroot/app.js
Normal file
File diff suppressed because it is too large
Load Diff
7
RutasDBUS/wwwroot/bootstrap/bootstrap.min.css
vendored
Normal file
7
RutasDBUS/wwwroot/bootstrap/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
RutasDBUS/wwwroot/bootstrap/bootstrap.min.css.map
Normal file
1
RutasDBUS/wwwroot/bootstrap/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
BIN
RutasDBUS/wwwroot/favicon.png
Normal file
BIN
RutasDBUS/wwwroot/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
38
RutasDBUS/wwwroot/service-worker.js
Normal file
38
RutasDBUS/wwwroot/service-worker.js
Normal 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;
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user