using NetTopologySuite.Geometries; using NetTopologySuite.LinearReferencing; using OperationControl.Mapas.Models; namespace OperationControl.Mapas.Services { public sealed class MapaEstimativaOperacaoService { /* * 21 pontos distribuídos na parte útil * da rua dão uma leitura boa do espaçamento, * sem transformar cada clique em um processamento GIS pesado. */ private const int QuantidadeAmostrasLargura = 21; /* * Cache válido enquanto o Documento oficial * continuar sendo a mesma instância. */ private MapaDocumento? _documentoCache; private readonly Dictionary< string, MapaEstimativaCorredor> _cacheCorredores = new( StringComparer.OrdinalIgnoreCase ); public MapaEstimativaOperacao Calcular( MapaDocumento? documento, MapaSelecaoOperacao? selecao, double? velocidadeSemErvasMs = null, double? velocidadeComErvasMs = null, double percentualErvas = 0) { int quantidadeSelecionadas = selecao?.Quantidade ?? 0; var resultado = new MapaEstimativaOperacao { QuantidadeRuasSelecionadas = quantidadeSelecionadas, PercentualErvasConsiderado = Math.Clamp( percentualErvas, 0, 100 ) }; if ( documento == null || selecao == null || quantidadeSelecionadas < 2 ) { resultado.MotivoIndisponivel = "Selecione ao menos duas ruas."; return resultado; } GarantirCacheDocumento( documento ); var ruasPorId = documento .Ruas .Where( x => x != null && !string.IsNullOrWhiteSpace( x.Id ) ) .ToDictionary( x => x.Id, x => x, StringComparer.OrdinalIgnoreCase ); /* * A ordem da seleção é a ordem operacional. * * Rua 0 + Rua 1 = corredor 0 * Rua 1 + Rua 2 = corredor 1 * etc. */ var ids = selecao .RuasOrdenadas .ToList(); for ( int i = 0; i < ids.Count - 1; i++) { string idA = ids[i]; string idB = ids[i + 1]; if ( !ruasPorId.TryGetValue( idA, out var ruaA ) || !ruasPorId.TryGetValue( idB, out var ruaB ) ) { throw new InvalidOperationException( $"Não foi possível montar " + $"o corredor {idA} -> {idB}." ); } var corredor = ObterCorredor( documento, ruaA, ruaB ); resultado .Corredores .Add( corredor ); } resultado.DistanciaMetros = resultado .Corredores .Sum( x => x.ComprimentoMetros ); resultado.AreaMetrosQuadrados = resultado .Corredores .Sum( x => x.AreaMetrosQuadrados ); resultado.LarguraMediaMetros = resultado.Corredores.Count > 0 ? resultado .Corredores .Average( x => x.LarguraMetros ) : 0; resultado.TempoEstimadoSegundos = CalcularTempoEstimado( resultado.DistanciaMetros, velocidadeSemErvasMs, velocidadeComErvasMs, resultado .PercentualErvasConsiderado ); return resultado; } private void GarantirCacheDocumento( MapaDocumento documento) { if ( ReferenceEquals( _documentoCache, documento ) ) { return; } _documentoCache = documento; _cacheCorredores.Clear(); } private MapaEstimativaCorredor ObterCorredor( MapaDocumento documento, MapaRua ruaA, MapaRua ruaB) { string chave = CriarChave( ruaA.Id, ruaB.Id ); if ( _cacheCorredores .TryGetValue( chave, out var existente ) ) { return existente; } var calculado = CalcularCorredor( ruaA, ruaB ); _cacheCorredores[ chave ] = calculado; return calculado; } private static string CriarChave( string idA, string idB) { return string.Compare( idA, idB, StringComparison.OrdinalIgnoreCase ) <= 0 ? $"{idA}\u001F{idB}" : $"{idB}\u001F{idA}"; } private static MapaEstimativaCorredor CalcularCorredor( MapaRua ruaA, MapaRua ruaB) { if ( ruaA?.Geometria == null || ruaB?.Geometria == null || ruaA.Geometria.IsEmpty || ruaB.Geometria.IsEmpty || ruaA.Geometria.NumPoints < 2 || ruaB.Geometria.NumPoints < 2 ) { throw new InvalidOperationException( "Uma das ruas possui geometria inválida." ); } var metricas = ProjetarParParaMetros( ruaA.Geometria, ruaB.Geometria ); double comprimentoA = ValorComprimentoValido( ruaA.ComprimentoMetros ) ? ruaA.ComprimentoMetros : metricas.A.Length; double comprimentoB = ValorComprimentoValido( ruaB.ComprimentoMetros ) ? ruaB.ComprimentoMetros : metricas.B.Length; /* * Regra operacional definida: * * corredor usa o comprimento da MAIOR rua, * garantindo que a estimativa cubra completamente * as duas laterais. */ double comprimentoCorredor = Math.Max( comprimentoA, comprimentoB ); double largura = CalcularLarguraMedia( metricas.A, metricas.B ); if ( !double.IsFinite(largura) || largura <= 0 ) { throw new InvalidOperationException( $"Não foi possível calcular a largura " + $"entre as ruas {ruaA.Id} e {ruaB.Id}." ); } return new MapaEstimativaCorredor { RuaAId = ruaA.Id, RuaBId = ruaB.Id, ComprimentoMetros = comprimentoCorredor, LarguraMetros = largura }; } private static bool ValorComprimentoValido( double valor) { return double.IsFinite(valor) && valor > 0; } /* * O documento permanece WGS84. * * Criamos uma projeção cartesiana LOCAL apenas * para medições métricas. * * Para a escala de um talhão agrícola, * a aproximação é mais do que suficiente. */ private static ( LineString A, LineString B) ProjetarParParaMetros( LineString linhaA, LineString linhaB) { var envelope = new Envelope( linhaA.EnvelopeInternal ); envelope.ExpandToInclude( linhaB.EnvelopeInternal ); double lonReferencia = ( envelope.MinX + envelope.MaxX ) / 2.0; double latReferencia = ( envelope.MinY + envelope.MaxY ) / 2.0; double latRad = latReferencia * Math.PI / 180.0; const double metrosPorGrauLat = 110540.0; double metrosPorGrauLon = 111320.0 * Math.Cos( latRad ); var factory = new GeometryFactory( new PrecisionModel(), 0 ); LineString Converter( LineString origem) { var coords = origem .Coordinates .Select( c => new Coordinate( ( c.X - lonReferencia ) * metrosPorGrauLon, ( c.Y - latReferencia ) * metrosPorGrauLat ) ) .ToArray(); return factory .CreateLineString( coords ); } return ( Converter(linhaA), Converter(linhaB) ); } private static double CalcularLarguraMedia( LineString linhaA, LineString linhaB) { var distancias = new List(); AdicionarAmostras( linhaA, linhaB, distancias ); /* * Fazemos também B -> A. * * Assim a largura não muda simplesmente * porque o operador selecionou as ruas * na ordem inversa. */ AdicionarAmostras( linhaB, linhaA, distancias ); distancias = distancias .Where( x => double.IsFinite(x) && x > 0 ) .OrderBy( x => x ) .ToList(); if (distancias.Count == 0) return 0; /* * Média aparada de 10%. * * Pontinhas divergentes de rua, erros locais * e finais desencontrados não devem distorcer * a largura inteira do corredor. */ int remover = distancias.Count >= 10 ? (int)Math.Floor( distancias.Count * 0.10 ) : 0; int quantidade = distancias.Count - remover * 2; if (quantidade <= 0) { return distancias.Average(); } return distancias .Skip(remover) .Take(quantidade) .Average(); } private static void AdicionarAmostras( LineString origem, LineString destino, List resultado) { if ( origem == null || destino == null || origem.Length <= 0 || destino.Length <= 0 ) { return; } var indexed = new LengthIndexedLine( origem ); double inicio = indexed.StartIndex; double fim = indexed.EndIndex; for ( int i = 0; i < QuantidadeAmostrasLargura; i++) { /* * 5% -> 95%. * * Evita que diferenças apenas nas pontas * sejam interpretadas como largura lateral. */ double t = 0.05 + ( 0.90 * i / ( QuantidadeAmostrasLargura - 1.0 ) ); double indice = inicio + ( fim - inicio ) * t; Coordinate coordenada = indexed.ExtractPoint( indice ); var ponto = origem.Factory .CreatePoint( coordenada ); double distancia = ponto.Distance( destino ); if ( double.IsFinite( distancia ) ) { resultado.Add( distancia ); } } } private static double? CalcularTempoEstimado( double distanciaMetros, double? velocidadeSemErvasMs, double? velocidadeComErvasMs, double percentualErvas) { if ( !double.IsFinite( distanciaMetros ) || distanciaMetros <= 0 ) { return null; } double fatorErvas = Math.Clamp( percentualErvas, 0, 100 ) / 100.0; double fatorSemErvas = 1.0 - fatorErvas; if ( fatorSemErvas > 0 && ( !velocidadeSemErvasMs.HasValue || !double.IsFinite( velocidadeSemErvasMs.Value ) || velocidadeSemErvasMs.Value <= 0 ) ) { return null; } if ( fatorErvas > 0 && ( !velocidadeComErvasMs.HasValue || !double.IsFinite( velocidadeComErvasMs.Value ) || velocidadeComErvasMs.Value <= 0 ) ) { return null; } double tempo = 0; if (fatorSemErvas > 0) { tempo += ( distanciaMetros * fatorSemErvas ) / velocidadeSemErvasMs!.Value; } if (fatorErvas > 0) { tempo += ( distanciaMetros * fatorErvas ) / velocidadeComErvasMs!.Value; } return double.IsFinite(tempo) ? tempo : null; } } }