Guilherme Silberfarb Costa commited on
Commit
3e958a5
·
1 Parent(s): d9d8daf

inclusao de coordenadas do avaliando e modificacao do mapa

Browse files
backend/app/api/pesquisa.py CHANGED
@@ -8,6 +8,7 @@ from app.services.pesquisa_service import (
8
  gerar_mapa_modelos,
9
  listar_modelos,
10
  obter_admin_config_pesquisa,
 
11
  salvar_admin_config_pesquisa,
12
  )
13
 
@@ -23,6 +24,17 @@ def _split_csv(value: str | None) -> list[str]:
23
 
24
  class MapaModelosPayload(BaseModel):
25
  modelos_ids: list[str]
 
 
 
 
 
 
 
 
 
 
 
26
 
27
 
28
  class PesquisaAdminConfigPayload(BaseModel):
@@ -42,6 +54,7 @@ def pesquisar_admin_config_salvar(payload: PesquisaAdminConfigPayload) -> dict:
42
  @router.get("/modelos")
43
  def pesquisar_modelos(
44
  somente_contexto: bool = Query(False),
 
45
  otica: str = Query("avaliando"),
46
  nome: str | None = Query(None),
47
  autor: str | None = Query(None),
@@ -83,6 +96,8 @@ def pesquisar_modelos(
83
  aval_valor_unitario_colunas: str | None = Query(None),
84
  aval_valor_total: float | None = Query(None),
85
  aval_valor_total_colunas: str | None = Query(None),
 
 
86
  limite: int = Query(300, ge=1, le=2000),
87
  ) -> dict:
88
  filtros = PesquisaFiltros(
@@ -127,10 +142,29 @@ def pesquisar_modelos(
127
  aval_valor_unitario_colunas=_split_csv(aval_valor_unitario_colunas),
128
  aval_valor_total=aval_valor_total,
129
  aval_valor_total_colunas=_split_csv(aval_valor_total_colunas),
 
 
 
130
  )
131
  return listar_modelos(filtros=filtros, limite=limite, somente_contexto=somente_contexto)
132
 
133
 
 
 
 
 
 
 
 
 
 
 
 
134
  @router.post("/mapa-modelos")
135
  def pesquisar_mapa_modelos(payload: MapaModelosPayload) -> dict:
136
- return gerar_mapa_modelos(payload.modelos_ids)
 
 
 
 
 
 
8
  gerar_mapa_modelos,
9
  listar_modelos,
10
  obter_admin_config_pesquisa,
11
+ resolver_localizacao_avaliando,
12
  salvar_admin_config_pesquisa,
13
  )
14
 
 
24
 
25
  class MapaModelosPayload(BaseModel):
26
  modelos_ids: list[str]
27
+ avaliando_lat: float | None = None
28
+ avaliando_lon: float | None = None
29
+ modo_exibicao: str | None = "pontos"
30
+
31
+
32
+ class LocalizacaoAvaliandoPayload(BaseModel):
33
+ latitude: float | None = None
34
+ longitude: float | None = None
35
+ logradouro: str | None = None
36
+ numero: float | None = None
37
+ cdlog: int | None = None
38
 
39
 
40
  class PesquisaAdminConfigPayload(BaseModel):
 
54
  @router.get("/modelos")
55
  def pesquisar_modelos(
56
  somente_contexto: bool = Query(False),
57
+ somente_versoes_atuais: bool = Query(True),
58
  otica: str = Query("avaliando"),
59
  nome: str | None = Query(None),
60
  autor: str | None = Query(None),
 
96
  aval_valor_unitario_colunas: str | None = Query(None),
97
  aval_valor_total: float | None = Query(None),
98
  aval_valor_total_colunas: str | None = Query(None),
99
+ aval_lat: float | None = Query(None),
100
+ aval_lon: float | None = Query(None),
101
  limite: int = Query(300, ge=1, le=2000),
102
  ) -> dict:
103
  filtros = PesquisaFiltros(
 
142
  aval_valor_unitario_colunas=_split_csv(aval_valor_unitario_colunas),
143
  aval_valor_total=aval_valor_total,
144
  aval_valor_total_colunas=_split_csv(aval_valor_total_colunas),
145
+ aval_lat=aval_lat,
146
+ aval_lon=aval_lon,
147
+ somente_versoes_atuais=somente_versoes_atuais,
148
  )
149
  return listar_modelos(filtros=filtros, limite=limite, somente_contexto=somente_contexto)
150
 
151
 
152
+ @router.post("/localizar-avaliando")
153
+ def pesquisar_localizar_avaliando(payload: LocalizacaoAvaliandoPayload) -> dict:
154
+ return resolver_localizacao_avaliando(
155
+ latitude=payload.latitude,
156
+ longitude=payload.longitude,
157
+ logradouro=payload.logradouro,
158
+ numero=payload.numero,
159
+ cdlog=payload.cdlog,
160
+ )
161
+
162
+
163
  @router.post("/mapa-modelos")
164
  def pesquisar_mapa_modelos(payload: MapaModelosPayload) -> dict:
165
+ return gerar_mapa_modelos(
166
+ payload.modelos_ids,
167
+ avaliando_lat=payload.avaliando_lat,
168
+ avaliando_lon=payload.avaliando_lon,
169
+ modo_exibicao=payload.modo_exibicao,
170
+ )
backend/app/services/pesquisa_service.py CHANGED
@@ -16,6 +16,7 @@ import pandas as pd
16
  from fastapi import HTTPException
17
  from joblib import load
18
 
 
19
  from app.core.elaboracao.core import _migrar_pacote_v1_para_v2, normalizar_observacao_modelo
20
  from app.core.map_layers import add_bairros_layer, add_indice_marker, add_zoom_responsive_circle_markers
21
  from app.services import model_repository
@@ -144,6 +145,9 @@ MAP_COLORS = [
144
  "#7f7f7f",
145
  ]
146
 
 
 
 
147
 
148
  @dataclass(frozen=True)
149
  class PesquisaFiltros:
@@ -188,6 +192,9 @@ class PesquisaFiltros:
188
  aval_valor_unitario_colunas: list[str] | None = None
189
  aval_valor_total: float | None = None
190
  aval_valor_total_colunas: list[str] | None = None
 
 
 
191
 
192
 
193
  _CACHE_LOCK = Lock()
@@ -195,6 +202,7 @@ _CACHE: dict[str, dict[str, Any]] = {}
195
  _ADMIN_CONFIG_LOCK = Lock()
196
  _CACHE_SOURCE_SIGNATURE: str | None = None
197
  _ADMIN_FONTES_SESSION: dict[str, list[str]] = {}
 
198
 
199
 
200
  def _resolver_repositorio_modelos() -> model_repository.ModelRepositoryResolution:
@@ -259,6 +267,8 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
259
  colunas_filtro = _montar_config_colunas_filtro(todos)
260
  admin_fontes = _carregar_fontes_admin(colunas_filtro)
261
  sugestoes = _extrair_sugestoes(todos, admin_fontes)
 
 
262
 
263
  if somente_contexto:
264
  return sanitize_value(
@@ -313,6 +323,9 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
313
  "aval_valor_unitario_colunas": filtros.aval_valor_unitario_colunas or [],
314
  "aval_valor_total": filtros.aval_valor_total,
315
  "aval_valor_total_colunas": filtros.aval_valor_total_colunas or [],
 
 
 
316
  },
317
  }
318
  )
@@ -323,6 +336,19 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
323
  filtrados = [_anexar_avaliando_info(item, filtros_exec, admin_fontes) for item in filtrados]
324
  filtrados = [item for item in filtrados if item.get("avaliando", {}).get("aceito")]
325
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  if limite and limite > 0:
327
  filtrados = filtrados[:limite]
328
 
@@ -380,12 +406,144 @@ def listar_modelos(filtros: PesquisaFiltros, limite: int | None = None, somente_
380
  "aval_valor_unitario_colunas": filtros.aval_valor_unitario_colunas or [],
381
  "aval_valor_total": filtros.aval_valor_total,
382
  "aval_valor_total_colunas": filtros.aval_valor_total_colunas or [],
 
 
 
383
  },
384
  }
385
  )
386
 
387
 
388
- def gerar_mapa_modelos(modelos_ids: list[str], limite_pontos_por_modelo: int = 0) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  ids = [str(item).strip() for item in (modelos_ids or []) if str(item).strip()]
390
  if not ids:
391
  raise HTTPException(status_code=400, detail="Selecione ao menos um modelo para gerar o mapa")
@@ -407,8 +565,13 @@ def gerar_mapa_modelos(modelos_ids: list[str], limite_pontos_por_modelo: int = 0
407
  if not selecionados:
408
  raise HTTPException(status_code=404, detail="Nenhum modelo selecionado foi encontrado na pasta de pesquisa")
409
 
 
 
410
  modelos_plotados: list[dict[str, Any]] = []
411
  bounds: list[list[float]] = []
 
 
 
412
 
413
  for idx, (modelo_id, caminho) in enumerate(selecionados):
414
  resumo = _carregar_resumo_com_cache(caminho)
@@ -420,8 +583,10 @@ def gerar_mapa_modelos(modelos_ids: list[str], limite_pontos_por_modelo: int = 0
420
  if not pontos:
421
  continue
422
 
 
423
  cor = MAP_COLORS[idx % len(MAP_COLORS)]
424
  nome = str(resumo.get("nome_modelo") or modelo_id)
 
425
 
426
  modelos_plotados.append(
427
  {
@@ -430,9 +595,13 @@ def gerar_mapa_modelos(modelos_ids: list[str], limite_pontos_por_modelo: int = 0
430
  "cor": cor,
431
  "total_pontos": len(pontos),
432
  "pontos": pontos,
 
 
 
433
  }
434
  )
435
  bounds.extend([[ponto["lat"], ponto["lon"]] for ponto in pontos])
 
436
 
437
  if not modelos_plotados:
438
  raise HTTPException(
@@ -440,6 +609,62 @@ def gerar_mapa_modelos(modelos_ids: list[str], limite_pontos_por_modelo: int = 0
440
  detail="Nao foi possivel gerar o mapa: os modelos selecionados nao possuem coordenadas validas",
441
  )
442
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  centro_lat = sum(coord[0] for coord in bounds) / len(bounds)
444
  centro_lon = sum(coord[1] for coord in bounds) / len(bounds)
445
 
@@ -453,38 +678,59 @@ def gerar_mapa_modelos(modelos_ids: list[str], limite_pontos_por_modelo: int = 0
453
  folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
454
  add_bairros_layer(mapa, show=True)
455
 
456
- total_pontos = 0
457
- mostrar_indices = sum(int(modelo["total_pontos"]) for modelo in modelos_plotados) <= 800
458
  camada_indices = folium.FeatureGroup(name="Índices", show=False) if mostrar_indices else None
459
- for modelo in modelos_plotados:
460
- total_pontos += int(modelo["total_pontos"])
461
- camada = folium.FeatureGroup(name=f'{modelo["nome"]} ({modelo["total_pontos"]})', show=True)
462
- for ponto in modelo["pontos"]:
463
- marcador = folium.CircleMarker(
464
- location=[ponto["lat"], ponto["lon"]],
465
- radius=3,
466
- color=modelo["cor"],
467
- fill=True,
468
- fill_color=modelo["cor"],
469
- fill_opacity=0.72,
470
- opacity=0.9,
471
- weight=1,
472
- tooltip=modelo["nome"],
473
- ).add_to(camada)
474
- marcador.options["mesaBaseRadius"] = 3.0
475
- if mostrar_indices and camada_indices is not None and ponto.get("indice") is not None:
476
- add_indice_marker(
477
- camada_indices,
478
- lat=float(ponto["lat"]),
479
- lon=float(ponto["lon"]),
480
- indice=ponto["indice"],
481
- )
482
- camada.add_to(mapa)
483
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  if mostrar_indices and camada_indices is not None:
485
  camada_indices.add_to(mapa)
 
 
 
 
 
 
 
 
 
 
 
486
 
487
- folium.LayerControl(collapsed=False).add_to(mapa)
488
  plugins.Fullscreen().add_to(mapa)
489
  add_zoom_responsive_circle_markers(mapa)
490
  if bounds:
@@ -502,23 +748,7 @@ def gerar_mapa_modelos(modelos_ids: list[str], limite_pontos_por_modelo: int = 0
502
  lon_max += lon_delta
503
  mapa.fit_bounds([[lat_min, lon_min], [lat_max, lon_max]], padding=(48, 48), max_zoom=18)
504
 
505
- return sanitize_value(
506
- {
507
- "mapa_html": mapa.get_root().render(),
508
- "total_modelos_plotados": len(modelos_plotados),
509
- "total_pontos": total_pontos,
510
- "modelos_plotados": [
511
- {
512
- "id": modelo["id"],
513
- "nome": modelo["nome"],
514
- "cor": modelo["cor"],
515
- "total_pontos": modelo["total_pontos"],
516
- }
517
- for modelo in modelos_plotados
518
- ],
519
- "status": f"Mapa gerado com {len(modelos_plotados)} modelo(s) e {total_pontos} ponto(s)",
520
- }
521
- )
522
 
523
 
524
  def _carregar_resumo_com_cache(caminho_modelo: Path) -> dict[str, Any]:
@@ -541,6 +771,25 @@ def _carregar_resumo_com_cache(caminho_modelo: Path) -> dict[str, Any]:
541
  return dict(resumo)
542
 
543
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544
  def _assinatura_arquivos(caminho_modelo: Path) -> tuple[int, int]:
545
  stat_modelo = caminho_modelo.stat()
546
  return (stat_modelo.st_mtime_ns, stat_modelo.st_size)
@@ -579,6 +828,7 @@ def _construir_resumo_modelo(caminho_modelo: Path) -> dict[str, Any]:
579
  "_texto_colunas_index": {},
580
  "_faixa_colunas_index": {},
581
  "_faixa_variaveis_index": {},
 
582
  }
583
 
584
  try:
@@ -870,6 +1120,37 @@ def _coletar_pontos_modelo(df_modelo: pd.DataFrame, limite_pontos: int) -> list[
870
  ]
871
 
872
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873
  def _formatar_indice_mapa(indice: Any) -> str:
874
  if isinstance(indice, (int, np.integer)):
875
  return str(int(indice))
@@ -886,6 +1167,290 @@ def _identificar_coluna_por_alias(colunas: Any, aliases: list[str]) -> str | Non
886
  return None
887
 
888
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
889
  def _extrair_bairros(df: pd.DataFrame) -> list[str]:
890
  candidatos = [col for col in df.columns if _has_alias(str(col), BAIRRO_ALIASES)]
891
  bairros: list[str] = []
@@ -1319,6 +1884,7 @@ def _extrair_sugestoes(
1319
  finalidades: list[str] = []
1320
  bairros: list[str] = []
1321
  enderecos: list[str] = []
 
1322
  tipos_modelo: list[str] = []
1323
  zonas_avaliacao: list[str] = []
1324
 
@@ -1341,12 +1907,21 @@ def _extrair_sugestoes(
1341
  tipos_modelo.append(str(_tipo_modelo_modelo(modelo) or ""))
1342
  zonas_avaliacao.extend(_zonas_avaliacao_modelo(modelo))
1343
 
 
 
 
 
 
 
 
 
1344
  return {
1345
  "nomes_modelo": _lista_textos_unicos(nomes, limite),
1346
  "autores": _lista_textos_unicos(autores, limite),
1347
  "finalidades": _lista_textos_unicos(finalidades, limite),
1348
  "bairros": _lista_textos_unicos(bairros, limite),
1349
  "enderecos": _lista_textos_unicos(enderecos, limite),
 
1350
  "tipos_modelo": _lista_textos_unicos(tipos_modelo, limite),
1351
  "zonas_avaliacao": _lista_zonas_unicas(zonas_avaliacao, limite),
1352
  }
@@ -1819,7 +2394,7 @@ def _indexar_faixas_variaveis(estat_df: pd.DataFrame | None, variaveis_modelo: l
1819
  return indice
1820
 
1821
 
1822
- def _lista_textos_unicos(valores: list[str], limite: int) -> list[str]:
1823
  unicos: list[str] = []
1824
  vistos = set()
1825
  for valor in valores:
@@ -1831,9 +2406,10 @@ def _lista_textos_unicos(valores: list[str], limite: int) -> list[str]:
1831
  continue
1832
  vistos.add(chave)
1833
  unicos.append(texto)
1834
- if len(unicos) >= limite:
1835
- break
1836
- return sorted(unicos, key=lambda item: item.lower())
 
1837
 
1838
 
1839
  def _aceita_valor_na_faixa(faixa: dict[str, Any] | None, valor: Any) -> bool:
 
16
  from fastapi import HTTPException
17
  from joblib import load
18
 
19
+ from app.core.elaboracao import geocodificacao
20
  from app.core.elaboracao.core import _migrar_pacote_v1_para_v2, normalizar_observacao_modelo
21
  from app.core.map_layers import add_bairros_layer, add_indice_marker, add_zoom_responsive_circle_markers
22
  from app.services import model_repository
 
145
  "#7f7f7f",
146
  ]
147
 
148
+ DISTANCIA_METRIC_CRS = "EPSG:31982"
149
+ VERSAO_MODELO_RE = re.compile(r"^(?P<base>.*?)(?P<sufixo>[A-Za-z])$")
150
+
151
 
152
  @dataclass(frozen=True)
153
  class PesquisaFiltros:
 
192
  aval_valor_unitario_colunas: list[str] | None = None
193
  aval_valor_total: float | None = None
194
  aval_valor_total_colunas: list[str] | None = None
195
+ aval_lat: float | None = None
196
+ aval_lon: float | None = None
197
+ somente_versoes_atuais: bool = True
198
 
199
 
200
  _CACHE_LOCK = Lock()
 
202
  _ADMIN_CONFIG_LOCK = Lock()
203
  _CACHE_SOURCE_SIGNATURE: str | None = None
204
  _ADMIN_FONTES_SESSION: dict[str, list[str]] = {}
205
+ _CATALOGO_VIAS_CACHE: list[dict[str, Any]] | None = None
206
 
207
 
208
  def _resolver_repositorio_modelos() -> model_repository.ModelRepositoryResolution:
 
267
  colunas_filtro = _montar_config_colunas_filtro(todos)
268
  admin_fontes = _carregar_fontes_admin(colunas_filtro)
269
  sugestoes = _extrair_sugestoes(todos, admin_fontes)
270
+ aval_lat, aval_lon = _normalizar_coordenadas_avaliando(filtros_exec.aval_lat, filtros_exec.aval_lon)
271
+ ids_versoes_antigas = _ids_versoes_antigas(todos) if filtros_exec.somente_versoes_atuais else set()
272
 
273
  if somente_contexto:
274
  return sanitize_value(
 
323
  "aval_valor_unitario_colunas": filtros.aval_valor_unitario_colunas or [],
324
  "aval_valor_total": filtros.aval_valor_total,
325
  "aval_valor_total_colunas": filtros.aval_valor_total_colunas or [],
326
+ "aval_lat": aval_lat,
327
+ "aval_lon": aval_lon,
328
+ "somente_versoes_atuais": bool(filtros_exec.somente_versoes_atuais),
329
  },
330
  }
331
  )
 
336
  filtrados = [_anexar_avaliando_info(item, filtros_exec, admin_fontes) for item in filtrados]
337
  filtrados = [item for item in filtrados if item.get("avaliando", {}).get("aceito")]
338
 
339
+ if ids_versoes_antigas:
340
+ filtrados = [item for item in filtrados if str(item.get("id") or "") not in ids_versoes_antigas]
341
+
342
+ if aval_lat is not None and aval_lon is not None:
343
+ filtrados = [_anexar_distancia_modelo(item, aval_lat, aval_lon) for item in filtrados]
344
+ filtrados.sort(
345
+ key=lambda item: (
346
+ item.get("distancia_km") is None,
347
+ float(item.get("distancia_km") or 0.0),
348
+ str(item.get("nome_modelo") or item.get("arquivo") or item.get("id") or "").lower(),
349
+ )
350
+ )
351
+
352
  if limite and limite > 0:
353
  filtrados = filtrados[:limite]
354
 
 
406
  "aval_valor_unitario_colunas": filtros.aval_valor_unitario_colunas or [],
407
  "aval_valor_total": filtros.aval_valor_total,
408
  "aval_valor_total_colunas": filtros.aval_valor_total_colunas or [],
409
+ "aval_lat": aval_lat,
410
+ "aval_lon": aval_lon,
411
+ "somente_versoes_atuais": bool(filtros_exec.somente_versoes_atuais),
412
  },
413
  }
414
  )
415
 
416
 
417
+ def resolver_localizacao_avaliando(
418
+ *,
419
+ latitude: float | None = None,
420
+ longitude: float | None = None,
421
+ logradouro: str | None = None,
422
+ numero: Any = None,
423
+ cdlog: Any = None,
424
+ ) -> dict[str, Any]:
425
+ lat, lon = _normalizar_coordenadas_avaliando(latitude, longitude)
426
+ if lat is not None and lon is not None:
427
+ return sanitize_value(
428
+ {
429
+ "lat": lat,
430
+ "lon": lon,
431
+ "origem": "coordenadas",
432
+ "status": f"Coordenadas do avaliando definidas em {lat:.6f}, {lon:.6f}.",
433
+ }
434
+ )
435
+
436
+ numero_int = _to_int_or_none(numero)
437
+ if numero_int is None or numero_int <= 0:
438
+ raise HTTPException(status_code=400, detail="Informe um numero valido para localizar o avaliando")
439
+
440
+ cdlog_int = _to_int_or_none(cdlog)
441
+ via_resolvida = None
442
+ if cdlog_int is None:
443
+ via_resolvida = _resolver_via_por_logradouro(logradouro)
444
+ cdlog_int = via_resolvida["cdlog"]
445
+ else:
446
+ via_resolvida = _resolver_via_por_cdlog(cdlog_int)
447
+
448
+ df_base = pd.DataFrame([{"CDLOG": cdlog_int, "NUMERO": numero_int}])
449
+ df_geo, df_falhas, ajustados = geocodificacao.geocodificar(df_base, "CDLOG", "NUMERO", auto_200=False)
450
+ lat = _to_float_or_none(df_geo.iloc[0].get("lat")) if not df_geo.empty else None
451
+ lon = _to_float_or_none(df_geo.iloc[0].get("lon")) if not df_geo.empty else None
452
+ lat, lon = _normalizar_coordenadas_avaliando(lat, lon)
453
+ if lat is None or lon is None:
454
+ sugestoes = ""
455
+ motivo = "Nao foi possivel localizar o endereco informado nos eixos."
456
+ if not df_falhas.empty:
457
+ linha_falha = df_falhas.iloc[0]
458
+ sugestoes = str(linha_falha.get("sugestoes") or "").strip()
459
+ motivo = str(linha_falha.get("motivo") or motivo).strip() or motivo
460
+ detalhe = motivo
461
+ if sugestoes:
462
+ detalhe = f"{motivo} Sugestoes de numeracao: {sugestoes}."
463
+ raise HTTPException(status_code=400, detail=detalhe)
464
+
465
+ numero_usado = numero_int
466
+ if ajustados:
467
+ try:
468
+ numero_usado = int(ajustados[0].get("numero_usado") or numero_int)
469
+ except Exception:
470
+ numero_usado = numero_int
471
+
472
+ logradouro_resolvido = str(via_resolvida.get("logradouro") or "").strip()
473
+ status = f"Endereco localizado em {logradouro_resolvido}, {numero_usado}."
474
+ if numero_usado != numero_int:
475
+ status += f" Numero interpolado mais proximo: {numero_usado}."
476
+
477
+ return sanitize_value(
478
+ {
479
+ "lat": lat,
480
+ "lon": lon,
481
+ "origem": "eixos",
482
+ "cdlog": cdlog_int,
483
+ "logradouro": logradouro_resolvido,
484
+ "numero_informado": numero_int,
485
+ "numero_usado": numero_usado,
486
+ "status": status,
487
+ }
488
+ )
489
+
490
+
491
+ def _ids_versoes_antigas(modelos: list[dict[str, Any]]) -> set[str]:
492
+ grupos: dict[str, list[tuple[str, str]]] = {}
493
+
494
+ for modelo in modelos:
495
+ versao_info = _extrair_info_versao_modelo(modelo)
496
+ if versao_info is None:
497
+ continue
498
+ base_norm, sufixo, modelo_id = versao_info
499
+ grupos.setdefault(base_norm, []).append((sufixo, modelo_id))
500
+
501
+ ids_antigos: set[str] = set()
502
+ for itens in grupos.values():
503
+ if len(itens) < 2:
504
+ continue
505
+ sufixos_distintos = {sufixo for sufixo, _modelo_id in itens}
506
+ if len(sufixos_distintos) < 2:
507
+ continue
508
+ ultimo_sufixo = max(sufixos_distintos)
509
+ for sufixo, modelo_id in itens:
510
+ if sufixo != ultimo_sufixo:
511
+ ids_antigos.add(modelo_id)
512
+
513
+ return ids_antigos
514
+
515
+
516
+ def _extrair_info_versao_modelo(modelo: dict[str, Any]) -> tuple[str, str, str] | None:
517
+ modelo_id = _str_or_none(modelo.get("id"))
518
+ if not modelo_id:
519
+ return None
520
+
521
+ candidatos = [
522
+ _texto_nome_modelo_sugestao(modelo.get("nome_modelo")),
523
+ _texto_nome_modelo_sugestao(modelo.get("arquivo")),
524
+ _texto_nome_modelo_sugestao(modelo.get("id")),
525
+ ]
526
+ for candidato in candidatos:
527
+ if not candidato:
528
+ continue
529
+ match = VERSAO_MODELO_RE.match(candidato)
530
+ if not match:
531
+ continue
532
+ base = _normalize(match.group("base"))
533
+ sufixo = str(match.group("sufixo") or "").upper()
534
+ if not base or not sufixo:
535
+ continue
536
+ return (base, sufixo, modelo_id)
537
+ return None
538
+
539
+
540
+ def gerar_mapa_modelos(
541
+ modelos_ids: list[str],
542
+ limite_pontos_por_modelo: int = 0,
543
+ avaliando_lat: float | None = None,
544
+ avaliando_lon: float | None = None,
545
+ modo_exibicao: str | None = "pontos",
546
+ ) -> dict[str, Any]:
547
  ids = [str(item).strip() for item in (modelos_ids or []) if str(item).strip()]
548
  if not ids:
549
  raise HTTPException(status_code=400, detail="Selecione ao menos um modelo para gerar o mapa")
 
565
  if not selecionados:
566
  raise HTTPException(status_code=404, detail="Nenhum modelo selecionado foi encontrado na pasta de pesquisa")
567
 
568
+ modo_exibicao_norm = _normalizar_modo_exibicao_mapa(modo_exibicao)
569
+
570
  modelos_plotados: list[dict[str, Any]] = []
571
  bounds: list[list[float]] = []
572
+ aval_lat, aval_lon = _normalizar_coordenadas_avaliando(avaliando_lat, avaliando_lon)
573
+ if aval_lat is not None and aval_lon is not None:
574
+ bounds.append([aval_lat, aval_lon])
575
 
576
  for idx, (modelo_id, caminho) in enumerate(selecionados):
577
  resumo = _carregar_resumo_com_cache(caminho)
 
583
  if not pontos:
584
  continue
585
 
586
+ geometria = _carregar_geometria_modelo_com_cache(caminho)
587
  cor = MAP_COLORS[idx % len(MAP_COLORS)]
588
  nome = str(resumo.get("nome_modelo") or modelo_id)
589
+ distancia_info = _calcular_distancia_geometria_cache(geometria, aval_lat, aval_lon)
590
 
591
  modelos_plotados.append(
592
  {
 
595
  "cor": cor,
596
  "total_pontos": len(pontos),
597
  "pontos": pontos,
598
+ "geometria": geometria,
599
+ "distancia_km": distancia_info.get("distancia_km"),
600
+ "distancia_label": distancia_info.get("distancia_label"),
601
  }
602
  )
603
  bounds.extend([[ponto["lat"], ponto["lon"]] for ponto in pontos])
604
+ bounds.extend(_extrair_bounds_geometria_wgs84(geometria))
605
 
606
  if not modelos_plotados:
607
  raise HTTPException(
 
609
  detail="Nao foi possivel gerar o mapa: os modelos selecionados nao possuem coordenadas validas",
610
  )
611
 
612
+ total_pontos = 0
613
+ for modelo in modelos_plotados:
614
+ total_pontos += int(modelo["total_pontos"])
615
+ mapas_html = {
616
+ "pontos": _renderizar_mapa_modelos(modelos_plotados, bounds, aval_lat, aval_lon, "pontos"),
617
+ "cobertura": _renderizar_mapa_modelos(modelos_plotados, bounds, aval_lat, aval_lon, "cobertura"),
618
+ }
619
+
620
+ return sanitize_value(
621
+ {
622
+ "mapa_html": mapas_html[modo_exibicao_norm],
623
+ "mapa_html_pontos": mapas_html["pontos"],
624
+ "mapa_html_cobertura": mapas_html["cobertura"],
625
+ "total_modelos_plotados": len(modelos_plotados),
626
+ "total_pontos": total_pontos,
627
+ "modelos_plotados": [
628
+ {
629
+ "id": modelo["id"],
630
+ "nome": modelo["nome"],
631
+ "cor": modelo["cor"],
632
+ "total_pontos": modelo["total_pontos"],
633
+ "distancia_km": modelo.get("distancia_km"),
634
+ "distancia_label": modelo.get("distancia_label"),
635
+ }
636
+ for modelo in modelos_plotados
637
+ ],
638
+ "avaliando": {
639
+ "lat": aval_lat,
640
+ "lon": aval_lon,
641
+ "ativo": bool(aval_lat is not None and aval_lon is not None),
642
+ },
643
+ "modo_exibicao": modo_exibicao_norm,
644
+ "status": (
645
+ f"Mapas de pontos e cobertura gerados com {len(modelos_plotados)} modelo(s) e {total_pontos} ponto(s)"
646
+ + (" com avaliando destacado." if aval_lat is not None and aval_lon is not None else ".")
647
+ ),
648
+ }
649
+ )
650
+
651
+
652
+ def _normalizar_modo_exibicao_mapa(value: Any) -> str:
653
+ modo = str(value or "").strip().lower()
654
+ if modo == "cobertura":
655
+ return "cobertura"
656
+ return "pontos"
657
+
658
+
659
+ def _renderizar_mapa_modelos(
660
+ modelos_plotados: list[dict[str, Any]],
661
+ bounds: list[list[float]],
662
+ aval_lat: float | None,
663
+ aval_lon: float | None,
664
+ modo_exibicao: str,
665
+ ) -> str:
666
+ renderizar_pontos = modo_exibicao == "pontos"
667
+ renderizar_cobertura = modo_exibicao == "cobertura"
668
  centro_lat = sum(coord[0] for coord in bounds) / len(bounds)
669
  centro_lon = sum(coord[1] for coord in bounds) / len(bounds)
670
 
 
678
  folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
679
  add_bairros_layer(mapa, show=True)
680
 
681
+ mostrar_indices = renderizar_pontos and sum(int(modelo["total_pontos"]) for modelo in modelos_plotados) <= 800
 
682
  camada_indices = folium.FeatureGroup(name="Índices", show=False) if mostrar_indices else None
683
+ camada_pontos = folium.FeatureGroup(name="Dados de mercado", show=True) if renderizar_pontos else None
684
+ camada_poligonos = folium.FeatureGroup(name="Cobertura dos modelos", show=True) if renderizar_cobertura else None
685
+ camada_avaliando = folium.FeatureGroup(name="Avaliando", show=True)
686
+ camada_distancias = folium.FeatureGroup(name="Distancias", show=False) if renderizar_cobertura else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
687
 
688
+ for modelo in modelos_plotados:
689
+ tooltip_modelo = modelo["nome"]
690
+ if modelo.get("distancia_label"):
691
+ tooltip_modelo = f'{tooltip_modelo} • Distancia: {modelo["distancia_label"]}'
692
+
693
+ if renderizar_pontos and camada_pontos is not None:
694
+ for ponto in modelo["pontos"]:
695
+ marcador = folium.CircleMarker(
696
+ location=[ponto["lat"], ponto["lon"]],
697
+ radius=3,
698
+ color=modelo["cor"],
699
+ fill=True,
700
+ fill_color=modelo["cor"],
701
+ fill_opacity=0.72,
702
+ opacity=0.9,
703
+ weight=1,
704
+ tooltip=tooltip_modelo,
705
+ ).add_to(camada_pontos)
706
+ marcador.options["mesaBaseRadius"] = 3.0
707
+ if mostrar_indices and camada_indices is not None and ponto.get("indice") is not None:
708
+ add_indice_marker(
709
+ camada_indices,
710
+ lat=float(ponto["lat"]),
711
+ lon=float(ponto["lon"]),
712
+ indice=ponto["indice"],
713
+ )
714
+
715
+ if renderizar_cobertura and camada_poligonos is not None:
716
+ _adicionar_geometria_modelo_no_mapa(camada_poligonos, camada_distancias, modelo, aval_lat, aval_lon)
717
+
718
+ if camada_pontos is not None:
719
+ camada_pontos.add_to(mapa)
720
  if mostrar_indices and camada_indices is not None:
721
  camada_indices.add_to(mapa)
722
+ if camada_poligonos is not None:
723
+ camada_poligonos.add_to(mapa)
724
+ if aval_lat is not None and aval_lon is not None:
725
+ folium.Marker(
726
+ location=[aval_lat, aval_lon],
727
+ tooltip="Avaliando",
728
+ icon=folium.Icon(color="red", icon="home", prefix="glyphicon"),
729
+ ).add_to(camada_avaliando)
730
+ camada_avaliando.add_to(mapa)
731
+ if camada_distancias is not None and any(modelo.get("distancia_km") not in (None, "") for modelo in modelos_plotados):
732
+ camada_distancias.add_to(mapa)
733
 
 
734
  plugins.Fullscreen().add_to(mapa)
735
  add_zoom_responsive_circle_markers(mapa)
736
  if bounds:
 
748
  lon_max += lon_delta
749
  mapa.fit_bounds([[lat_min, lon_min], [lat_max, lon_max]], padding=(48, 48), max_zoom=18)
750
 
751
+ return mapa.get_root().render()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
752
 
753
 
754
  def _carregar_resumo_com_cache(caminho_modelo: Path) -> dict[str, Any]:
 
771
  return dict(resumo)
772
 
773
 
774
+ def _carregar_geometria_modelo_com_cache(caminho_modelo: Path) -> dict[str, Any] | None:
775
+ assinatura = _assinatura_arquivos(caminho_modelo)
776
+ cache_key = str(caminho_modelo)
777
+
778
+ with _CACHE_LOCK:
779
+ cached = _CACHE.get(cache_key)
780
+ if cached and cached.get("assinatura") == assinatura and "geometria" in cached:
781
+ return cached.get("geometria")
782
+
783
+ geometria = _construir_geometria_modelo(caminho_modelo)
784
+
785
+ with _CACHE_LOCK:
786
+ entry = _CACHE.setdefault(cache_key, {})
787
+ entry["assinatura"] = assinatura
788
+ entry["geometria"] = geometria
789
+
790
+ return geometria
791
+
792
+
793
  def _assinatura_arquivos(caminho_modelo: Path) -> tuple[int, int]:
794
  stat_modelo = caminho_modelo.stat()
795
  return (stat_modelo.st_mtime_ns, stat_modelo.st_size)
 
828
  "_texto_colunas_index": {},
829
  "_faixa_colunas_index": {},
830
  "_faixa_variaveis_index": {},
831
+ "_caminho_modelo": str(caminho_modelo),
832
  }
833
 
834
  try:
 
1120
  ]
1121
 
1122
 
1123
+ def _construir_geometria_modelo(caminho_modelo: Path) -> dict[str, Any] | None:
1124
+ df_modelo = _carregar_dataframe_modelo(caminho_modelo)
1125
+ pontos = _coletar_pontos_modelo(df_modelo, 0)
1126
+ if not pontos:
1127
+ return None
1128
+
1129
+ try:
1130
+ from shapely.geometry import MultiPoint
1131
+ except ImportError:
1132
+ return None
1133
+
1134
+ coordenadas = [(float(item["lon"]), float(item["lat"])) for item in pontos]
1135
+ if not coordenadas:
1136
+ return None
1137
+
1138
+ try:
1139
+ hull = MultiPoint(coordenadas).convex_hull
1140
+ except Exception:
1141
+ return None
1142
+ if hull is None or getattr(hull, "is_empty", False):
1143
+ return None
1144
+
1145
+ geom_metric = _transformar_geometria_para_metrico(hull)
1146
+ return {
1147
+ "geom_wgs84": hull,
1148
+ "geom_metric": geom_metric,
1149
+ "geom_type": str(getattr(hull, "geom_type", "") or ""),
1150
+ "total_pontos": len(pontos),
1151
+ }
1152
+
1153
+
1154
  def _formatar_indice_mapa(indice: Any) -> str:
1155
  if isinstance(indice, (int, np.integer)):
1156
  return str(int(indice))
 
1167
  return None
1168
 
1169
 
1170
+ def _normalizar_coordenadas_avaliando(lat_raw: Any, lon_raw: Any) -> tuple[float | None, float | None]:
1171
+ lat = _to_float_or_none(lat_raw)
1172
+ lon = _to_float_or_none(lon_raw)
1173
+ if lat is None or lon is None:
1174
+ return None, None
1175
+ if not (-90.0 <= lat <= 90.0) or not (-180.0 <= lon <= 180.0):
1176
+ return None, None
1177
+ return float(lat), float(lon)
1178
+
1179
+
1180
+ def _anexar_distancia_modelo(modelo: dict[str, Any], aval_lat: float, aval_lon: float) -> dict[str, Any]:
1181
+ item = dict(modelo)
1182
+ caminho_texto = str(item.get("_caminho_modelo") or "").strip()
1183
+ if not caminho_texto:
1184
+ item["distancia_km"] = None
1185
+ item["distancia_label"] = "-"
1186
+ item["avaliando_dentro_cobertura"] = None
1187
+ return item
1188
+
1189
+ geometria = _carregar_geometria_modelo_com_cache(Path(caminho_texto))
1190
+ distancia = _calcular_distancia_geometria_cache(geometria, aval_lat, aval_lon)
1191
+ item["distancia_km"] = distancia.get("distancia_km")
1192
+ item["distancia_label"] = distancia.get("distancia_label")
1193
+ item["avaliando_dentro_cobertura"] = distancia.get("avaliando_dentro_cobertura")
1194
+ return item
1195
+
1196
+
1197
+ def _calcular_distancia_geometria_cache(
1198
+ geometria: dict[str, Any] | None,
1199
+ aval_lat: float | None,
1200
+ aval_lon: float | None,
1201
+ ) -> dict[str, Any]:
1202
+ if geometria is None or aval_lat is None or aval_lon is None:
1203
+ return {"distancia_km": None, "distancia_label": "-", "avaliando_dentro_cobertura": None}
1204
+
1205
+ geom_metric = geometria.get("geom_metric")
1206
+ if geom_metric is None:
1207
+ return {"distancia_km": None, "distancia_label": "-", "avaliando_dentro_cobertura": None}
1208
+
1209
+ ponto_metric = _criar_ponto_metrico(aval_lat, aval_lon)
1210
+ if ponto_metric is None:
1211
+ return {"distancia_km": None, "distancia_label": "-", "avaliando_dentro_cobertura": None}
1212
+
1213
+ try:
1214
+ distancia_m = float(ponto_metric.distance(geom_metric))
1215
+ except Exception:
1216
+ return {"distancia_km": None, "distancia_label": "-", "avaliando_dentro_cobertura": None}
1217
+
1218
+ if not np.isfinite(distancia_m):
1219
+ return {"distancia_km": None, "distancia_label": "-", "avaliando_dentro_cobertura": None}
1220
+
1221
+ distancia_km = max(0.0, distancia_m / 1000.0)
1222
+ return {
1223
+ "distancia_km": float(distancia_km),
1224
+ "distancia_label": _formatar_distancia_km(distancia_km),
1225
+ "avaliando_dentro_cobertura": bool(distancia_m <= 1e-6),
1226
+ }
1227
+
1228
+
1229
+ def _formatar_distancia_km(distancia_km: float | None) -> str:
1230
+ if distancia_km is None or not np.isfinite(distancia_km):
1231
+ return "-"
1232
+ return f"{float(distancia_km):,.2f} km".replace(",", "X").replace(".", ",").replace("X", ".")
1233
+
1234
+
1235
+ def _transformar_geometria_para_metrico(geom: Any) -> Any:
1236
+ if geom is None:
1237
+ return None
1238
+ try:
1239
+ from pyproj import Transformer
1240
+ from shapely.ops import transform
1241
+ except ImportError:
1242
+ return None
1243
+
1244
+ try:
1245
+ transformer = Transformer.from_crs("EPSG:4326", DISTANCIA_METRIC_CRS, always_xy=True)
1246
+ return transform(transformer.transform, geom)
1247
+ except Exception:
1248
+ return None
1249
+
1250
+
1251
+ def _criar_ponto_metrico(lat: float, lon: float) -> Any:
1252
+ try:
1253
+ from shapely.geometry import Point
1254
+ except ImportError:
1255
+ return None
1256
+ ponto = Point(float(lon), float(lat))
1257
+ return _transformar_geometria_para_metrico(ponto)
1258
+
1259
+
1260
+ def _adicionar_geometria_modelo_no_mapa(
1261
+ camada_poligonos: folium.FeatureGroup,
1262
+ camada_distancias: folium.FeatureGroup,
1263
+ modelo: dict[str, Any],
1264
+ aval_lat: float | None,
1265
+ aval_lon: float | None,
1266
+ ) -> None:
1267
+ geometria = modelo.get("geometria") or {}
1268
+ geom_wgs84 = geometria.get("geom_wgs84")
1269
+ if geom_wgs84 is None:
1270
+ return
1271
+
1272
+ tooltip = str(modelo.get("nome") or "").strip() or "Modelo"
1273
+ if modelo.get("distancia_label"):
1274
+ tooltip = f'{tooltip} • Distancia: {modelo["distancia_label"]}'
1275
+
1276
+ geom_type = str(geometria.get("geom_type") or "")
1277
+ cor = str(modelo.get("cor") or "#1f77b4")
1278
+
1279
+ try:
1280
+ if geom_type == "Polygon":
1281
+ coords = [[float(lat), float(lon)] for lon, lat in list(geom_wgs84.exterior.coords)]
1282
+ folium.Polygon(
1283
+ locations=coords,
1284
+ color=cor,
1285
+ fill=True,
1286
+ fill_color=cor,
1287
+ fill_opacity=0.12,
1288
+ weight=2,
1289
+ tooltip=tooltip,
1290
+ ).add_to(camada_poligonos)
1291
+ elif geom_type == "LineString":
1292
+ coords = [[float(lat), float(lon)] for lon, lat in list(geom_wgs84.coords)]
1293
+ folium.PolyLine(locations=coords, color=cor, weight=3, opacity=0.8, tooltip=tooltip).add_to(camada_poligonos)
1294
+ elif geom_type == "Point":
1295
+ folium.CircleMarker(
1296
+ location=[float(geom_wgs84.y), float(geom_wgs84.x)],
1297
+ radius=6,
1298
+ color=cor,
1299
+ fill=True,
1300
+ fill_color=cor,
1301
+ fill_opacity=0.7,
1302
+ tooltip=tooltip,
1303
+ ).add_to(camada_poligonos)
1304
+ except Exception:
1305
+ return
1306
+
1307
+ if aval_lat is None or aval_lon is None:
1308
+ return
1309
+
1310
+ try:
1311
+ from shapely.geometry import Point
1312
+ from shapely.ops import nearest_points
1313
+ except ImportError:
1314
+ return
1315
+
1316
+ try:
1317
+ aval_point = Point(float(aval_lon), float(aval_lat))
1318
+ _, nearest_geom = nearest_points(aval_point, geom_wgs84)
1319
+ distancia_km = _to_float_or_none(modelo.get("distancia_km"))
1320
+ if nearest_geom is None or distancia_km is None or distancia_km <= 0:
1321
+ return
1322
+ folium.PolyLine(
1323
+ locations=[
1324
+ [float(aval_point.y), float(aval_point.x)],
1325
+ [float(nearest_geom.y), float(nearest_geom.x)],
1326
+ ],
1327
+ color=cor,
1328
+ weight=2,
1329
+ opacity=0.65,
1330
+ dash_array="6,6",
1331
+ tooltip=f'Ligacao de distancia • {tooltip}',
1332
+ ).add_to(camada_distancias)
1333
+ except Exception:
1334
+ return
1335
+
1336
+
1337
+ def _extrair_bounds_geometria_wgs84(geometria: dict[str, Any] | None) -> list[list[float]]:
1338
+ if geometria is None or geometria.get("geom_wgs84") is None:
1339
+ return []
1340
+ try:
1341
+ min_x, min_y, max_x, max_y = geometria["geom_wgs84"].bounds
1342
+ except Exception:
1343
+ return []
1344
+ return [[float(min_y), float(min_x)], [float(max_y), float(max_x)]]
1345
+
1346
+
1347
+ def _resolver_via_por_cdlog(cdlog: int) -> dict[str, Any]:
1348
+ catalogo = _carregar_catalogo_vias()
1349
+ for item in catalogo:
1350
+ if int(item["cdlog"]) == int(cdlog):
1351
+ return item
1352
+ raise HTTPException(status_code=404, detail="CDLOG informado nao foi encontrado nos eixos")
1353
+
1354
+
1355
+ def _resolver_via_por_logradouro(logradouro: str | None) -> dict[str, Any]:
1356
+ texto = str(logradouro or "").strip()
1357
+ if not texto:
1358
+ raise HTTPException(status_code=400, detail="Informe o logradouro para localizar o avaliando")
1359
+
1360
+ consulta = _normalize(texto)
1361
+ catalogo = _carregar_catalogo_vias()
1362
+ melhores: list[tuple[int, dict[str, Any]]] = []
1363
+ for item in catalogo:
1364
+ score = _score_logradouro(consulta, item)
1365
+ if score <= 0:
1366
+ continue
1367
+ melhores.append((score, item))
1368
+
1369
+ if not melhores:
1370
+ raise HTTPException(status_code=404, detail="Nenhum logradouro compativel foi encontrado nos eixos")
1371
+
1372
+ melhores.sort(key=lambda entry: (-entry[0], str(entry[1].get("logradouro") or "").lower(), int(entry[1].get("cdlog") or 0)))
1373
+ melhor_score, melhor_item = melhores[0]
1374
+ if melhor_score < 60:
1375
+ sugestoes = ", ".join(str(item.get("logradouro") or "") for _, item in melhores[:5])
1376
+ detalhe = "Nao foi possivel identificar com seguranca o logradouro informado."
1377
+ if sugestoes:
1378
+ detalhe += f" Sugestoes: {sugestoes}."
1379
+ raise HTTPException(status_code=400, detail=detalhe)
1380
+ return melhor_item
1381
+
1382
+
1383
+ def _carregar_catalogo_vias() -> list[dict[str, Any]]:
1384
+ global _CATALOGO_VIAS_CACHE
1385
+ if _CATALOGO_VIAS_CACHE is not None:
1386
+ return list(_CATALOGO_VIAS_CACHE)
1387
+
1388
+ gdf = geocodificacao.carregar_eixos()
1389
+ if gdf is None or gdf.empty:
1390
+ raise HTTPException(status_code=500, detail="Base de eixos indisponivel para localizar o avaliando")
1391
+
1392
+ cols = {str(col).upper(): col for col in gdf.columns}
1393
+ cdlog_col = cols.get("CDLOG")
1394
+ nome_col = cols.get("NMIDELOG")
1395
+ prefixo_col = cols.get("NMIDEPRE")
1396
+ abreviado_col = cols.get("NMIDEABR")
1397
+ if cdlog_col is None or nome_col is None:
1398
+ raise HTTPException(status_code=500, detail="Base de eixos sem colunas necessarias para localizar o avaliando")
1399
+
1400
+ vistos: set[int] = set()
1401
+ catalogo: list[dict[str, Any]] = []
1402
+ for _, row in gdf.iterrows():
1403
+ cdlog = _to_int_or_none(row.get(cdlog_col))
1404
+ if cdlog is None or cdlog in vistos:
1405
+ continue
1406
+ vistos.add(cdlog)
1407
+ nome = str(row.get(nome_col) or "").strip()
1408
+ prefixo = str(row.get(prefixo_col) or "").strip() if prefixo_col else ""
1409
+ abreviado = str(row.get(abreviado_col) or "").strip() if abreviado_col else ""
1410
+ logradouro = " ".join(item for item in [prefixo, nome] if item).strip() or nome or abreviado
1411
+ aliases = _dedupe_strings([logradouro, nome, abreviado])
1412
+ catalogo.append(
1413
+ {
1414
+ "cdlog": cdlog,
1415
+ "logradouro": logradouro,
1416
+ "_aliases_norm": [_normalize(item) for item in aliases if _normalize(item)],
1417
+ }
1418
+ )
1419
+ _CATALOGO_VIAS_CACHE = list(catalogo)
1420
+ return list(catalogo)
1421
+
1422
+
1423
+ def _score_logradouro(consulta: str, item: dict[str, Any]) -> int:
1424
+ if not consulta:
1425
+ return 0
1426
+ aliases = [str(alias or "").strip() for alias in item.get("_aliases_norm") or [] if str(alias or "").strip()]
1427
+ if not aliases:
1428
+ return 0
1429
+ melhor = 0
1430
+ consulta_tokens = [tok for tok in consulta.split() if tok]
1431
+ for alias in aliases:
1432
+ if alias == consulta:
1433
+ melhor = max(melhor, 120)
1434
+ continue
1435
+ if alias.startswith(consulta):
1436
+ melhor = max(melhor, 100)
1437
+ continue
1438
+ if consulta in alias:
1439
+ melhor = max(melhor, 82)
1440
+ if consulta_tokens and all(tok in alias for tok in consulta_tokens):
1441
+ melhor = max(melhor, 72)
1442
+ return melhor
1443
+
1444
+
1445
+ def _to_int_or_none(value: Any) -> int | None:
1446
+ if _is_empty(value):
1447
+ return None
1448
+ try:
1449
+ return int(float(str(value).strip()))
1450
+ except Exception:
1451
+ return None
1452
+
1453
+
1454
  def _extrair_bairros(df: pd.DataFrame) -> list[str]:
1455
  candidatos = [col for col in df.columns if _has_alias(str(col), BAIRRO_ALIASES)]
1456
  bairros: list[str] = []
 
1884
  finalidades: list[str] = []
1885
  bairros: list[str] = []
1886
  enderecos: list[str] = []
1887
+ logradouros_eixos: list[str] = []
1888
  tipos_modelo: list[str] = []
1889
  zonas_avaliacao: list[str] = []
1890
 
 
1907
  tipos_modelo.append(str(_tipo_modelo_modelo(modelo) or ""))
1908
  zonas_avaliacao.extend(_zonas_avaliacao_modelo(modelo))
1909
 
1910
+ try:
1911
+ logradouros_eixos = [
1912
+ str(item.get("logradouro") or "").strip()
1913
+ for item in _carregar_catalogo_vias()
1914
+ ]
1915
+ except HTTPException:
1916
+ logradouros_eixos = []
1917
+
1918
  return {
1919
  "nomes_modelo": _lista_textos_unicos(nomes, limite),
1920
  "autores": _lista_textos_unicos(autores, limite),
1921
  "finalidades": _lista_textos_unicos(finalidades, limite),
1922
  "bairros": _lista_textos_unicos(bairros, limite),
1923
  "enderecos": _lista_textos_unicos(enderecos, limite),
1924
+ "logradouros_eixos": _lista_textos_unicos(logradouros_eixos, None),
1925
  "tipos_modelo": _lista_textos_unicos(tipos_modelo, limite),
1926
  "zonas_avaliacao": _lista_zonas_unicas(zonas_avaliacao, limite),
1927
  }
 
2394
  return indice
2395
 
2396
 
2397
+ def _lista_textos_unicos(valores: list[str], limite: int | None) -> list[str]:
2398
  unicos: list[str] = []
2399
  vistos = set()
2400
  for valor in valores:
 
2406
  continue
2407
  vistos.add(chave)
2408
  unicos.append(texto)
2409
+ ordenados = sorted(unicos, key=lambda item: item.lower())
2410
+ if limite is None or limite <= 0:
2411
+ return ordenados
2412
+ return ordenados[:limite]
2413
 
2414
 
2415
  def _aceita_valor_na_faixa(faixa: dict[str, Any] | None, valor: Any) -> bool:
frontend/src/api.js CHANGED
@@ -174,8 +174,16 @@ export const api = {
174
  const query = params.toString()
175
  return getJson(query ? `/api/pesquisa/modelos?${query}` : '/api/pesquisa/modelos')
176
  },
177
- pesquisarMapaModelos(modelosIds = []) {
178
- return postJson('/api/pesquisa/mapa-modelos', { modelos_ids: modelosIds })
 
 
 
 
 
 
 
 
179
  },
180
 
181
  uploadElaboracaoFile(sessionId, file) {
 
174
  const query = params.toString()
175
  return getJson(query ? `/api/pesquisa/modelos?${query}` : '/api/pesquisa/modelos')
176
  },
177
+ pesquisarLocalizacaoAvaliando(payload = {}) {
178
+ return postJson('/api/pesquisa/localizar-avaliando', payload)
179
+ },
180
+ pesquisarMapaModelos(modelosIds = [], avaliando = null, modoExibicao = 'pontos') {
181
+ return postJson('/api/pesquisa/mapa-modelos', {
182
+ modelos_ids: modelosIds,
183
+ avaliando_lat: avaliando?.lat ?? null,
184
+ avaliando_lon: avaliando?.lon ?? null,
185
+ modo_exibicao: modoExibicao,
186
+ })
187
  },
188
 
189
  uploadElaboracaoFile(sessionId, file) {
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -8,6 +8,7 @@ import MapFrame from './MapFrame'
8
  import PlotFigure from './PlotFigure'
9
  import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
10
  import SectionBlock from './SectionBlock'
 
11
  import { getFaixaDataRecencyInfo } from '../modelRecency'
12
 
13
  const EMPTY_FILTERS = {
@@ -16,6 +17,7 @@ const EMPTY_FILTERS = {
16
  negociacaoModelo: '',
17
  dataMin: '',
18
  dataMax: '',
 
19
  avalFinalidade: '',
20
  avalZona: '',
21
  avalBairro: '',
@@ -23,6 +25,14 @@ const EMPTY_FILTERS = {
23
  avalRh: '',
24
  }
25
 
 
 
 
 
 
 
 
 
26
  const RESULT_INITIAL = {
27
  modelos: [],
28
  sugestoes: {},
@@ -79,6 +89,12 @@ function formatCount(value) {
79
  return String(value)
80
  }
81
 
 
 
 
 
 
 
82
  function formatDateBrIfIso(value) {
83
  const text = String(value ?? '').trim()
84
  if (!text) return '-'
@@ -469,8 +485,8 @@ function formatTipoImovel(modelo) {
469
  return mapped || text
470
  }
471
 
472
- function buildApiFilters(filters) {
473
- return {
474
  otica: 'avaliando',
475
  nome: filters.nomeModelo,
476
  tipo_modelo: filters.tipoModelo,
@@ -480,9 +496,17 @@ function buildApiFilters(filters) {
480
  aval_bairro: filters.avalBairro,
481
  data_min: filters.dataMin,
482
  data_max: filters.dataMax,
 
483
  aval_area: filters.avalArea,
484
  aval_rh: filters.avalRh,
485
  }
 
 
 
 
 
 
 
486
  }
487
 
488
  function toInputName(field) {
@@ -788,6 +812,12 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
788
 
789
  const [filters, setFilters] = useState(EMPTY_FILTERS)
790
  const [result, setResult] = useState(RESULT_INITIAL)
 
 
 
 
 
 
791
 
792
  const [selectedIds, setSelectedIds] = useState([])
793
  const selectAllRef = useRef(null)
@@ -795,8 +825,10 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
795
  const [mapaLoading, setMapaLoading] = useState(false)
796
  const [mapaError, setMapaError] = useState('')
797
  const [mapaStatus, setMapaStatus] = useState('')
798
- const [mapaHtml, setMapaHtml] = useState('')
799
  const [mapaLegendas, setMapaLegendas] = useState([])
 
 
800
 
801
  const [modeloAbertoMeta, setModeloAbertoMeta] = useState(null)
802
  const [modeloAbertoLoading, setModeloAbertoLoading] = useState(false)
@@ -830,6 +862,8 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
830
  const modoModeloAberto = Boolean(modeloAbertoMeta)
831
  const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
832
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
 
 
833
 
834
  function scrollToElementTop(el, behavior = 'smooth', offsetPx = 0) {
835
  if (!el || typeof window === 'undefined') return
@@ -856,11 +890,51 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
856
  })
857
  }
858
 
859
- async function buscarModelos(nextFilters = filters) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
860
  setLoading(true)
861
  setError('')
862
  try {
863
- const response = await api.pesquisarModelos(buildApiFilters(nextFilters))
864
  const modelos = response.modelos || []
865
  const idsNovos = new Set(modelos.map((item) => item.id))
866
 
@@ -873,10 +947,7 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
873
 
874
  setSelectedIds((current) => current.filter((id) => idsNovos.has(id)))
875
 
876
- setMapaHtml('')
877
- setMapaStatus('')
878
- setMapaLegendas([])
879
- setMapaError('')
880
  setPesquisaInicializada(true)
881
  setSugestoesInicializadas(true)
882
  } catch (err) {
@@ -899,10 +970,7 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
899
  sugestoes: response.sugestoes || {},
900
  })
901
  setSelectedIds([])
902
- setMapaHtml('')
903
- setMapaStatus('')
904
- setMapaLegendas([])
905
- setMapaError('')
906
  setPesquisaInicializada(false)
907
  setSugestoesInicializadas(true)
908
  } catch (err) {
@@ -923,14 +991,36 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
923
  }, [algunsSelecionados, todosSelecionados])
924
 
925
  function onFieldChange(event) {
 
 
 
 
 
 
 
926
  const { value, dataset, name } = event.target
927
  const field = dataset.field || name
928
  if (!field) return
929
- setFilters((prev) => ({ ...prev, [field]: value }))
 
 
 
 
 
 
 
 
 
 
 
930
  }
931
 
932
  async function onLimparFiltros() {
933
  setFilters(EMPTY_FILTERS)
 
 
 
 
934
  await carregarContextoInicial()
935
  }
936
 
@@ -1049,26 +1139,93 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
1049
  return
1050
  }
1051
 
1052
- setMapaLoading(true)
1053
- setMapaError('')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1054
  try {
1055
- const response = await api.pesquisarMapaModelos(selectedIds)
1056
- setMapaHtml(response.mapa_html || '')
1057
- setMapaStatus(response.status || '')
1058
- setMapaLegendas(response.modelos_plotados || [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1059
  } catch (err) {
1060
- setMapaError(err.message)
 
1061
  } finally {
1062
- setMapaLoading(false)
1063
  }
1064
  }
1065
 
1066
- async function onAdminConfigSalva() {
 
 
 
 
 
1067
  if (pesquisaInicializada) {
1068
- await buscarModelos()
1069
- return
1070
  }
1071
- await carregarContextoInicial()
1072
  }
1073
 
1074
  if (modoModeloAberto) {
@@ -1285,18 +1442,177 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
1285
  </div>
1286
  </div>
1287
 
1288
- <div className="pesquisa-field-pair pesquisa-avaliando-periodo-pair">
1289
- <span className="pesquisa-field-pair-title">Período mínimo desejado</span>
1290
- <label className="pesquisa-field">
1291
- Data inicial
1292
- <DateFieldInput field="dataMin" value={filters.dataMin} onChange={onFieldChange} />
1293
- </label>
1294
- <label className="pesquisa-field">
1295
- Data final
1296
- <DateFieldInput field="dataMax" value={filters.dataMax} onChange={onFieldChange} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1297
  </label>
1298
  </div>
1299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1300
  <div className="row pesquisa-actions pesquisa-actions-primary">
1301
  <button type="button" onClick={() => void buscarModelos()} disabled={loading}>
1302
  {loading ? 'Pesquisando...' : 'Pesquisar'}
@@ -1370,6 +1686,7 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
1370
  </div>
1371
  <div className="pesquisa-card-body">
1372
  <div className="pesquisa-card-dados-list">
 
1373
  <div><strong>Tipo:</strong> {formatTipoImovel(modelo)}</div>
1374
  <div><strong>Autor:</strong> {modelo.autor || '-'}</div>
1375
  <div><strong>Dados:</strong> {formatCount(modelo.total_dados)}</div>
@@ -1424,12 +1741,21 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
1424
  </SectionBlock>
1425
  </div>
1426
 
1427
- <SectionBlock step="3" title="Mapa" subtitle="Plote os modelos selecionados com cores distintas e legenda.">
 
 
 
 
 
 
1428
  <div className="pesquisa-summary-line">
1429
  <strong>{formatCount(selectedIds.length)}</strong> modelo(s) selecionado(s) para plotagem.
1430
  </div>
 
 
 
1431
 
1432
- <div className="row pesquisa-actions">
1433
  <button type="button" onClick={() => void onGerarMapaSelecionados()} disabled={mapaLoading || !selectedIds.length}>
1434
  {mapaLoading ? 'Gerando mapa...' : 'Plotar mapa dos selecionados'}
1435
  </button>
@@ -1438,18 +1764,46 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
1438
  {mapaStatus ? <div className="status-line">{mapaStatus}</div> : null}
1439
  {mapaError ? <div className="error-line inline-error">{mapaError}</div> : null}
1440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1441
  {mapaLegendas.length ? (
1442
  <div className="pesquisa-legenda-grid">
1443
  {mapaLegendas.map((item) => (
1444
- <div key={item.id} className="pesquisa-legenda-item">
 
 
 
 
 
 
 
 
 
1445
  <span className="pesquisa-legenda-color" style={{ backgroundColor: item.cor }} />
1446
- <span>{item.nome} ({item.total_pontos})</span>
1447
- </div>
 
 
 
1448
  ))}
1449
  </div>
1450
  ) : null}
1451
 
1452
- {mapaHtml ? <MapFrame html={mapaHtml} /> : <div className="empty-box">Nenhum mapa gerado ainda.</div>}
1453
  </SectionBlock>
1454
 
1455
  <LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
 
8
  import PlotFigure from './PlotFigure'
9
  import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
10
  import SectionBlock from './SectionBlock'
11
+ import SinglePillAutocomplete from './SinglePillAutocomplete'
12
  import { getFaixaDataRecencyInfo } from '../modelRecency'
13
 
14
  const EMPTY_FILTERS = {
 
17
  negociacaoModelo: '',
18
  dataMin: '',
19
  dataMax: '',
20
+ versionamentoModelos: 'atuais',
21
  avalFinalidade: '',
22
  avalZona: '',
23
  avalBairro: '',
 
25
  avalRh: '',
26
  }
27
 
28
+ const EMPTY_LOCATION_INPUTS = {
29
+ latitude: '',
30
+ longitude: '',
31
+ logradouro: '',
32
+ numero: '',
33
+ cdlog: '',
34
+ }
35
+
36
  const RESULT_INITIAL = {
37
  modelos: [],
38
  sugestoes: {},
 
89
  return String(value)
90
  }
91
 
92
+ function formatDistanceKm(value) {
93
+ const number = Number(value)
94
+ if (!Number.isFinite(number)) return '-'
95
+ return `${number.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} km`
96
+ }
97
+
98
  function formatDateBrIfIso(value) {
99
  const text = String(value ?? '').trim()
100
  if (!text) return '-'
 
485
  return mapped || text
486
  }
487
 
488
+ function buildApiFilters(filters, localizacaoResolvida = null) {
489
+ const payload = {
490
  otica: 'avaliando',
491
  nome: filters.nomeModelo,
492
  tipo_modelo: filters.tipoModelo,
 
496
  aval_bairro: filters.avalBairro,
497
  data_min: filters.dataMin,
498
  data_max: filters.dataMax,
499
+ somente_versoes_atuais: filters.versionamentoModelos !== 'incluir_antigos',
500
  aval_area: filters.avalArea,
501
  aval_rh: filters.avalRh,
502
  }
503
+ const lat = Number(localizacaoResolvida?.lat)
504
+ const lon = Number(localizacaoResolvida?.lon)
505
+ if (Number.isFinite(lat) && Number.isFinite(lon)) {
506
+ payload.aval_lat = lat
507
+ payload.aval_lon = lon
508
+ }
509
+ return payload
510
  }
511
 
512
  function toInputName(field) {
 
812
 
813
  const [filters, setFilters] = useState(EMPTY_FILTERS)
814
  const [result, setResult] = useState(RESULT_INITIAL)
815
+ const [localizacaoModo, setLocalizacaoModo] = useState('endereco')
816
+ const [localizacaoInputs, setLocalizacaoInputs] = useState(EMPTY_LOCATION_INPUTS)
817
+ const [localizacaoResolvida, setLocalizacaoResolvida] = useState(null)
818
+ const [localizacaoLoading, setLocalizacaoLoading] = useState(false)
819
+ const [localizacaoError, setLocalizacaoError] = useState('')
820
+ const [localizacaoStatus, setLocalizacaoStatus] = useState('')
821
 
822
  const [selectedIds, setSelectedIds] = useState([])
823
  const selectAllRef = useRef(null)
 
825
  const [mapaLoading, setMapaLoading] = useState(false)
826
  const [mapaError, setMapaError] = useState('')
827
  const [mapaStatus, setMapaStatus] = useState('')
828
+ const [mapaHtmls, setMapaHtmls] = useState({ pontos: '', cobertura: '' })
829
  const [mapaLegendas, setMapaLegendas] = useState([])
830
+ const [mapaModoExibicao, setMapaModoExibicao] = useState('pontos')
831
+ const [mapaIdsVisiveis, setMapaIdsVisiveis] = useState([])
832
 
833
  const [modeloAbertoMeta, setModeloAbertoMeta] = useState(null)
834
  const [modeloAbertoLoading, setModeloAbertoLoading] = useState(false)
 
862
  const modoModeloAberto = Boolean(modeloAbertoMeta)
863
  const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
864
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
865
+ const localizacaoAtiva = Number.isFinite(Number(localizacaoResolvida?.lat)) && Number.isFinite(Number(localizacaoResolvida?.lon))
866
+ const mapaHtmlAtual = mapaHtmls[mapaModoExibicao] || ''
867
 
868
  function scrollToElementTop(el, behavior = 'smooth', offsetPx = 0) {
869
  if (!el || typeof window === 'undefined') return
 
890
  })
891
  }
892
 
893
+ function resetMapaPesquisa() {
894
+ setMapaHtmls({ pontos: '', cobertura: '' })
895
+ setMapaStatus('')
896
+ setMapaLegendas([])
897
+ setMapaError('')
898
+ setMapaModoExibicao('pontos')
899
+ setMapaIdsVisiveis([])
900
+ }
901
+
902
+ async function carregarMapaPesquisa(ids, { atualizarLegendas = false } = {}) {
903
+ const idsValidos = (ids || []).map((item) => String(item || '').trim()).filter(Boolean)
904
+
905
+ if (!idsValidos.length) {
906
+ setMapaHtmls({ pontos: '', cobertura: '' })
907
+ setMapaStatus('Nenhum modelo marcado para exibição no mapa.')
908
+ setMapaError('')
909
+ setMapaIdsVisiveis([])
910
+ return
911
+ }
912
+
913
+ setMapaLoading(true)
914
+ setMapaError('')
915
+ try {
916
+ const response = await api.pesquisarMapaModelos(idsValidos, localizacaoResolvida)
917
+ setMapaHtmls({
918
+ pontos: response.mapa_html_pontos || '',
919
+ cobertura: response.mapa_html_cobertura || '',
920
+ })
921
+ setMapaStatus(response.status || '')
922
+ setMapaIdsVisiveis(idsValidos)
923
+ if (atualizarLegendas) {
924
+ setMapaLegendas(response.modelos_plotados || [])
925
+ }
926
+ } catch (err) {
927
+ setMapaError(err.message)
928
+ } finally {
929
+ setMapaLoading(false)
930
+ }
931
+ }
932
+
933
+ async function buscarModelos(nextFilters = filters, nextLocalizacao = localizacaoResolvida) {
934
  setLoading(true)
935
  setError('')
936
  try {
937
+ const response = await api.pesquisarModelos(buildApiFilters(nextFilters, nextLocalizacao))
938
  const modelos = response.modelos || []
939
  const idsNovos = new Set(modelos.map((item) => item.id))
940
 
 
947
 
948
  setSelectedIds((current) => current.filter((id) => idsNovos.has(id)))
949
 
950
+ resetMapaPesquisa()
 
 
 
951
  setPesquisaInicializada(true)
952
  setSugestoesInicializadas(true)
953
  } catch (err) {
 
970
  sugestoes: response.sugestoes || {},
971
  })
972
  setSelectedIds([])
973
+ resetMapaPesquisa()
 
 
 
974
  setPesquisaInicializada(false)
975
  setSugestoesInicializadas(true)
976
  } catch (err) {
 
991
  }, [algunsSelecionados, todosSelecionados])
992
 
993
  function onFieldChange(event) {
994
+ const { value, checked, type, dataset, name } = event.target
995
+ const field = dataset.field || name
996
+ if (!field) return
997
+ setFilters((prev) => ({ ...prev, [field]: type === 'checkbox' ? checked : value }))
998
+ }
999
+
1000
+ function onLocalizacaoFieldChange(event) {
1001
  const { value, dataset, name } = event.target
1002
  const field = dataset.field || name
1003
  if (!field) return
1004
+ atualizarCampoLocalizacao(field, value)
1005
+ }
1006
+
1007
+ function atualizarCampoLocalizacao(field, value) {
1008
+ if (!field) return
1009
+ setLocalizacaoInputs((prev) => ({ ...prev, [field]: value }))
1010
+ if (localizacaoResolvida) {
1011
+ setLocalizacaoResolvida(null)
1012
+ }
1013
+ setLocalizacaoError('')
1014
+ setLocalizacaoStatus('')
1015
+ resetMapaPesquisa()
1016
  }
1017
 
1018
  async function onLimparFiltros() {
1019
  setFilters(EMPTY_FILTERS)
1020
+ setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
1021
+ setLocalizacaoResolvida(null)
1022
+ setLocalizacaoError('')
1023
+ setLocalizacaoStatus('')
1024
  await carregarContextoInicial()
1025
  }
1026
 
 
1139
  return
1140
  }
1141
 
1142
+ setMapaModoExibicao('pontos')
1143
+ await carregarMapaPesquisa(selectedIds, { atualizarLegendas: true })
1144
+ }
1145
+
1146
+ async function onToggleLegendaMapa(modeloId) {
1147
+ const modeloIdNorm = String(modeloId || '').trim()
1148
+ if (!modeloIdNorm || mapaLoading) return
1149
+
1150
+ const ativosAtuais = mapaIdsVisiveis.length
1151
+ ? mapaIdsVisiveis
1152
+ : mapaLegendas.map((item) => item.id)
1153
+ const nextIds = ativosAtuais.includes(modeloIdNorm)
1154
+ ? ativosAtuais.filter((id) => id !== modeloIdNorm)
1155
+ : [...ativosAtuais, modeloIdNorm]
1156
+
1157
+ await carregarMapaPesquisa(nextIds, { atualizarLegendas: false })
1158
+ }
1159
+
1160
+ async function onAdminConfigSalva() {
1161
+ if (pesquisaInicializada) {
1162
+ await buscarModelos(filters, localizacaoResolvida)
1163
+ return
1164
+ }
1165
+ await carregarContextoInicial()
1166
+ }
1167
+
1168
+ async function onResolverLocalizacao() {
1169
+ setLocalizacaoError('')
1170
+ setLocalizacaoStatus('')
1171
+ if (localizacaoModo === 'coords') {
1172
+ const lat = Number(localizacaoInputs.latitude)
1173
+ const lon = Number(localizacaoInputs.longitude)
1174
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
1175
+ setLocalizacaoError('Informe latitude e longitude válidas para localizar o avaliando.')
1176
+ return
1177
+ }
1178
+ } else {
1179
+ if (!String(localizacaoInputs.logradouro || '').trim()) {
1180
+ setLocalizacaoError('Informe o logradouro para localizar o avaliando.')
1181
+ return
1182
+ }
1183
+ const numero = Number(localizacaoInputs.numero)
1184
+ if (!Number.isFinite(numero) || numero <= 0) {
1185
+ setLocalizacaoError('Informe um número válido para localizar o avaliando.')
1186
+ return
1187
+ }
1188
+ }
1189
+
1190
+ setLocalizacaoLoading(true)
1191
  try {
1192
+ const response = await api.pesquisarLocalizacaoAvaliando({
1193
+ latitude: localizacaoModo === 'coords' ? Number(localizacaoInputs.latitude) : null,
1194
+ longitude: localizacaoModo === 'coords' ? Number(localizacaoInputs.longitude) : null,
1195
+ logradouro: localizacaoModo === 'endereco' ? String(localizacaoInputs.logradouro || '').trim() : null,
1196
+ numero: localizacaoModo === 'endereco' ? Number(localizacaoInputs.numero) : null,
1197
+ cdlog: localizacaoModo === 'endereco' && String(localizacaoInputs.cdlog || '').trim()
1198
+ ? Number(localizacaoInputs.cdlog)
1199
+ : null,
1200
+ })
1201
+ const resolvida = {
1202
+ ...response,
1203
+ lat: Number(response?.lat),
1204
+ lon: Number(response?.lon),
1205
+ }
1206
+ setLocalizacaoResolvida(resolvida)
1207
+ setLocalizacaoStatus(response?.status || 'Localização do avaliando definida.')
1208
+ resetMapaPesquisa()
1209
+ if (pesquisaInicializada) {
1210
+ await buscarModelos(filters, resolvida)
1211
+ }
1212
  } catch (err) {
1213
+ setLocalizacaoError(err.message || 'Falha ao localizar o avaliando.')
1214
+ setLocalizacaoResolvida(null)
1215
  } finally {
1216
+ setLocalizacaoLoading(false)
1217
  }
1218
  }
1219
 
1220
+ async function onLimparLocalizacao() {
1221
+ setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
1222
+ setLocalizacaoResolvida(null)
1223
+ setLocalizacaoError('')
1224
+ setLocalizacaoStatus('')
1225
+ resetMapaPesquisa()
1226
  if (pesquisaInicializada) {
1227
+ await buscarModelos(filters, null)
 
1228
  }
 
1229
  }
1230
 
1231
  if (modoModeloAberto) {
 
1442
  </div>
1443
  </div>
1444
 
1445
+ <div className="pesquisa-periodo-versoes-grid">
1446
+ <div className="pesquisa-periodo-fields">
1447
+ <label className="pesquisa-field">
1448
+ Data inicial
1449
+ <DateFieldInput field="dataMin" value={filters.dataMin} onChange={onFieldChange} />
1450
+ </label>
1451
+ <label className="pesquisa-field">
1452
+ Data final
1453
+ <DateFieldInput field="dataMax" value={filters.dataMax} onChange={onFieldChange} />
1454
+ </label>
1455
+ </div>
1456
+
1457
+ <label className="pesquisa-field pesquisa-versionamento-field">
1458
+ Versionamento dos modelos
1459
+ <select
1460
+ data-field="versionamentoModelos"
1461
+ name={toInputName('versionamentoModelos')}
1462
+ value={filters.versionamentoModelos}
1463
+ onChange={onFieldChange}
1464
+ autoComplete="off"
1465
+ >
1466
+ <option value="atuais">Exibir somente versões mais atuais</option>
1467
+ <option value="incluir_antigos">Incluir também modelos substituídos</option>
1468
+ </select>
1469
  </label>
1470
  </div>
1471
 
1472
+ <div className="pesquisa-field-pair pesquisa-localizacao-group">
1473
+ <span className="pesquisa-field-pair-title">Localização do avaliando (opcional)</span>
1474
+ <div className="pesquisa-localizacao-optional-hint">
1475
+ O preenchimento deste grupo não é obrigatório. Se você não informar localização, a pesquisa continua normal e apenas a distância espacial dos modelos não será calculada.
1476
+ </div>
1477
+
1478
+ {localizacaoModo === 'coords' ? (
1479
+ <div className="pesquisa-localizacao-grid pesquisa-localizacao-grid-coords">
1480
+ <label className="pesquisa-field">
1481
+ Forma de localização
1482
+ <select
1483
+ data-field="localizacaoModo"
1484
+ name={toInputName('localizacaoModo')}
1485
+ value={localizacaoModo}
1486
+ onChange={(event) => setLocalizacaoModo(event.target.value)}
1487
+ autoComplete="off"
1488
+ >
1489
+ <option value="endereco">Endereço</option>
1490
+ <option value="coords">Coordenadas</option>
1491
+ </select>
1492
+ </label>
1493
+ <label className="pesquisa-field">
1494
+ Latitude
1495
+ <NumberFieldInput field="latitude" value={localizacaoInputs.latitude} onChange={onLocalizacaoFieldChange} placeholder="-30.000000" />
1496
+ </label>
1497
+ <label className="pesquisa-field">
1498
+ Longitude
1499
+ <NumberFieldInput field="longitude" value={localizacaoInputs.longitude} onChange={onLocalizacaoFieldChange} placeholder="-51.000000" />
1500
+ </label>
1501
+ <div className="pesquisa-localizacao-actions-inline">
1502
+ <button
1503
+ type="button"
1504
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
1505
+ onClick={() => void onResolverLocalizacao()}
1506
+ disabled={localizacaoLoading}
1507
+ >
1508
+ {localizacaoLoading ? 'Buscando...' : 'Buscar'}
1509
+ </button>
1510
+ <button
1511
+ type="button"
1512
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1513
+ onClick={() => void onLimparLocalizacao()}
1514
+ disabled={localizacaoLoading}
1515
+ >
1516
+ Limpar
1517
+ </button>
1518
+ </div>
1519
+ </div>
1520
+ ) : (
1521
+ <div className="pesquisa-localizacao-grid pesquisa-localizacao-grid-endereco">
1522
+ <label className="pesquisa-field">
1523
+ Forma de localização
1524
+ <select
1525
+ data-field="localizacaoModo"
1526
+ name={toInputName('localizacaoModo')}
1527
+ value={localizacaoModo}
1528
+ onChange={(event) => setLocalizacaoModo(event.target.value)}
1529
+ autoComplete="off"
1530
+ >
1531
+ <option value="endereco">Endereço</option>
1532
+ <option value="coords">Coordenadas</option>
1533
+ </select>
1534
+ </label>
1535
+ <label className="pesquisa-field">
1536
+ CDLOG
1537
+ <NumberFieldInput field="cdlog" value={localizacaoInputs.cdlog} onChange={onLocalizacaoFieldChange} placeholder="Opcional" />
1538
+ </label>
1539
+ <label className="pesquisa-field pesquisa-localizacao-logradouro-field">
1540
+ Logradouro
1541
+ <SinglePillAutocomplete
1542
+ value={localizacaoInputs.logradouro}
1543
+ onChange={(nextValue) => atualizarCampoLocalizacao('logradouro', nextValue)}
1544
+ options={sugestoes.logradouros_eixos || []}
1545
+ placeholder="Digite ou selecione um logradouro dos eixos"
1546
+ panelTitle="Logradouros dos eixos"
1547
+ emptyMessage="Nenhum logradouro encontrado nos eixos."
1548
+ loading={loading && !sugestoesInicializadas}
1549
+ inputName={toInputName('logradouroEixosPesquisa')}
1550
+ inputAutoComplete="new-password"
1551
+ />
1552
+ </label>
1553
+ <label className="pesquisa-field">
1554
+ Número
1555
+ <NumberFieldInput field="numero" value={localizacaoInputs.numero} onChange={onLocalizacaoFieldChange} placeholder="0" />
1556
+ </label>
1557
+ <div className="pesquisa-localizacao-actions-inline">
1558
+ <button
1559
+ type="button"
1560
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
1561
+ onClick={() => void onResolverLocalizacao()}
1562
+ disabled={localizacaoLoading}
1563
+ >
1564
+ {localizacaoLoading ? 'Buscando...' : 'Buscar'}
1565
+ </button>
1566
+ <button
1567
+ type="button"
1568
+ className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
1569
+ onClick={() => void onLimparLocalizacao()}
1570
+ disabled={localizacaoLoading}
1571
+ >
1572
+ Limpar
1573
+ </button>
1574
+ </div>
1575
+ </div>
1576
+ )}
1577
+
1578
+ {localizacaoStatus && !localizacaoAtiva ? <div className="status-line">{localizacaoStatus}</div> : null}
1579
+ {localizacaoError ? <div className="error-line inline-error">{localizacaoError}</div> : null}
1580
+
1581
+ {localizacaoAtiva ? (
1582
+ <div className="pesquisa-localizacao-summary">
1583
+ {localizacaoResolvida?.logradouro ? (
1584
+ <div className="pesquisa-localizacao-summary-row">
1585
+ <span className="pesquisa-localizacao-summary-label">Endereço</span>
1586
+ <span className="pesquisa-localizacao-summary-value">
1587
+ {localizacaoResolvida.logradouro}
1588
+ {localizacaoResolvida?.numero_usado ? `, ${localizacaoResolvida.numero_usado}` : ''}
1589
+ </span>
1590
+ </div>
1591
+ ) : null}
1592
+ {localizacaoResolvida?.cdlog ? (
1593
+ <div className="pesquisa-localizacao-summary-row">
1594
+ <span className="pesquisa-localizacao-summary-label">CDLOG</span>
1595
+ <span className="pesquisa-localizacao-summary-value">{localizacaoResolvida.cdlog}</span>
1596
+ </div>
1597
+ ) : null}
1598
+ <div className="pesquisa-localizacao-summary-row">
1599
+ <span className="pesquisa-localizacao-summary-label">Latitude</span>
1600
+ <span className="pesquisa-localizacao-summary-value">{Number(localizacaoResolvida.lat).toFixed(6)}</span>
1601
+ </div>
1602
+ <div className="pesquisa-localizacao-summary-row">
1603
+ <span className="pesquisa-localizacao-summary-label">Longitude</span>
1604
+ <span className="pesquisa-localizacao-summary-value">{Number(localizacaoResolvida.lon).toFixed(6)}</span>
1605
+ </div>
1606
+ <div className="pesquisa-localizacao-summary-row">
1607
+ <span className="pesquisa-localizacao-summary-label">Origem</span>
1608
+ <span className="pesquisa-localizacao-summary-value">
1609
+ {localizacaoResolvida?.origem === 'eixos' ? 'Eixos de logradouro' : 'Coordenadas informadas'}
1610
+ </span>
1611
+ </div>
1612
+ </div>
1613
+ ) : null}
1614
+ </div>
1615
+
1616
  <div className="row pesquisa-actions pesquisa-actions-primary">
1617
  <button type="button" onClick={() => void buscarModelos()} disabled={loading}>
1618
  {loading ? 'Pesquisando...' : 'Pesquisar'}
 
1686
  </div>
1687
  <div className="pesquisa-card-body">
1688
  <div className="pesquisa-card-dados-list">
1689
+ <div><strong>Distância:</strong> {String(modelo.distancia_label || '').trim() || formatDistanceKm(modelo.distancia_km)}</div>
1690
  <div><strong>Tipo:</strong> {formatTipoImovel(modelo)}</div>
1691
  <div><strong>Autor:</strong> {modelo.autor || '-'}</div>
1692
  <div><strong>Dados:</strong> {formatCount(modelo.total_dados)}</div>
 
1741
  </SectionBlock>
1742
  </div>
1743
 
1744
+ <SectionBlock
1745
+ step="3"
1746
+ title="Mapa"
1747
+ subtitle={localizacaoAtiva
1748
+ ? 'Plote os modelos selecionados com dados de mercado ou cobertura dos modelos, com o avaliando destacado.'
1749
+ : 'Plote os modelos selecionados com dados de mercado ou cobertura dos modelos.'}
1750
+ >
1751
  <div className="pesquisa-summary-line">
1752
  <strong>{formatCount(selectedIds.length)}</strong> modelo(s) selecionado(s) para plotagem.
1753
  </div>
1754
+ <div className="pesquisa-summary-line">
1755
+ <strong>Localização do avaliando:</strong> {localizacaoAtiva ? 'ativa' : 'não definida'}
1756
+ </div>
1757
 
1758
+ <div className="row pesquisa-actions pesquisa-mapa-actions">
1759
  <button type="button" onClick={() => void onGerarMapaSelecionados()} disabled={mapaLoading || !selectedIds.length}>
1760
  {mapaLoading ? 'Gerando mapa...' : 'Plotar mapa dos selecionados'}
1761
  </button>
 
1764
  {mapaStatus ? <div className="status-line">{mapaStatus}</div> : null}
1765
  {mapaError ? <div className="error-line inline-error">{mapaError}</div> : null}
1766
 
1767
+ {mapaHtmlAtual ? (
1768
+ <div className="row pesquisa-actions pesquisa-mapa-actions">
1769
+ <label className="pesquisa-field pesquisa-mapa-modo-field">
1770
+ Exibição do mapa
1771
+ <select
1772
+ value={mapaModoExibicao}
1773
+ onChange={(event) => setMapaModoExibicao(event.target.value)}
1774
+ autoComplete="off"
1775
+ >
1776
+ <option value="pontos">Pontos representando dados de mercado</option>
1777
+ <option value="cobertura">Cobertura dos modelos</option>
1778
+ </select>
1779
+ </label>
1780
+ </div>
1781
+ ) : null}
1782
+
1783
  {mapaLegendas.length ? (
1784
  <div className="pesquisa-legenda-grid">
1785
  {mapaLegendas.map((item) => (
1786
+ <label
1787
+ key={item.id}
1788
+ className={`pesquisa-legenda-item${mapaIdsVisiveis.includes(item.id) ? '' : ' is-disabled'}`}
1789
+ >
1790
+ <input
1791
+ type="checkbox"
1792
+ checked={mapaIdsVisiveis.includes(item.id)}
1793
+ onChange={() => void onToggleLegendaMapa(item.id)}
1794
+ disabled={mapaLoading}
1795
+ />
1796
  <span className="pesquisa-legenda-color" style={{ backgroundColor: item.cor }} />
1797
+ <span>
1798
+ {item.nome} ({item.total_pontos})
1799
+ {String(item?.distancia_label || '').trim() ? ` • ${item.distancia_label}` : ''}
1800
+ </span>
1801
+ </label>
1802
  ))}
1803
  </div>
1804
  ) : null}
1805
 
1806
+ {mapaHtmlAtual ? <MapFrame html={mapaHtmlAtual} /> : <div className="empty-box">Nenhum mapa gerado ainda.</div>}
1807
  </SectionBlock>
1808
 
1809
  <LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
frontend/src/components/SinglePillAutocomplete.jsx CHANGED
@@ -22,6 +22,25 @@ function normalizeOption(option) {
22
  return { value, label, secondary }
23
  }
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  export default function SinglePillAutocomplete({
26
  value,
27
  onChange,
@@ -32,6 +51,8 @@ export default function SinglePillAutocomplete({
32
  loading = false,
33
  disabled = false,
34
  onOpenChange = null,
 
 
35
  }) {
36
  const rootRef = useRef(null)
37
  const inputRef = useRef(null)
@@ -63,10 +84,13 @@ export default function SinglePillAutocomplete({
63
  if (loading) return []
64
  if (!queryNormalized) return normalizedOptions.slice(0, 160)
65
  return normalizedOptions
66
- .filter((item) => (
67
- normalizeSearchText(item.label).includes(queryNormalized)
68
- || normalizeSearchText(item.secondary).includes(queryNormalized)
69
- ))
 
 
 
70
  .slice(0, 160)
71
  }, [loading, normalizedOptions, queryNormalized])
72
 
@@ -203,16 +227,19 @@ export default function SinglePillAutocomplete({
203
  ref={inputRef}
204
  type="text"
205
  className="chip-autocomplete-single-input"
 
206
  value={query}
207
  onChange={onInputChange}
208
  onFocus={onInputFocus}
209
  onKeyDown={onInputKeyDown}
210
  placeholder={selectedOption ? '' : placeholder}
211
- autoComplete="off"
212
  autoCorrect="off"
213
  autoCapitalize="none"
214
  spellCheck={false}
215
  disabled={disabled}
 
 
216
  />
217
  </div>
218
 
 
22
  return { value, label, secondary }
23
  }
24
 
25
+ function scoreOptionMatch(option, queryNormalized) {
26
+ if (!queryNormalized) return 1
27
+
28
+ const labelNorm = normalizeSearchText(option?.label || '')
29
+ const secondaryNorm = normalizeSearchText(option?.secondary || '')
30
+ const labelTokens = labelNorm.split(/\s+/).filter(Boolean)
31
+ const secondaryTokens = secondaryNorm.split(/\s+/).filter(Boolean)
32
+
33
+ if (labelNorm === queryNormalized) return 400
34
+ if (labelTokens.includes(queryNormalized)) return 320
35
+ if (labelNorm.startsWith(queryNormalized)) return 280
36
+ if (labelTokens.some((token) => token.startsWith(queryNormalized))) return 220
37
+ if (labelNorm.includes(queryNormalized)) return 180
38
+ if (secondaryNorm === queryNormalized) return 160
39
+ if (secondaryTokens.includes(queryNormalized)) return 140
40
+ if (secondaryNorm.includes(queryNormalized)) return 120
41
+ return 0
42
+ }
43
+
44
  export default function SinglePillAutocomplete({
45
  value,
46
  onChange,
 
51
  loading = false,
52
  disabled = false,
53
  onOpenChange = null,
54
+ inputName = '',
55
+ inputAutoComplete = 'off',
56
  }) {
57
  const rootRef = useRef(null)
58
  const inputRef = useRef(null)
 
84
  if (loading) return []
85
  if (!queryNormalized) return normalizedOptions.slice(0, 160)
86
  return normalizedOptions
87
+ .map((item, index) => ({ item, index, score: scoreOptionMatch(item, queryNormalized) }))
88
+ .filter((entry) => entry.score > 0)
89
+ .sort((a, b) => {
90
+ if (b.score !== a.score) return b.score - a.score
91
+ return a.index - b.index
92
+ })
93
+ .map((entry) => entry.item)
94
  .slice(0, 160)
95
  }, [loading, normalizedOptions, queryNormalized])
96
 
 
227
  ref={inputRef}
228
  type="text"
229
  className="chip-autocomplete-single-input"
230
+ name={inputName || undefined}
231
  value={query}
232
  onChange={onInputChange}
233
  onFocus={onInputFocus}
234
  onKeyDown={onInputKeyDown}
235
  placeholder={selectedOption ? '' : placeholder}
236
+ autoComplete={inputAutoComplete}
237
  autoCorrect="off"
238
  autoCapitalize="none"
239
  spellCheck={false}
240
  disabled={disabled}
241
+ data-lpignore="true"
242
+ data-1p-ignore="true"
243
  />
244
  </div>
245
 
frontend/src/styles.css CHANGED
@@ -22,6 +22,7 @@
22
  --radius-lg: 16px;
23
  --radius-md: 12px;
24
  --radius-sm: 9px;
 
25
  }
26
 
27
  * {
@@ -1676,9 +1677,23 @@ button.pesquisa-otica-btn.active:hover {
1676
  align-items: start;
1677
  }
1678
 
1679
- .pesquisa-avaliando-periodo-pair {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1680
  margin: 0;
1681
- height: auto;
1682
  align-self: start;
1683
  }
1684
 
@@ -1697,6 +1712,150 @@ button.pesquisa-otica-btn.active:hover {
1697
  min-width: 0;
1698
  }
1699
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1700
  .pesquisa-avaliando-bottom-grid .pesquisa-field-pair {
1701
  grid-column: auto;
1702
  margin: 0;
@@ -1841,6 +2000,21 @@ button.pesquisa-coluna-remove:hover {
1841
  justify-content: flex-end;
1842
  }
1843
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1844
  .pesquisa-status {
1845
  margin-top: 2px;
1846
  }
@@ -2458,6 +2632,17 @@ button.pesquisa-coluna-remove:hover {
2458
  color: #3f566b;
2459
  font-size: 0.8rem;
2460
  font-weight: 700;
 
 
 
 
 
 
 
 
 
 
 
2461
  }
2462
 
2463
  .pesquisa-legenda-color {
@@ -5499,6 +5684,7 @@ button.btn-download-subtle {
5499
  .pesquisa-filtros-grid,
5500
  .pesquisa-avaliando-grid-v2,
5501
  .pesquisa-fields-grid,
 
5502
  .pesquisa-card-grid,
5503
  .pesquisa-compare-grid,
5504
  .pesquisa-compat-row {
@@ -5515,10 +5701,19 @@ button.btn-download-subtle {
5515
  grid-template-columns: 1fr;
5516
  }
5517
 
 
 
 
 
5518
  .pesquisa-avaliando-bottom-grid {
5519
  grid-template-columns: 1fr;
5520
  }
5521
 
 
 
 
 
 
5522
  .pesquisa-area-rh-grid {
5523
  grid-template-columns: 1fr;
5524
  }
 
22
  --radius-lg: 16px;
23
  --radius-md: 12px;
24
  --radius-sm: 9px;
25
+ --pesquisa-localizacao-control-height: 40px;
26
  }
27
 
28
  * {
 
1677
  align-items: start;
1678
  }
1679
 
1680
+ .pesquisa-periodo-versoes-grid {
1681
+ display: grid;
1682
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1683
+ gap: 16px 14px;
1684
+ align-items: start;
1685
+ margin-bottom: 12px;
1686
+ }
1687
+
1688
+ .pesquisa-periodo-fields {
1689
+ display: grid;
1690
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1691
+ gap: 16px 14px;
1692
+ align-items: start;
1693
+ }
1694
+
1695
+ .pesquisa-versionamento-field {
1696
  margin: 0;
 
1697
  align-self: start;
1698
  }
1699
 
 
1712
  min-width: 0;
1713
  }
1714
 
1715
+ .pesquisa-localizacao-grid {
1716
+ grid-column: 1 / -1;
1717
+ display: grid;
1718
+ gap: 16px 14px;
1719
+ margin-bottom: 12px;
1720
+ align-items: start;
1721
+ }
1722
+
1723
+ .pesquisa-localizacao-grid-coords {
1724
+ grid-template-columns: minmax(120px, 0.54fr) minmax(0, 1fr) minmax(0, 1fr) auto;
1725
+ align-items: start;
1726
+ }
1727
+
1728
+ .pesquisa-localizacao-grid-endereco {
1729
+ grid-template-columns: minmax(120px, 0.54fr) minmax(120px, 0.55fr) minmax(0, 1.7fr) minmax(110px, 0.5fr) auto;
1730
+ align-items: start;
1731
+ }
1732
+
1733
+ .pesquisa-localizacao-logradouro-field {
1734
+ min-width: 0;
1735
+ }
1736
+
1737
+ .pesquisa-localizacao-logradouro-field .chip-autocomplete,
1738
+ .pesquisa-localizacao-logradouro-field .chip-autocomplete-single {
1739
+ width: 100%;
1740
+ }
1741
+
1742
+ .pesquisa-localizacao-logradouro-field .chip-autocomplete-single-control {
1743
+ min-height: var(--pesquisa-localizacao-control-height);
1744
+ height: var(--pesquisa-localizacao-control-height);
1745
+ padding: 0 10px;
1746
+ flex-wrap: nowrap;
1747
+ align-items: center;
1748
+ }
1749
+
1750
+ .pesquisa-localizacao-logradouro-field .chip-autocomplete-single-input {
1751
+ min-height: calc(var(--pesquisa-localizacao-control-height) - 4px);
1752
+ height: calc(var(--pesquisa-localizacao-control-height) - 4px);
1753
+ padding: 0;
1754
+ }
1755
+
1756
+ .pesquisa-localizacao-grid .pesquisa-field {
1757
+ min-width: 0;
1758
+ }
1759
+
1760
+ .pesquisa-localizacao-grid .pesquisa-field input,
1761
+ .pesquisa-localizacao-grid .pesquisa-field select {
1762
+ width: 100%;
1763
+ min-height: var(--pesquisa-localizacao-control-height);
1764
+ height: var(--pesquisa-localizacao-control-height);
1765
+ }
1766
+
1767
+ .pesquisa-localizacao-group {
1768
+ margin-top: 2px;
1769
+ }
1770
+
1771
+ .pesquisa-localizacao-optional-hint {
1772
+ grid-column: 1 / -1;
1773
+ font-size: 0.82rem;
1774
+ color: #55708a;
1775
+ line-height: 1.45;
1776
+ }
1777
+
1778
+ .pesquisa-localizacao-action {
1779
+ min-width: 86px;
1780
+ min-height: var(--pesquisa-localizacao-control-height);
1781
+ height: var(--pesquisa-localizacao-control-height);
1782
+ padding: 0 14px;
1783
+ border-radius: 10px;
1784
+ font-weight: 800;
1785
+ box-shadow: none;
1786
+ white-space: nowrap;
1787
+ }
1788
+
1789
+ .pesquisa-localizacao-action:hover {
1790
+ transform: none;
1791
+ box-shadow: none;
1792
+ }
1793
+
1794
+ .pesquisa-localizacao-action-ok {
1795
+ background: #2e8b57;
1796
+ border-color: #2e8b57;
1797
+ color: #ffffff;
1798
+ }
1799
+
1800
+ .pesquisa-localizacao-action-reset {
1801
+ background: #edf1f5;
1802
+ border-color: #cfd8e2;
1803
+ color: #647588;
1804
+ }
1805
+
1806
+ .pesquisa-localizacao-actions-inline {
1807
+ align-self: start;
1808
+ display: inline-flex;
1809
+ align-items: center;
1810
+ justify-content: flex-end;
1811
+ gap: 8px;
1812
+ min-height: var(--pesquisa-localizacao-control-height);
1813
+ height: var(--pesquisa-localizacao-control-height);
1814
+ margin-top: 27px;
1815
+ }
1816
+
1817
+ .pesquisa-localizacao-summary {
1818
+ grid-column: 1 / -1;
1819
+ display: grid;
1820
+ gap: 0;
1821
+ border: 1px solid #d6e3ef;
1822
+ border-radius: 10px;
1823
+ background: #fbfdff;
1824
+ overflow: hidden;
1825
+ }
1826
+
1827
+ .pesquisa-localizacao-summary-row {
1828
+ display: grid;
1829
+ grid-template-columns: minmax(130px, 0.9fr) minmax(0, 2fr);
1830
+ border-top: 1px solid #e6eef5;
1831
+ }
1832
+
1833
+ .pesquisa-localizacao-summary-row:first-child {
1834
+ border-top: none;
1835
+ }
1836
+
1837
+ .pesquisa-localizacao-summary-label,
1838
+ .pesquisa-localizacao-summary-value {
1839
+ padding: 8px 12px;
1840
+ font-size: 0.83rem;
1841
+ line-height: 1.4;
1842
+ }
1843
+
1844
+ .pesquisa-localizacao-summary-label {
1845
+ background: #f3f7fb;
1846
+ color: #46627d;
1847
+ font-weight: 700;
1848
+ }
1849
+
1850
+ .pesquisa-localizacao-summary-value {
1851
+ color: #35526b;
1852
+ }
1853
+
1854
+ .pesquisa-localizacao-group .status-line,
1855
+ .pesquisa-localizacao-group .error-line {
1856
+ grid-column: 1 / -1;
1857
+ }
1858
+
1859
  .pesquisa-avaliando-bottom-grid .pesquisa-field-pair {
1860
  grid-column: auto;
1861
  margin: 0;
 
2000
  justify-content: flex-end;
2001
  }
2002
 
2003
+ .pesquisa-mapa-actions {
2004
+ align-items: flex-end;
2005
+ gap: 12px;
2006
+ }
2007
+
2008
+ .status-line + .pesquisa-mapa-actions {
2009
+ margin-top: 14px;
2010
+ }
2011
+
2012
+ .pesquisa-mapa-modo-field {
2013
+ min-width: min(420px, 100%);
2014
+ max-width: 520px;
2015
+ margin: 0;
2016
+ }
2017
+
2018
  .pesquisa-status {
2019
  margin-top: 2px;
2020
  }
 
2632
  color: #3f566b;
2633
  font-size: 0.8rem;
2634
  font-weight: 700;
2635
+ cursor: pointer;
2636
+ }
2637
+
2638
+ .pesquisa-legenda-item input {
2639
+ margin: 0;
2640
+ }
2641
+
2642
+ .pesquisa-legenda-item.is-disabled {
2643
+ opacity: 0.52;
2644
+ background: #f5f8fb;
2645
+ color: #708396;
2646
  }
2647
 
2648
  .pesquisa-legenda-color {
 
5684
  .pesquisa-filtros-grid,
5685
  .pesquisa-avaliando-grid-v2,
5686
  .pesquisa-fields-grid,
5687
+ .pesquisa-localizacao-grid,
5688
  .pesquisa-card-grid,
5689
  .pesquisa-compare-grid,
5690
  .pesquisa-compat-row {
 
5701
  grid-template-columns: 1fr;
5702
  }
5703
 
5704
+ .pesquisa-localizacao-summary-row {
5705
+ grid-template-columns: 1fr;
5706
+ }
5707
+
5708
  .pesquisa-avaliando-bottom-grid {
5709
  grid-template-columns: 1fr;
5710
  }
5711
 
5712
+ .pesquisa-periodo-versoes-grid,
5713
+ .pesquisa-periodo-fields {
5714
+ grid-template-columns: 1fr;
5715
+ }
5716
+
5717
  .pesquisa-area-rh-grid {
5718
  grid-template-columns: 1fr;
5719
  }