Guilherme Silberfarb Costa commited on
Commit
21fc066
·
1 Parent(s): da3ac65

knn e melhorias esteticas

Browse files
backend/app/api/elaboracao.py CHANGED
@@ -126,6 +126,10 @@ class AvaliacaoPayload(SessionPayload):
126
  indice_base: str | None = None
127
 
128
 
 
 
 
 
129
  class AvaliacaoDeletePayload(SessionPayload):
130
  indice: str | None = None
131
  indice_base: str | None = None
@@ -384,6 +388,21 @@ def evaluation_calculate(payload: AvaliacaoPayload, request: Request) -> dict[st
384
  return resposta
385
 
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  @router.post("/evaluation/clear")
388
  def evaluation_clear(payload: SessionPayload) -> dict[str, Any]:
389
  session = session_store.get(payload.session_id)
 
126
  indice_base: str | None = None
127
 
128
 
129
+ class AvaliacaoKnnDetalhesPayload(SessionPayload):
130
+ valores_x: dict[str, Any]
131
+
132
+
133
  class AvaliacaoDeletePayload(SessionPayload):
134
  indice: str | None = None
135
  indice_base: str | None = None
 
388
  return resposta
389
 
390
 
391
+ @router.post("/evaluation/knn-details")
392
+ def evaluation_knn_details(payload: AvaliacaoKnnDetalhesPayload, request: Request) -> dict[str, Any]:
393
+ session = session_store.get(payload.session_id)
394
+ user = auth_service.require_user(request)
395
+ resposta = elaboracao_service.detalhes_knn_avaliacao_elaboracao(session, payload.valores_x)
396
+ log_event(
397
+ "elaboracao",
398
+ "avaliacao_knn_detalhes",
399
+ user=user,
400
+ session_id=payload.session_id,
401
+ request=request,
402
+ )
403
+ return resposta
404
+
405
+
406
  @router.post("/evaluation/clear")
407
  def evaluation_clear(payload: SessionPayload) -> dict[str, Any]:
408
  session = session_store.get(payload.session_id)
backend/app/api/visualizacao.py CHANGED
@@ -28,6 +28,10 @@ class AvaliacaoPayload(SessionPayload):
28
  indice_base: str | None = None
29
 
30
 
 
 
 
 
31
  class AvaliacaoDeletePayload(SessionPayload):
32
  indice: str | None = None
33
  indice_base: str | None = None
@@ -122,6 +126,21 @@ def evaluation_calculate(payload: AvaliacaoPayload, request: Request) -> dict[st
122
  return resposta
123
 
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  @router.post("/evaluation/clear")
126
  def evaluation_clear(payload: SessionPayload) -> dict[str, Any]:
127
  session = session_store.get(payload.session_id)
 
28
  indice_base: str | None = None
29
 
30
 
31
+ class AvaliacaoKnnDetalhesPayload(SessionPayload):
32
+ valores_x: dict[str, Any]
33
+
34
+
35
  class AvaliacaoDeletePayload(SessionPayload):
36
  indice: str | None = None
37
  indice_base: str | None = None
 
126
  return resposta
127
 
128
 
129
+ @router.post("/evaluation/knn-details")
130
+ def evaluation_knn_details(payload: AvaliacaoKnnDetalhesPayload, request: Request) -> dict[str, Any]:
131
+ session = session_store.get(payload.session_id)
132
+ user = auth_service.require_user(request)
133
+ resposta = visualizacao_service.detalhes_knn_avaliacao(session, payload.valores_x)
134
+ log_event(
135
+ "visualizacao",
136
+ "avaliacao_knn_detalhes",
137
+ user=user,
138
+ session_id=payload.session_id,
139
+ request=request,
140
+ )
141
+ return resposta
142
+
143
+
144
  @router.post("/evaluation/clear")
145
  def evaluation_clear(payload: SessionPayload) -> dict[str, Any]:
146
  session = session_store.get(payload.session_id)
backend/app/core/elaboracao/core.py CHANGED
@@ -2209,6 +2209,7 @@ def exportar_avaliacoes_excel(avaliacoes_lista):
2209
  valores.append(aval["valores_x"].get(var, ""))
2210
  # Métricas
2211
  valores.append(aval["estimado"])
 
2212
  valores.append(aval["ca_inf"])
2213
  valores.append(aval["ca_sup"])
2214
  valores.append(aval["ic_inf"])
@@ -2221,7 +2222,7 @@ def exportar_avaliacoes_excel(avaliacoes_lista):
2221
  dados[col_name] = valores
2222
 
2223
  indice = colunas_x + [
2224
- "Estimado", "CA −15%", "CA +15%",
2225
  "IC 80% Inf.", "IC 80% Sup.",
2226
  "% Inf.", "% Sup.", "Amplitude",
2227
  "Precisão", "Fundamentação",
 
2209
  valores.append(aval["valores_x"].get(var, ""))
2210
  # Métricas
2211
  valores.append(aval["estimado"])
2212
+ valores.append(aval.get("knn_estimado"))
2213
  valores.append(aval["ca_inf"])
2214
  valores.append(aval["ca_sup"])
2215
  valores.append(aval["ic_inf"])
 
2222
  dados[col_name] = valores
2223
 
2224
  indice = colunas_x + [
2225
+ "Estimado", "Estimativa KNN", "CA −15%", "CA +15%",
2226
  "IC 80% Inf.", "IC 80% Sup.",
2227
  "% Inf.", "% Sup.", "Amplitude",
2228
  "Precisão", "Fundamentação",
backend/app/core/elaboracao/formatadores.py CHANGED
@@ -584,6 +584,65 @@ def _popup_fronteira_html(aval):
584
  )
585
 
586
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  def formatar_avaliacao_html(avaliacoes_lista, indice_base=0, elem_id_excluir="excluir-aval-elab"):
588
  """Formata resultados de avaliação como tabela HTML acumulada.
589
 
@@ -682,6 +741,20 @@ def formatar_avaliacao_html(avaliacoes_lista, indice_base=0, elem_id_excluir="ex
682
  html += f'<td {_td_r}>{celula} {popup}</td>'
683
  html += '</tr>'
684
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
685
  # Estimado / Base (só se houver mais de 1 avaliação e base selecionada)
686
  if n > 1 and indice_base_normalizado is not None:
687
  estimado_base = avaliacoes_lista[indice_base_normalizado]["estimado"]
 
584
  )
585
 
586
 
587
+ def _popup_knn_html(aval):
588
+ """Gera conteúdo HTML do popup para a linha 'Estimativa KNN'."""
589
+
590
+ disponivel = bool(aval.get("knn_disponivel"))
591
+ estimado = _formatar_brl(aval.get("knn_estimado"))
592
+ motivo = str(aval.get("knn_motivo") or "").strip()
593
+ colunas = ", ".join(str(c) for c in (aval.get("knn_colunas") or [])) or "—"
594
+ k = aval.get("knn_k")
595
+ n_validos = aval.get("knn_n_validos")
596
+ usa_geo = bool(aval.get("knn_geo_aplicado"))
597
+ alpha_geo = aval.get("knn_alpha_geo")
598
+
599
+ if not disponivel or estimado == "—":
600
+ motivo_txt = motivo or "Nao foi possivel calcular a estimativa KNN para esta avaliacao."
601
+ return (
602
+ '<div style="font-weight: 600; margin-bottom: 4px; color: #495057;">'
603
+ 'Estimativa KNN</div>'
604
+ f'<div style="font-size: 11px; color: #6c757d; line-height: 1.45;">{motivo_txt}</div>'
605
+ )
606
+
607
+ metodo = "Caracteristicas + localizacao" if usa_geo else "Somente caracteristicas"
608
+ alpha_txt = f"{float(alpha_geo):.2f}".replace(".", ",") if alpha_geo is not None else "—"
609
+ motivo_extra = (
610
+ f'<div style="margin-top: 4px; font-size: 11px; color: #6c757d;">{motivo}</div>'
611
+ if (motivo and not usa_geo)
612
+ else ""
613
+ )
614
+
615
+ return (
616
+ '<div style="font-weight: 600; margin-bottom: 4px; color: #495057;">'
617
+ 'Estimativa KNN</div>'
618
+ f'<div style="font-size: 11px; color: #6c757d; margin-bottom: 6px; line-height: 1.45;">'
619
+ f'Valor estimado por KNN: <b>{estimado}</b></div>'
620
+ '<table style="width: 100%; border-collapse: collapse; font-size: 11px;">'
621
+ '<tr style="border-bottom: 1px solid #f0f0f0;">'
622
+ '<td style="padding: 2px 6px; color: #6c757d;">Metodo</td>'
623
+ f'<td style="padding: 2px 6px; text-align: right;"><b>{metodo}</b></td>'
624
+ '</tr>'
625
+ '<tr style="border-bottom: 1px solid #f0f0f0;">'
626
+ '<td style="padding: 2px 6px; color: #6c757d;">k dinamico</td>'
627
+ f'<td style="padding: 2px 6px; text-align: right;"><b>{k if k is not None else "—"}</b></td>'
628
+ '</tr>'
629
+ '<tr style="border-bottom: 1px solid #f0f0f0;">'
630
+ '<td style="padding: 2px 6px; color: #6c757d;">Base valida</td>'
631
+ f'<td style="padding: 2px 6px; text-align: right;"><b>{n_validos if n_validos is not None else "—"}</b></td>'
632
+ '</tr>'
633
+ '<tr style="border-bottom: 1px solid #f0f0f0;">'
634
+ '<td style="padding: 2px 6px; color: #6c757d;">Peso geo (a)</td>'
635
+ f'<td style="padding: 2px 6px; text-align: right;"><b>{alpha_txt}</b></td>'
636
+ '</tr>'
637
+ '<tr>'
638
+ '<td style="padding: 2px 6px; color: #6c757d;">Caracteristicas</td>'
639
+ f'<td style="padding: 2px 6px; text-align: right;">{colunas}</td>'
640
+ '</tr>'
641
+ '</table>'
642
+ f'{motivo_extra}'
643
+ )
644
+
645
+
646
  def formatar_avaliacao_html(avaliacoes_lista, indice_base=0, elem_id_excluir="excluir-aval-elab"):
647
  """Formata resultados de avaliação como tabela HTML acumulada.
648
 
 
741
  html += f'<td {_td_r}>{celula} {popup}</td>'
742
  html += '</tr>'
743
 
744
+ # Estimativa KNN
745
+ html += f'<tr><td {_td}>Estimativa KNN</td>'
746
+ for i, aval in enumerate(avaliacoes_lista):
747
+ knn_disponivel = bool(aval.get("knn_disponivel"))
748
+ valor_knn = _formatar_brl(aval.get("knn_estimado")) if knn_disponivel else '\u2014'
749
+ idx_1 = i + 1
750
+ botao_knn = (
751
+ f'<button type="button" data-avaliacao-knn-open="1" data-avaliacao-knn-index="{idx_1}" '
752
+ f'class="avaliacao-knn-open-icon" title="Abrir detalhamento KNN" '
753
+ f'aria-label="Abrir detalhamento KNN">ⓘ</button>'
754
+ )
755
+ html += f'<td {_td_r}>{valor_knn} {botao_knn}</td>'
756
+ html += '</tr>'
757
+
758
  # Estimado / Base (só se houver mais de 1 avaliação e base selecionada)
759
  if n > 1 and indice_base_normalizado is not None:
760
  estimado_base = avaliacoes_lista[indice_base_normalizado]["estimado"]
backend/app/services/elaboracao_service.py CHANGED
@@ -50,6 +50,7 @@ from app.core.elaboracao.formatadores import (
50
  from app.models.session import SessionState
51
  from app.services import model_repository
52
  from app.services.equacao_service import build_equacoes_payload, exportar_planilha_equacao
 
53
  from app.services.serializers import dataframe_to_payload, figure_to_payload, sanitize_value
54
 
55
  _AVALIADORES_PATH = Path(__file__).resolve().parent.parent / "core" / "elaboracao" / "avaliadores.json"
@@ -2020,6 +2021,16 @@ def calcular_avaliacao_elaboracao(
2020
  if resultado is None:
2021
  raise HTTPException(status_code=400, detail="Erro ao calcular avaliacao")
2022
 
 
 
 
 
 
 
 
 
 
 
2023
  session.avaliacoes_elaboracao.append(resultado)
2024
  total_avaliacoes = len(session.avaliacoes_elaboracao)
2025
  idx_base, base = _resolver_indice_base(indice_base, total_avaliacoes, default_para_primeira=True)
@@ -2035,6 +2046,110 @@ def calcular_avaliacao_elaboracao(
2035
  }
2036
 
2037
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2038
  def limpar_avaliacoes_elaboracao(session: SessionState) -> dict[str, Any]:
2039
  session.avaliacoes_elaboracao = []
2040
  return {
 
50
  from app.models.session import SessionState
51
  from app.services import model_repository
52
  from app.services.equacao_service import build_equacoes_payload, exportar_planilha_equacao
53
+ from app.services.knn_avaliacao_service import estimar_valor_knn_avaliacao
54
  from app.services.serializers import dataframe_to_payload, figure_to_payload, sanitize_value
55
 
56
  _AVALIADORES_PATH = Path(__file__).resolve().parent.parent / "core" / "elaboracao" / "avaliadores.json"
 
2021
  if resultado is None:
2022
  raise HTTPException(status_code=400, detail="Erro ao calcular avaliacao")
2023
 
2024
+ coluna_y_knn = str(session.coluna_y or session.resultado_modelo.get("coluna_y") or "").strip()
2025
+ resultado_knn = estimar_valor_knn_avaliacao(
2026
+ df_base=session.df_filtrado if session.df_filtrado is not None else session.df_original,
2027
+ coluna_y=coluna_y_knn,
2028
+ colunas_x=colunas_x,
2029
+ valores_x=entradas,
2030
+ alpha_geo=0.35,
2031
+ )
2032
+ resultado.update(resultado_knn)
2033
+
2034
  session.avaliacoes_elaboracao.append(resultado)
2035
  total_avaliacoes = len(session.avaliacoes_elaboracao)
2036
  idx_base, base = _resolver_indice_base(indice_base, total_avaliacoes, default_para_primeira=True)
 
2046
  }
2047
 
2048
 
2049
+ def detalhes_knn_avaliacao_elaboracao(session: SessionState, valores_x: dict[str, Any]) -> dict[str, Any]:
2050
+ if session.resultado_modelo is None:
2051
+ raise HTTPException(status_code=400, detail="Ajuste um modelo primeiro")
2052
+ if session.tabela_estatisticas is None:
2053
+ raise HTTPException(status_code=400, detail="Estatisticas indisponiveis")
2054
+
2055
+ colunas_x = list(session.resultado_modelo.get("colunas_x", []))
2056
+ if not colunas_x:
2057
+ raise HTTPException(status_code=400, detail="Modelo sem variaveis")
2058
+
2059
+ entradas: dict[str, float] = {}
2060
+ for col in colunas_x:
2061
+ if col not in valores_x:
2062
+ raise HTTPException(status_code=400, detail=f"Valor ausente para {col}")
2063
+ try:
2064
+ entradas[col] = float(valores_x[col])
2065
+ except Exception as exc:
2066
+ raise HTTPException(status_code=400, detail=f"Valor invalido para {col}") from exc
2067
+
2068
+ est = session.tabela_estatisticas
2069
+ if "Variável" in est.columns:
2070
+ est_idx = est.set_index("Variável")
2071
+ else:
2072
+ est_idx = est
2073
+
2074
+ for col in colunas_x:
2075
+ valor = entradas[col]
2076
+ if col in session.dicotomicas and valor not in (0, 0.0, 1, 1.0):
2077
+ raise HTTPException(status_code=400, detail=f"{col} aceita apenas 0 ou 1")
2078
+ if col in session.codigo_alocado and col in est_idx.index:
2079
+ min_v = float(est_idx.loc[col, "Mínimo"])
2080
+ max_v = float(est_idx.loc[col, "Máximo"])
2081
+ if _is_rh_col(col):
2082
+ if float(valor) != int(float(valor)):
2083
+ raise HTTPException(status_code=400, detail=f"{col} aceita apenas valores inteiros")
2084
+ elif float(valor) != int(float(valor)) or valor < min_v or valor > max_v:
2085
+ raise HTTPException(status_code=400, detail=f"{col} aceita inteiros de {int(min_v)} a {int(max_v)}")
2086
+ if col in session.percentuais and (valor < -PERCENTUAL_RUIDO_TOL or valor > 1 + PERCENTUAL_RUIDO_TOL):
2087
+ raise HTTPException(status_code=400, detail=f"{col} aceita valores entre 0 e 1")
2088
+
2089
+ df_knn = session.df_filtrado if session.df_filtrado is not None else session.df_original
2090
+ if not isinstance(df_knn, pd.DataFrame) or df_knn.empty:
2091
+ raise HTTPException(status_code=400, detail="Base de dados indisponivel para KNN.")
2092
+
2093
+ coluna_y_knn = str(session.coluna_y or session.resultado_modelo.get("coluna_y") or "").strip()
2094
+ resultado_knn = estimar_valor_knn_avaliacao(
2095
+ df_base=df_knn,
2096
+ coluna_y=coluna_y_knn,
2097
+ colunas_x=colunas_x,
2098
+ valores_x=entradas,
2099
+ alpha_geo=0.35,
2100
+ retornar_detalhes=True,
2101
+ )
2102
+
2103
+ vizinhos = resultado_knn.get("knn_vizinhos") or []
2104
+ posicoes_vizinhos = [
2105
+ int(item.get("posicao_base"))
2106
+ for item in vizinhos
2107
+ if item is not None and item.get("posicao_base") is not None
2108
+ ]
2109
+
2110
+ tabela_payload = None
2111
+ if posicoes_vizinhos:
2112
+ pares_validos: list[tuple[int, float | None]] = []
2113
+ for item in vizinhos:
2114
+ if not item:
2115
+ continue
2116
+ pos_raw = item.get("posicao_base")
2117
+ if pos_raw is None:
2118
+ continue
2119
+ pos = int(pos_raw)
2120
+ if pos < 0 or pos >= len(df_knn):
2121
+ continue
2122
+ distancia = item.get("distancia")
2123
+ pares_validos.append((pos, float(distancia) if distancia is not None else None))
2124
+
2125
+ if pares_validos:
2126
+ posicoes_validas = [pos for pos, _ in pares_validos]
2127
+ distancias_validas = [dist for _, dist in pares_validos]
2128
+ df_vizinhos = df_knn.iloc[posicoes_validas].copy()
2129
+ df_vizinhos.insert(0, "__ordem_knn__", list(range(1, len(df_vizinhos) + 1)))
2130
+ df_vizinhos.insert(1, "__distancia_knn__", distancias_validas)
2131
+ tabela_payload = dataframe_to_payload(df_vizinhos, decimals=4, max_rows=None)
2132
+
2133
+ # Import local evita acoplamento no carregamento inicial do módulo.
2134
+ from app.services.visualizacao_service import _criar_mapa_knn_destaque
2135
+
2136
+ mapa_html = _criar_mapa_knn_destaque(df_knn, posicoes_vizinhos, coluna_y_knn)
2137
+ avaliando = [{"variavel": str(col), "valor": entradas.get(col)} for col in colunas_x]
2138
+
2139
+ return sanitize_value(
2140
+ {
2141
+ "mapa_html": mapa_html,
2142
+ "avaliando": avaliando,
2143
+ "vizinhos_tabela": tabela_payload,
2144
+ "knn": resultado_knn,
2145
+ "legenda_mapa": {
2146
+ "mercado": "#4f6d8a",
2147
+ "selecionados_knn": "#d7263d",
2148
+ },
2149
+ }
2150
+ )
2151
+
2152
+
2153
  def limpar_avaliacoes_elaboracao(session: SessionState) -> dict[str, Any]:
2154
  session.avaliacoes_elaboracao = []
2155
  return {
backend/app/services/knn_avaliacao_service.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+
10
+ COORD_LAT_NAMES = {
11
+ "lat",
12
+ "latitude",
13
+ "siat_latitude",
14
+ }
15
+ COORD_LON_NAMES = {
16
+ "lon",
17
+ "long",
18
+ "longitude",
19
+ "siat_longitude",
20
+ }
21
+
22
+
23
+ def _normalizar_nome_coluna(value: Any) -> str:
24
+ return str(value or "").strip().lower()
25
+
26
+
27
+ def _primeira_serie_por_nome(df: pd.DataFrame, coluna: str) -> pd.Series | None:
28
+ if coluna not in df.columns:
29
+ return None
30
+ serie = df.loc[:, coluna]
31
+ if isinstance(serie, pd.DataFrame):
32
+ if serie.shape[1] == 0:
33
+ return None
34
+ return serie.iloc[:, 0]
35
+ return serie
36
+
37
+
38
+ def _detectar_coluna_coord(df: pd.DataFrame, candidatos: set[str]) -> str | None:
39
+ for coluna in df.columns:
40
+ if _normalizar_nome_coluna(coluna) in candidatos:
41
+ return str(coluna)
42
+ return None
43
+
44
+
45
+ def _coercer_float(value: Any) -> float | None:
46
+ try:
47
+ numero = float(value)
48
+ except Exception:
49
+ return None
50
+ if not np.isfinite(numero):
51
+ return None
52
+ return float(numero)
53
+
54
+
55
+ def _escala_robusta(valores: np.ndarray) -> float:
56
+ arr = np.asarray(valores, dtype=float)
57
+ arr = arr[np.isfinite(arr)]
58
+ if arr.size == 0:
59
+ return 1.0
60
+
61
+ med = float(np.median(arr))
62
+ mad = float(np.median(np.abs(arr - med)))
63
+ if np.isfinite(mad) and mad > 1e-9:
64
+ return float(mad * 1.4826)
65
+
66
+ std = float(np.std(arr))
67
+ if np.isfinite(std) and std > 1e-9:
68
+ return std
69
+
70
+ span = float(np.max(arr) - np.min(arr))
71
+ if np.isfinite(span) and span > 1e-9:
72
+ return span
73
+
74
+ return 1.0
75
+
76
+
77
+ def _normalizar_distancias(distancias: np.ndarray) -> np.ndarray:
78
+ arr = np.asarray(distancias, dtype=float)
79
+ positivos = arr[np.isfinite(arr) & (arr > 0)]
80
+ if positivos.size == 0:
81
+ escala = 1.0
82
+ else:
83
+ escala = float(np.median(positivos))
84
+ if not np.isfinite(escala) or escala <= 0:
85
+ escala = float(np.mean(positivos))
86
+ if not np.isfinite(escala) or escala <= 0:
87
+ escala = 1.0
88
+ return arr / escala
89
+
90
+
91
+ def _escala_iqr_robusta(valores: np.ndarray) -> float:
92
+ arr = np.asarray(valores, dtype=float)
93
+ arr = arr[np.isfinite(arr)]
94
+ if arr.size == 0:
95
+ return 1.0
96
+
97
+ q1, q3 = np.quantile(arr, [0.25, 0.75])
98
+ iqr = float(q3 - q1)
99
+ if np.isfinite(iqr) and iqr > 1e-9:
100
+ return iqr
101
+
102
+ return _escala_robusta(arr)
103
+
104
+
105
+ def _eh_coluna_binaria(valores: np.ndarray) -> bool:
106
+ arr = np.asarray(valores, dtype=float)
107
+ arr = arr[np.isfinite(arr)]
108
+ if arr.size == 0:
109
+ return False
110
+
111
+ unicos = np.unique(arr)
112
+ if unicos.size > 2:
113
+ return False
114
+
115
+ arredondados = np.round(unicos)
116
+ if np.max(np.abs(unicos - arredondados)) > 1e-9:
117
+ return False
118
+
119
+ return bool(np.all(np.isin(arredondados, np.asarray([0.0, 1.0]))))
120
+
121
+
122
+ def _haversine_km(lat: np.ndarray, lon: np.ndarray, lat_ref: float, lon_ref: float) -> np.ndarray:
123
+ raio_terra_km = 6371.0088
124
+
125
+ lat1 = np.radians(np.asarray(lat, dtype=float))
126
+ lon1 = np.radians(np.asarray(lon, dtype=float))
127
+ lat2 = math.radians(float(lat_ref))
128
+ lon2 = math.radians(float(lon_ref))
129
+
130
+ dlat = lat1 - lat2
131
+ dlon = lon1 - lon2
132
+ termo = np.sin(dlat / 2.0) ** 2 + np.cos(lat1) * math.cos(lat2) * (np.sin(dlon / 2.0) ** 2)
133
+ termo = np.clip(termo, 0.0, 1.0)
134
+ return 2.0 * raio_terra_km * np.arcsin(np.sqrt(termo))
135
+
136
+
137
+ def _resultado_indisponivel(motivo: str, alpha_geo: float) -> dict[str, Any]:
138
+ return {
139
+ "knn_estimado": None,
140
+ "knn_disponivel": False,
141
+ "knn_metodo": "indisponivel",
142
+ "knn_geo_aplicado": False,
143
+ "knn_k": None,
144
+ "knn_n_validos": 0,
145
+ "knn_alpha_geo": float(alpha_geo),
146
+ "knn_motivo": str(motivo or "").strip(),
147
+ "knn_colunas": [],
148
+ }
149
+
150
+
151
+ def estimar_valor_knn_avaliacao(
152
+ df_base: pd.DataFrame | None,
153
+ coluna_y: str,
154
+ colunas_x: list[str],
155
+ valores_x: dict[str, Any],
156
+ alpha_geo: float = 0.35,
157
+ min_k: int = 3,
158
+ max_k: int = 12,
159
+ retornar_detalhes: bool = False,
160
+ ) -> dict[str, Any]:
161
+ if df_base is None or not isinstance(df_base, pd.DataFrame) or df_base.empty:
162
+ return _resultado_indisponivel("Base de dados indisponivel para KNN.", alpha_geo)
163
+
164
+ if not coluna_y or coluna_y not in df_base.columns:
165
+ return _resultado_indisponivel("Variavel dependente indisponivel na base.", alpha_geo)
166
+
167
+ y_serie = _primeira_serie_por_nome(df_base, coluna_y)
168
+ if y_serie is None:
169
+ return _resultado_indisponivel("Variavel dependente indisponivel na base.", alpha_geo)
170
+
171
+ y_arr = pd.to_numeric(y_serie, errors="coerce").to_numpy(dtype=float)
172
+
173
+ colunas_validas: list[str] = []
174
+ matriz_colunas: list[np.ndarray] = []
175
+ query_vals: list[float] = []
176
+
177
+ for coluna in colunas_x:
178
+ if coluna not in df_base.columns:
179
+ continue
180
+ query_val = _coercer_float(valores_x.get(coluna))
181
+ if query_val is None:
182
+ continue
183
+
184
+ serie = _primeira_serie_por_nome(df_base, coluna)
185
+ if serie is None:
186
+ continue
187
+
188
+ valores_col = pd.to_numeric(serie, errors="coerce").to_numpy(dtype=float)
189
+ if not np.isfinite(valores_col).any():
190
+ continue
191
+
192
+ colunas_validas.append(str(coluna))
193
+ matriz_colunas.append(valores_col)
194
+ query_vals.append(float(query_val))
195
+
196
+ if not colunas_validas:
197
+ return _resultado_indisponivel(
198
+ "Nao foi possivel montar caracteristicas numericas validas para o KNN.",
199
+ alpha_geo,
200
+ )
201
+
202
+ mask_valid = np.isfinite(y_arr)
203
+ for valores_col in matriz_colunas:
204
+ mask_valid &= np.isfinite(valores_col)
205
+
206
+ if int(mask_valid.sum()) < 2:
207
+ return _resultado_indisponivel("Base com poucos registros validos para KNN.", alpha_geo)
208
+
209
+ feat_matrix = np.column_stack([valores_col[mask_valid] for valores_col in matriz_colunas]).astype(float)
210
+ y_valid = y_arr[mask_valid].astype(float)
211
+ query_vector = np.asarray(query_vals, dtype=float)
212
+ base_index = np.asarray(df_base.index, dtype=object)
213
+ pos_valid = np.flatnonzero(mask_valid)
214
+ idx_valid = base_index[pos_valid]
215
+
216
+ dist_partes: list[np.ndarray] = []
217
+ for idx_col in range(feat_matrix.shape[1]):
218
+ col = feat_matrix[:, idx_col]
219
+ query_val = float(query_vector[idx_col])
220
+
221
+ if _eh_coluna_binaria(col):
222
+ distancia_col = (np.round(col) != round(query_val)).astype(float)
223
+ else:
224
+ escala = _escala_iqr_robusta(col)
225
+ distancia_col = np.abs(col - query_val) / escala
226
+ distancia_col = np.clip(distancia_col, 0.0, 4.0)
227
+
228
+ dist_partes.append(distancia_col)
229
+
230
+ d_feat = np.mean(np.column_stack(dist_partes), axis=1)
231
+ d_feat_norm = _normalizar_distancias(d_feat)
232
+
233
+ lat_col = _detectar_coluna_coord(df_base, COORD_LAT_NAMES)
234
+ lon_col = _detectar_coluna_coord(df_base, COORD_LON_NAMES)
235
+
236
+ valores_x_norm = {_normalizar_nome_coluna(k): v for k, v in (valores_x or {}).items()}
237
+ lat_query = None
238
+ lon_query = None
239
+ for nome in COORD_LAT_NAMES:
240
+ if nome in valores_x_norm:
241
+ lat_query = _coercer_float(valores_x_norm.get(nome))
242
+ if lat_query is not None:
243
+ break
244
+ for nome in COORD_LON_NAMES:
245
+ if nome in valores_x_norm:
246
+ lon_query = _coercer_float(valores_x_norm.get(nome))
247
+ if lon_query is not None:
248
+ break
249
+
250
+ usa_geo = False
251
+ motivo_geo = ""
252
+ d_total = d_feat_norm
253
+ y_final = y_valid
254
+ pos_final = pos_valid
255
+ idx_final = idx_valid
256
+
257
+ if lat_col and lon_col and lat_query is not None and lon_query is not None:
258
+ lat_serie = _primeira_serie_por_nome(df_base, lat_col)
259
+ lon_serie = _primeira_serie_por_nome(df_base, lon_col)
260
+ if lat_serie is not None and lon_serie is not None:
261
+ lat_vals = pd.to_numeric(lat_serie, errors="coerce").to_numpy(dtype=float)[mask_valid]
262
+ lon_vals = pd.to_numeric(lon_serie, errors="coerce").to_numpy(dtype=float)[mask_valid]
263
+ mask_geo = (
264
+ np.isfinite(lat_vals)
265
+ & np.isfinite(lon_vals)
266
+ & (np.abs(lat_vals) <= 90.0)
267
+ & (np.abs(lon_vals) <= 180.0)
268
+ )
269
+
270
+ if int(mask_geo.sum()) >= 3:
271
+ d_geo = _haversine_km(lat_vals[mask_geo], lon_vals[mask_geo], lat_query, lon_query)
272
+ d_geo_norm = _normalizar_distancias(d_geo)
273
+ d_feat_geo = d_feat_norm[mask_geo]
274
+ d_total = np.sqrt((1.0 - alpha_geo) * (d_feat_geo ** 2) + alpha_geo * (d_geo_norm ** 2))
275
+ y_final = y_valid[mask_geo]
276
+ pos_final = pos_valid[mask_geo]
277
+ idx_final = idx_valid[mask_geo]
278
+ usa_geo = True
279
+ else:
280
+ motivo_geo = "Base com poucas coordenadas validas para aplicar componente geografica."
281
+ else:
282
+ motivo_geo = "Base sem coordenadas validas para aplicar componente geografica."
283
+ elif not (lat_col and lon_col):
284
+ motivo_geo = "Base sem colunas de coordenadas para componente geografica."
285
+ else:
286
+ motivo_geo = "Avaliando sem coordenadas informadas; KNN aplicado apenas por caracteristicas."
287
+
288
+ n_validos = int(len(y_final))
289
+ if n_validos <= 0:
290
+ return _resultado_indisponivel("Base sem registros validos para KNN.", alpha_geo)
291
+
292
+ k_dinamico = int(round(math.sqrt(n_validos)))
293
+ k = max(int(min_k), k_dinamico)
294
+ k = min(int(max_k), k, n_validos)
295
+ if k <= 0:
296
+ return _resultado_indisponivel("Nao foi possivel definir K valido para KNN.", alpha_geo)
297
+
298
+ ordem = np.argsort(d_total)
299
+ idx_vizinhos = ordem[:k]
300
+ y_vizinhos = y_final[idx_vizinhos]
301
+ d_vizinhos = d_total[idx_vizinhos]
302
+
303
+ mask_zero = d_vizinhos <= 1e-12
304
+ if np.any(mask_zero):
305
+ estimado = float(np.mean(y_vizinhos[mask_zero]))
306
+ else:
307
+ pesos = 1.0 / ((d_vizinhos + 1e-9) ** 2)
308
+ estimado = float(np.sum(pesos * y_vizinhos) / np.sum(pesos))
309
+
310
+ if not np.isfinite(estimado):
311
+ return _resultado_indisponivel("Estimativa KNN invalida apos calculo.", alpha_geo)
312
+
313
+ if usa_geo:
314
+ metodo = "caracteristicas_localizacao"
315
+ motivo = ""
316
+ else:
317
+ metodo = "caracteristicas"
318
+ motivo = motivo_geo or "Componente geografica nao aplicada."
319
+
320
+ resultado = {
321
+ "knn_estimado": estimado,
322
+ "knn_disponivel": True,
323
+ "knn_metodo": metodo,
324
+ "knn_geo_aplicado": bool(usa_geo),
325
+ "knn_k": int(k),
326
+ "knn_n_validos": int(n_validos),
327
+ "knn_alpha_geo": float(alpha_geo),
328
+ "knn_motivo": str(motivo or "").strip(),
329
+ "knn_colunas": colunas_validas,
330
+ }
331
+
332
+ if retornar_detalhes:
333
+ pos_vizinhos = pos_final[idx_vizinhos]
334
+ idx_base_vizinhos = idx_final[idx_vizinhos]
335
+ detalhes_vizinhos: list[dict[str, Any]] = []
336
+ for ordem_vizinho, (pos, idx_base, dist, y_viz) in enumerate(
337
+ zip(pos_vizinhos, idx_base_vizinhos, d_vizinhos, y_vizinhos, strict=False),
338
+ start=1,
339
+ ):
340
+ detalhes_vizinhos.append(
341
+ {
342
+ "ordem": int(ordem_vizinho),
343
+ "posicao_base": int(pos),
344
+ "indice_base": idx_base.item() if isinstance(idx_base, np.generic) else idx_base,
345
+ "distancia": float(dist),
346
+ "valor_y": float(y_viz),
347
+ }
348
+ )
349
+ resultado["knn_vizinhos"] = detalhes_vizinhos
350
+
351
+ return resultado
backend/app/services/visualizacao_service.py CHANGED
@@ -3,28 +3,166 @@ from __future__ import annotations
3
  from pathlib import Path
4
  from typing import Any
5
 
 
6
  import numpy as np
7
  import pandas as pd
8
  from fastapi import HTTPException
 
9
  from joblib import load
10
 
11
  from app.core.visualizacao import app as viz_app
 
12
  from app.core.elaboracao.core import PERCENTUAL_RUIDO_TOL, _migrar_pacote_v1_para_v2, avaliar_imovel, exportar_avaliacoes_excel
13
  from app.core.elaboracao.formatadores import formatar_avaliacao_html
14
  from app.models.session import SessionState
15
  from app.services import model_repository
16
  from app.services.equacao_service import build_equacoes_payload, exportar_planilha_equacao
 
17
  from app.services.serializers import dataframe_to_payload, figure_to_payload, sanitize_value
18
 
19
 
20
  CHAVES_ESPERADAS = ["versao", "dados", "transformacoes", "modelo"]
21
  BASE_COMPARACAO_SEM_BASE = "__none__"
 
 
22
 
23
 
24
  def _is_rh_col(coluna: str) -> bool:
25
  return str(coluna or "").strip().upper() == "RH"
26
 
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  def _resolver_indice_base(
29
  indice_base_raw: str | None,
30
  total_avaliacoes: int,
@@ -366,6 +504,18 @@ def calcular_avaliacao(session: SessionState, valores_x: dict[str, Any], indice_
366
  if resultado is None:
367
  raise HTTPException(status_code=400, detail="Erro ao calcular avaliacao")
368
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  session.avaliacoes_visualizacao.append(resultado)
370
 
371
  total_avaliacoes = len(session.avaliacoes_visualizacao)
@@ -381,6 +531,108 @@ def calcular_avaliacao(session: SessionState, valores_x: dict[str, Any], indice_
381
  }
382
 
383
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  def limpar_avaliacoes(session: SessionState) -> dict[str, Any]:
385
  session.avaliacoes_visualizacao = []
386
  return {
 
3
  from pathlib import Path
4
  from typing import Any
5
 
6
+ import folium
7
  import numpy as np
8
  import pandas as pd
9
  from fastapi import HTTPException
10
+ from folium import plugins
11
  from joblib import load
12
 
13
  from app.core.visualizacao import app as viz_app
14
+ from app.core.map_layers import add_bairros_layer, add_popup_pagination_handlers, add_zoom_responsive_circle_markers
15
  from app.core.elaboracao.core import PERCENTUAL_RUIDO_TOL, _migrar_pacote_v1_para_v2, avaliar_imovel, exportar_avaliacoes_excel
16
  from app.core.elaboracao.formatadores import formatar_avaliacao_html
17
  from app.models.session import SessionState
18
  from app.services import model_repository
19
  from app.services.equacao_service import build_equacoes_payload, exportar_planilha_equacao
20
+ from app.services.knn_avaliacao_service import estimar_valor_knn_avaliacao
21
  from app.services.serializers import dataframe_to_payload, figure_to_payload, sanitize_value
22
 
23
 
24
  CHAVES_ESPERADAS = ["versao", "dados", "transformacoes", "modelo"]
25
  BASE_COMPARACAO_SEM_BASE = "__none__"
26
+ COORD_LAT_NAMES = {"lat", "latitude", "siat_latitude"}
27
+ COORD_LON_NAMES = {"lon", "long", "longitude", "siat_longitude"}
28
 
29
 
30
  def _is_rh_col(coluna: str) -> bool:
31
  return str(coluna or "").strip().upper() == "RH"
32
 
33
 
34
+ def _normalizar_nome_coluna(value: Any) -> str:
35
+ return str(value or "").strip().lower()
36
+
37
+
38
+ def _detectar_coluna_coord(df: pd.DataFrame, candidatos: set[str]) -> str | None:
39
+ for coluna in df.columns:
40
+ if _normalizar_nome_coluna(coluna) in candidatos:
41
+ return str(coluna)
42
+ return None
43
+
44
+
45
+ def _primeira_serie_por_nome(df: pd.DataFrame, coluna: str) -> pd.Series | None:
46
+ if coluna not in df.columns:
47
+ return None
48
+ serie = df.loc[:, coluna]
49
+ if isinstance(serie, pd.DataFrame):
50
+ if serie.shape[1] == 0:
51
+ return None
52
+ return serie.iloc[:, 0]
53
+ return serie
54
+
55
+
56
+ def _formatar_tooltip_valor(coluna: str, valor: Any) -> str:
57
+ if valor is None or (isinstance(valor, float) and not np.isfinite(valor)):
58
+ return "—"
59
+ nome = str(coluna or "").strip().lower()
60
+ if isinstance(valor, (int, float, np.integer, np.floating)):
61
+ numero = float(valor)
62
+ if not np.isfinite(numero):
63
+ return "—"
64
+ if any(chave in nome for chave in ["valor", "preco", "vu", "vunit"]):
65
+ return f"R$ {numero:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
66
+ if "lat" in nome or "lon" in nome:
67
+ return f"{numero:.6f}"
68
+ return f"{numero:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
69
+ return str(valor)
70
+
71
+
72
+ def _criar_mapa_knn_destaque(df_base: pd.DataFrame, posicoes_knn: list[int], coluna_y: str) -> str:
73
+ if df_base is None or df_base.empty:
74
+ return "<p>Base de dados indisponivel para mapa KNN.</p>"
75
+
76
+ lat_col = _detectar_coluna_coord(df_base, COORD_LAT_NAMES)
77
+ lon_col = _detectar_coluna_coord(df_base, COORD_LON_NAMES)
78
+ if not lat_col or not lon_col:
79
+ return "<p>Modelo sem colunas de coordenadas para exibir o mapa KNN.</p>"
80
+
81
+ lat_serie = _primeira_serie_por_nome(df_base, lat_col)
82
+ lon_serie = _primeira_serie_por_nome(df_base, lon_col)
83
+ if lat_serie is None or lon_serie is None:
84
+ return "<p>Coordenadas indisponiveis para o mapa KNN.</p>"
85
+
86
+ dados = df_base.copy()
87
+ dados["__pos_base__"] = np.arange(len(dados), dtype=int)
88
+ dados["__indice_base__"] = [str(v) for v in dados.index]
89
+ dados["__lat__"] = pd.to_numeric(lat_serie, errors="coerce")
90
+ dados["__lon__"] = pd.to_numeric(lon_serie, errors="coerce")
91
+ dados = dados[
92
+ np.isfinite(dados["__lat__"])
93
+ & np.isfinite(dados["__lon__"])
94
+ & (np.abs(dados["__lat__"]) <= 90.0)
95
+ & (np.abs(dados["__lon__"]) <= 180.0)
96
+ ].copy()
97
+
98
+ if dados.empty:
99
+ return "<p>Sem coordenadas validas para exibir o mapa KNN.</p>"
100
+
101
+ centro_lat = float(dados["__lat__"].median())
102
+ centro_lon = float(dados["__lon__"].median())
103
+
104
+ mapa = folium.Map(
105
+ location=[centro_lat, centro_lon],
106
+ zoom_start=12,
107
+ tiles=None,
108
+ prefer_canvas=True,
109
+ control_scale=True,
110
+ )
111
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(mapa)
112
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
113
+ add_bairros_layer(mapa, show=True)
114
+
115
+ posicoes_set = {int(v) for v in (posicoes_knn or [])}
116
+ camada_mercado = folium.FeatureGroup(name="Mercado (base completa)", show=True)
117
+ camada_knn = folium.FeatureGroup(name="Selecionados KNN", show=True)
118
+
119
+ for _, row in dados.iterrows():
120
+ pos = int(row["__pos_base__"])
121
+ selecionado = pos in posicoes_set
122
+ col_y_val = row.get(coluna_y)
123
+ valor_tooltip = _formatar_tooltip_valor(coluna_y, col_y_val)
124
+ tooltip_html = (
125
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.5;'>"
126
+ f"<b>Indice {row['__indice_base__']}</b>"
127
+ f"<br><span style='color:#555;'>{coluna_y}:</span> <b>{valor_tooltip}</b>"
128
+ "</div>"
129
+ )
130
+
131
+ marcador = folium.CircleMarker(
132
+ location=[float(row["__lat__"]), float(row["__lon__"])],
133
+ radius=8 if selecionado else 5,
134
+ tooltip=folium.Tooltip(tooltip_html, sticky=True),
135
+ color="#ffffff",
136
+ weight=0.9,
137
+ fill=True,
138
+ fillColor="#d7263d" if selecionado else "#4f6d8a",
139
+ fillOpacity=0.92 if selecionado else 0.52,
140
+ )
141
+ marcador.options["mesaBaseRadius"] = 8.0 if selecionado else 5.0
142
+ (camada_knn if selecionado else camada_mercado).add_child(marcador)
143
+
144
+ camada_mercado.add_to(mapa)
145
+ camada_knn.add_to(mapa)
146
+ folium.LayerControl().add_to(mapa)
147
+ plugins.Fullscreen().add_to(mapa)
148
+ add_zoom_responsive_circle_markers(mapa)
149
+ add_popup_pagination_handlers(mapa)
150
+
151
+ lat_min = float(dados["__lat__"].quantile(0.01))
152
+ lat_max = float(dados["__lat__"].quantile(0.99))
153
+ lon_min = float(dados["__lon__"].quantile(0.01))
154
+ lon_max = float(dados["__lon__"].quantile(0.99))
155
+ if np.isclose(lat_min, lat_max):
156
+ lat_min -= 0.0008
157
+ lat_max += 0.0008
158
+ if np.isclose(lon_min, lon_max):
159
+ lon_min -= 0.0008
160
+ lon_max += 0.0008
161
+ mapa.fit_bounds([[lat_min, lon_min], [lat_max, lon_max]], padding=(46, 46), max_zoom=18)
162
+
163
+ return mapa.get_root().render()
164
+
165
+
166
  def _resolver_indice_base(
167
  indice_base_raw: str | None,
168
  total_avaliacoes: int,
 
504
  if resultado is None:
505
  raise HTTPException(status_code=400, detail="Erro ao calcular avaliacao")
506
 
507
+ df_knn = pacote.get("dados", {}).get("df")
508
+ if not isinstance(df_knn, pd.DataFrame):
509
+ df_knn = pd.DataFrame(df_knn)
510
+ resultado_knn = estimar_valor_knn_avaliacao(
511
+ df_base=df_knn,
512
+ coluna_y=info["nome_y"],
513
+ colunas_x=colunas_x,
514
+ valores_x=entradas,
515
+ alpha_geo=0.35,
516
+ )
517
+ resultado.update(resultado_knn)
518
+
519
  session.avaliacoes_visualizacao.append(resultado)
520
 
521
  total_avaliacoes = len(session.avaliacoes_visualizacao)
 
531
  }
532
 
533
 
534
+ def detalhes_knn_avaliacao(session: SessionState, valores_x: dict[str, Any]) -> dict[str, Any]:
535
+ pacote = session.pacote_visualizacao
536
+ if pacote is None:
537
+ raise HTTPException(status_code=400, detail="Carregue um modelo primeiro")
538
+
539
+ info = _extrair_modelo_info(pacote)
540
+ colunas_x = info["colunas_x"]
541
+ entradas: dict[str, float] = {}
542
+ for col in colunas_x:
543
+ if col not in valores_x:
544
+ raise HTTPException(status_code=400, detail=f"Valor ausente para {col}")
545
+ try:
546
+ entradas[col] = float(valores_x[col])
547
+ except Exception as exc:
548
+ raise HTTPException(status_code=400, detail=f"Valor invalido para {col}") from exc
549
+
550
+ estatisticas_df = pacote["dados"]["estatisticas"]
551
+ if isinstance(estatisticas_df, pd.DataFrame):
552
+ est_df = estatisticas_df
553
+ else:
554
+ est_df = pd.DataFrame(estatisticas_df)
555
+
556
+ if "Variável" in est_df.columns:
557
+ est_idx = est_df.set_index("Variável")
558
+ else:
559
+ est_idx = est_df
560
+
561
+ for col in colunas_x:
562
+ valor = entradas[col]
563
+ if col in info["dicotomicas"] and valor not in (0, 0.0, 1, 1.0):
564
+ raise HTTPException(status_code=400, detail=f"{col} aceita apenas 0 ou 1")
565
+ if col in info["codigo_alocado"] and col in est_idx.index:
566
+ min_v = float(est_idx.loc[col, "Mínimo"])
567
+ max_v = float(est_idx.loc[col, "Máximo"])
568
+ if _is_rh_col(col):
569
+ if float(valor) != int(float(valor)):
570
+ raise HTTPException(status_code=400, detail=f"{col} aceita apenas valores inteiros")
571
+ elif float(valor) != int(float(valor)) or valor < min_v or valor > max_v:
572
+ raise HTTPException(status_code=400, detail=f"{col} aceita inteiros de {int(min_v)} a {int(max_v)}")
573
+ if col in info["percentuais"] and (valor < -PERCENTUAL_RUIDO_TOL or valor > 1 + PERCENTUAL_RUIDO_TOL):
574
+ raise HTTPException(status_code=400, detail=f"{col} aceita valores entre 0 e 1")
575
+
576
+ df_knn = pacote.get("dados", {}).get("df")
577
+ if not isinstance(df_knn, pd.DataFrame):
578
+ df_knn = pd.DataFrame(df_knn)
579
+
580
+ resultado_knn = estimar_valor_knn_avaliacao(
581
+ df_base=df_knn,
582
+ coluna_y=info["nome_y"],
583
+ colunas_x=colunas_x,
584
+ valores_x=entradas,
585
+ alpha_geo=0.35,
586
+ retornar_detalhes=True,
587
+ )
588
+
589
+ vizinhos = resultado_knn.get("knn_vizinhos") or []
590
+ posicoes_vizinhos = [
591
+ int(item.get("posicao_base"))
592
+ for item in vizinhos
593
+ if item is not None and item.get("posicao_base") is not None
594
+ ]
595
+
596
+ tabela_payload = None
597
+ if posicoes_vizinhos:
598
+ pares_validos: list[tuple[int, float | None]] = []
599
+ for item in vizinhos:
600
+ if not item:
601
+ continue
602
+ pos_raw = item.get("posicao_base")
603
+ if pos_raw is None:
604
+ continue
605
+ pos = int(pos_raw)
606
+ if pos < 0 or pos >= len(df_knn):
607
+ continue
608
+ distancia = item.get("distancia")
609
+ pares_validos.append((pos, float(distancia) if distancia is not None else None))
610
+
611
+ if pares_validos:
612
+ posicoes_validas = [pos for pos, _ in pares_validos]
613
+ distancias_validas = [dist for _, dist in pares_validos]
614
+ df_vizinhos = df_knn.iloc[posicoes_validas].copy()
615
+ df_vizinhos.insert(0, "__ordem_knn__", list(range(1, len(df_vizinhos) + 1)))
616
+ df_vizinhos.insert(1, "__distancia_knn__", distancias_validas)
617
+ tabela_payload = dataframe_to_payload(df_vizinhos, decimals=4, max_rows=None)
618
+
619
+ mapa_html = _criar_mapa_knn_destaque(df_knn, posicoes_vizinhos, info["nome_y"])
620
+ avaliando = [{"variavel": str(col), "valor": entradas.get(col)} for col in colunas_x]
621
+
622
+ return sanitize_value(
623
+ {
624
+ "mapa_html": mapa_html,
625
+ "avaliando": avaliando,
626
+ "vizinhos_tabela": tabela_payload,
627
+ "knn": resultado_knn,
628
+ "legenda_mapa": {
629
+ "mercado": "#4f6d8a",
630
+ "selecionados_knn": "#d7263d",
631
+ },
632
+ }
633
+ )
634
+
635
+
636
  def limpar_avaliacoes(session: SessionState) -> dict[str, Any]:
637
  session.avaliacoes_visualizacao = []
638
  return {
frontend/src/api.js CHANGED
@@ -253,6 +253,10 @@ export const api = {
253
  valores_x: valoresX,
254
  indice_base: indiceBase,
255
  }),
 
 
 
 
256
  evaluationClearElab: (sessionId) => postJson('/api/elaboracao/evaluation/clear', { session_id: sessionId }),
257
  evaluationDeleteElab: (sessionId, indice, indiceBase) => postJson('/api/elaboracao/evaluation/delete', {
258
  session_id: sessionId,
@@ -309,6 +313,10 @@ export const api = {
309
  valores_x: valoresX,
310
  indice_base: indiceBase,
311
  }),
 
 
 
 
312
  evaluationClearViz: (sessionId) => postJson('/api/visualizacao/evaluation/clear', { session_id: sessionId }),
313
  evaluationDeleteViz: (sessionId, indice, indiceBase) => postJson('/api/visualizacao/evaluation/delete', {
314
  session_id: sessionId,
 
253
  valores_x: valoresX,
254
  indice_base: indiceBase,
255
  }),
256
+ evaluationKnnDetailsElab: (sessionId, valoresX) => postJson('/api/elaboracao/evaluation/knn-details', {
257
+ session_id: sessionId,
258
+ valores_x: valoresX,
259
+ }),
260
  evaluationClearElab: (sessionId) => postJson('/api/elaboracao/evaluation/clear', { session_id: sessionId }),
261
  evaluationDeleteElab: (sessionId, indice, indiceBase) => postJson('/api/elaboracao/evaluation/delete', {
262
  session_id: sessionId,
 
313
  valores_x: valoresX,
314
  indice_base: indiceBase,
315
  }),
316
+ evaluationKnnDetailsViz: (sessionId, valoresX) => postJson('/api/visualizacao/evaluation/knn-details', {
317
+ session_id: sessionId,
318
+ valores_x: valoresX,
319
+ }),
320
  evaluationClearViz: (sessionId) => postJson('/api/visualizacao/evaluation/clear', { session_id: sessionId }),
321
  evaluationDeleteViz: (sessionId, indice, indiceBase) => postJson('/api/visualizacao/evaluation/delete', {
322
  session_id: sessionId,
frontend/src/components/AvaliacaoTab.jsx CHANGED
@@ -1,6 +1,7 @@
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
  import LoadingOverlay from './LoadingOverlay'
 
4
  import SinglePillAutocomplete from './SinglePillAutocomplete'
5
 
6
  function normalizarChaveModelo(value) {
@@ -33,6 +34,46 @@ function formatarDataHoraIso(iso) {
33
  return data.toLocaleString('pt-BR')
34
  }
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  function formatarExtrapoladasNaFronteira(aval) {
37
  const qtdExtrapolacoes = Number(aval?.qtd_extrapolacoes ?? 0)
38
  const houveExtrapolacao = Boolean(aval?.houve_extrapolacao) || qtdExtrapolacoes > 0
@@ -53,15 +94,33 @@ function formatarExtrapoladasNaFronteiraCsv(aval) {
53
  return formatarNumero(fronteira, 2)
54
  }
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  function classificarExtrapolacao(info) {
57
  const status = String(info?.status || 'ok')
58
  const percentual = Number(info?.percentual || 0)
59
  const direcao = String(info?.direcao || '')
60
  const seta = direcao === 'acima' ? '↑' : (direcao === 'abaixo' ? '↓' : '')
61
- if (status === 'ok') return 'ok'
62
- if (status === 'warning') return `⚠️${seta} ${formatarNumero(percentual, 1)}%`
63
- if (status === 'grave') return `❌${seta} ${formatarNumero(percentual, 1)}%`
64
  if (status === 'dicotomica' || status === 'codigo_alocado' || status === 'percentual') return '—'
 
 
 
 
 
 
65
  return status
66
  }
67
 
@@ -74,13 +133,6 @@ function escaparHtml(value) {
74
  .replaceAll("'", '&#39;')
75
  }
76
 
77
- const CORES_GRAU = {
78
- 'Grau III': '#28a745',
79
- 'Grau II': '#17a2b8',
80
- 'Grau I': '#e67e00',
81
- 'Sem enquadramento': '#dc3545',
82
- }
83
-
84
  function normalizarGrau(value) {
85
  const texto = String(value || '').toLowerCase()
86
  if (texto.includes('grau iii')) return 'Grau III'
@@ -90,31 +142,26 @@ function normalizarGrau(value) {
90
  return String(value || '-')
91
  }
92
 
93
- function corGrau(valor) {
94
- const grau = normalizarGrau(valor)
95
- return CORES_GRAU[grau] || '#495057'
96
- }
97
-
98
  function popupPrecisaoHtml(aval) {
99
  const amplitude = Number(aval?.amplitude)
100
  const amplitudeTexto = Number.isFinite(amplitude) ? `${formatarNumero(amplitude, 1)}%` : '—'
101
  const grau = normalizarGrau(aval?.precisao)
102
  const regras = [
103
- ['≤30%', 'Grau III', '#28a745'],
104
- ['≤40%', 'Grau II', '#17a2b8'],
105
- ['≤50%', 'Grau I', '#e67e00'],
106
- ['>50%', 'Sem enquadramento', '#dc3545'],
107
  ]
108
 
109
- const linhas = regras.map(([faixa, nome, cor]) => {
110
  const ativo = nome === grau
111
- const bg = ativo ? `background: ${cor}11;` : ''
112
  const fw = ativo ? 'font-weight: 600;' : ''
113
  const marca = ativo ? ' ◀' : ''
114
  return (
115
  `<tr style="border-bottom: 1px solid #f0f0f0; ${bg}">`
116
  + `<td style="padding: 2px 6px; ${fw}">${faixa}</td>`
117
- + `<td style="padding: 2px 6px; color: ${cor}; ${fw}">${nome}${marca}</td>`
118
  + '</tr>'
119
  )
120
  }).join('')
@@ -139,7 +186,6 @@ function popupPrecisaoHtml(aval) {
139
 
140
  function popupFundamentacaoHtml(aval) {
141
  const grau = normalizarGrau(aval?.fundamentacao)
142
- const corGrauAtual = CORES_GRAU[grau] || '#495057'
143
  const qtdExtrapolacoes = Number(aval?.qtd_extrapolacoes ?? 0)
144
  const houveExtrapolacao = Boolean(aval?.houve_extrapolacao) || qtdExtrapolacoes > 0
145
  const percExt = Number(aval?.perc_ext)
@@ -164,7 +210,7 @@ function popupFundamentacaoHtml(aval) {
164
  if (!houveExtrapolacao) {
165
  return (
166
  header
167
- + `<div>Nenhuma variável extrapolou os limites amostrais. <span style="color: #28a745; font-weight: 600;">→ ${grau}</span></div>`
168
  )
169
  }
170
 
@@ -208,7 +254,7 @@ function popupFundamentacaoHtml(aval) {
208
  ]
209
  const tabelaRegras = regras.map(([condicao, nome]) => {
210
  const ativo = nome === grau || (nome === 'Sem enq.' && grau === 'Sem enquadramento')
211
- const bg = ativo ? `background: ${corGrauAtual}11;` : ''
212
  const fw = ativo ? 'font-weight: 600;' : ''
213
  const marca = ativo ? ' ◀' : ''
214
  return (
@@ -226,7 +272,7 @@ function popupFundamentacaoHtml(aval) {
226
  header
227
  + `<div style="margin-bottom: 3px;">${resumo}</div>`
228
  + `<div style="background: #f8f9fa; border-radius: 4px; padding: 4px 8px; margin-bottom: 4px; font-size: 11px;">${varsStr}</div>`
229
- + `<div style="margin-bottom: 4px; font-size: 11px; color: #495057;">${motivo} <span style="color: ${corGrauAtual}; font-weight: 600;">→ ${grau}</span></div>`
230
  + '<table style="width: 100%; border-collapse: collapse; font-size: 11px;">'
231
  + '<tr style="border-bottom: 1px solid #e9ecef;">'
232
  + '<th style="text-align: left; padding: 2px 6px; color: #6c757d;">Condição</th>'
@@ -329,7 +375,16 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
329
  const [avaliacoesCards, setAvaliacoesCards] = useState([])
330
  const [baseCardId, setBaseCardId] = useState(BASE_COMPARACAO_SEM_BASE)
331
  const [confirmarLimpezaAvaliacoes, setConfirmarLimpezaAvaliacoes] = useState(false)
332
- const [avaliacaoPopupHtml, setAvaliacaoPopupHtml] = useState('')
 
 
 
 
 
 
 
 
 
333
 
334
  const uploadInputRef = useRef(null)
335
  const quickLoadHandledRef = useRef('')
@@ -521,12 +576,15 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
521
  }
522
  }, [avaliacoesCards, baseCardId])
523
 
 
 
 
 
 
 
 
524
  function resetCamposAvaliacao(campos = camposAvaliacao) {
525
- const limpo = {}
526
- ;(campos || []).forEach((campo) => {
527
- limpo[campo.coluna] = ''
528
- })
529
- valoresAvaliacaoRef.current = limpo
530
  setAvaliacaoFormVersion((prev) => prev + 1)
531
  }
532
 
@@ -674,6 +732,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
674
  }
675
  setAvaliacoesCards((prev) => [...prev, card])
676
  setBaseCardId((prev) => prev || card.id)
 
677
  setConfirmarLimpezaAvaliacoes(false)
678
  })
679
  }
@@ -682,22 +741,96 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
682
  resetCamposAvaliacao()
683
  }
684
 
685
- function onExcluirCard(cardId) {
 
 
 
 
686
  setAvaliacoesCards((prev) => prev.filter((item) => item.id !== cardId))
 
 
 
 
 
687
  }
688
 
689
  function onLimparAvaliacoes() {
690
  setAvaliacoesCards([])
691
  setBaseCardId(BASE_COMPARACAO_SEM_BASE)
 
692
  setConfirmarLimpezaAvaliacoes(false)
693
  }
694
 
695
- function onPopupEnter(html) {
696
- setAvaliacaoPopupHtml(String(html || ''))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  }
698
 
699
  function onPopupLeave() {
700
- setAvaliacaoPopupHtml('')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
701
  }
702
 
703
  function calcularComparacaoBase(avaliacao) {
@@ -735,6 +868,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
735
  'Fundamentacao',
736
  'QtdExtrapolacoes',
737
  'ExtrapoladasNaFronteira',
 
738
  ...variaveis.map((item) => `X_${item}`),
739
  ]
740
  const linhas = [cabecalho.join(';')]
@@ -758,6 +892,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
758
  String(aval.fundamentacao || ''),
759
  String(aval.qtd_extrapolacoes ?? ''),
760
  formatarExtrapoladasNaFronteiraCsv(aval),
 
761
  ]
762
  const camposVars = variaveis.map((variavel) => {
763
  const valor = aval?.valores_x?.[variavel]
@@ -771,6 +906,10 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
771
  }
772
 
773
  const modeloPronto = Boolean(camposAvaliacao.length)
 
 
 
 
774
 
775
  return (
776
  <div className="tab-content">
@@ -889,12 +1028,11 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
889
  <label>{campo.coluna}</label>
890
  {campo.tipo === 'dicotomica' ? (
891
  <select
892
- defaultValue={String(valoresAvaliacaoRef.current[campo.coluna] ?? '')}
893
  onChange={(event) => {
894
  valoresAvaliacaoRef.current[campo.coluna] = event.target.value
895
  }}
896
  >
897
- <option value="">Selecione</option>
898
  {(campo.opcoes || [0, 1]).map((opcao) => (
899
  <option key={`op-avaliacao-${campo.coluna}-${opcao}`} value={String(opcao)}>
900
  {opcao}
@@ -966,6 +1104,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
966
  {avaliacoesCards.map((item, idx) => {
967
  const aval = item.avaliacao || {}
968
  const ehBase = item.id === baseCardId
 
969
  const variaveis = Object.keys(aval.valores_x || {})
970
  return (
971
  <article key={item.id} className="avaliacao-modelos-card">
@@ -975,9 +1114,35 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
975
  <span>{item.modelo}</span>
976
  </div>
977
  <div className="avaliacao-modelos-card-actions">
978
- <button type="button" className="avaliacao-modelos-delete-btn" onClick={() => onExcluirCard(item.id)} disabled={loading}>
979
- Excluir
980
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
981
  </div>
982
  </div>
983
 
@@ -985,79 +1150,116 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
985
  {formatarDataHoraIso(item.createdAt)}
986
  </div>
987
 
988
- <div className="avaliacao-modelos-card-base">
989
- <strong>Estimado / Base:</strong>{' '}
990
- {ehBase ? (
991
- <span className="avaliacao-modelos-base-pill">Base</span>
992
- ) : (
993
- <span>{calcularComparacaoBase(aval)}</span>
994
- )}
995
- </div>
996
-
997
- <div className="avaliacao-modelos-vars-list">
998
  {variaveis.map((variavel) => (
999
- <div key={`${item.id}-var-${variavel}`} className="avaliacao-modelos-vars-item">
1000
  <span>{variavel}</span>
1001
  <span>
1002
  {formatarNumero(aval?.valores_x?.[variavel], 2)} {classificarExtrapolacao(aval?.extrapolacoes?.[variavel])}
1003
  </span>
1004
  </div>
1005
  ))}
1006
- </div>
1007
 
1008
- <div className="avaliacao-modelos-metrics">
1009
- <div><strong>Estimado:</strong> {formatarMoeda(aval.estimado)}</div>
1010
- <div>
1011
- <strong>Extrapoladas na fronteira:</strong> {formatarExtrapoladasNaFronteira(aval)}{' '}
1012
- <button
1013
- type="button"
1014
- className="avaliacao-popup-trigger"
1015
- aria-label="Detalhes de extrapoladas na fronteira"
1016
- onMouseEnter={() => onPopupEnter(popupFronteiraHtml(aval))}
1017
- onMouseLeave={onPopupLeave}
1018
- onFocus={() => onPopupEnter(popupFronteiraHtml(aval))}
1019
- onBlur={onPopupLeave}
1020
- >
1021
-
1022
- </button>
 
 
 
 
 
 
1023
  </div>
1024
- <div><strong>CA -15%:</strong> {formatarMoeda(aval.ca_inf)}</div>
1025
- <div><strong>CA +15%:</strong> {formatarMoeda(aval.ca_sup)}</div>
1026
- <div><strong>IC 80% Inf.:</strong> {formatarMoeda(aval.ic_inf)} ({`-${formatarNumero(aval.perc_inf, 1)}%`})</div>
1027
- <div><strong>IC 80% Sup.:</strong> {formatarMoeda(aval.ic_sup)} ({`+${formatarNumero(aval.perc_sup, 1)}%`})</div>
1028
- <div><strong>Amplitude:</strong> {formatarNumero(aval.amplitude, 1)}%</div>
1029
- <div><strong>Qtd. extrapolações:</strong> {String(aval.qtd_extrapolacoes ?? 0)}</div>
1030
- </div>
1031
 
1032
- <div className="avaliacao-modelos-graus">
1033
- <span className="avaliacao-grau-item" style={{ color: corGrau(aval.precisao) }}>
1034
- <strong>Precisão:</strong> {String(aval.precisao || '-')}
1035
- <button
1036
- type="button"
1037
- className="avaliacao-popup-trigger"
1038
- aria-label="Detalhes do enquadramento de precisão"
1039
- onMouseEnter={() => onPopupEnter(popupPrecisaoHtml(aval))}
1040
- onMouseLeave={onPopupLeave}
1041
- onFocus={() => onPopupEnter(popupPrecisaoHtml(aval))}
1042
- onBlur={onPopupLeave}
1043
- >
1044
-
1045
- </button>
1046
- </span>
1047
- <span className="avaliacao-grau-item" style={{ color: corGrau(aval.fundamentacao) }}>
1048
- <strong>Fundamentação:</strong> {String(aval.fundamentacao || '-')}
1049
- <button
1050
- type="button"
1051
- className="avaliacao-popup-trigger"
1052
- aria-label="Detalhes do enquadramento de fundamentação"
1053
- onMouseEnter={() => onPopupEnter(popupFundamentacaoHtml(aval))}
1054
- onMouseLeave={onPopupLeave}
1055
- onFocus={() => onPopupEnter(popupFundamentacaoHtml(aval))}
1056
- onBlur={onPopupLeave}
1057
- >
1058
-
1059
- </button>
1060
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1061
  </div>
1062
  </article>
1063
  )
@@ -1071,8 +1273,134 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null }) {
1071
  </div>
1072
 
1073
  <LoadingOverlay show={loading} label="Processando dados..." />
1074
- {avaliacaoPopupHtml ? (
1075
- <div className="avaliacao-popup-overlay" dangerouslySetInnerHTML={{ __html: avaliacaoPopupHtml }} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1076
  ) : null}
1077
  {error ? <div className="error-line">{error}</div> : null}
1078
  </div>
 
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
  import LoadingOverlay from './LoadingOverlay'
4
+ import MapFrame from './MapFrame'
5
  import SinglePillAutocomplete from './SinglePillAutocomplete'
6
 
7
  function normalizarChaveModelo(value) {
 
34
  return data.toLocaleString('pt-BR')
35
  }
36
 
37
+ function formatarValorTabelaKnn(coluna, valor) {
38
+ if (valor === null || valor === undefined || valor === '') return '-'
39
+ const nome = String(coluna || '').toLowerCase()
40
+ if (typeof valor === 'number' && Number.isFinite(valor)) {
41
+ if (nome.includes('lat') || nome.includes('lon')) {
42
+ return valor.toLocaleString('pt-BR', {
43
+ minimumFractionDigits: 6,
44
+ maximumFractionDigits: 6,
45
+ })
46
+ }
47
+ if (nome.includes('distancia')) {
48
+ return valor.toLocaleString('pt-BR', {
49
+ minimumFractionDigits: 4,
50
+ maximumFractionDigits: 4,
51
+ })
52
+ }
53
+ return valor.toLocaleString('pt-BR', {
54
+ minimumFractionDigits: 2,
55
+ maximumFractionDigits: 2,
56
+ })
57
+ }
58
+ return String(valor)
59
+ }
60
+
61
+ function valorPadraoCampoAvaliacao(campo) {
62
+ if (String(campo?.tipo || '') !== 'dicotomica') return ''
63
+ const opcoes = Array.isArray(campo?.opcoes) && campo.opcoes.length ? campo.opcoes : [0, 1]
64
+ const zero = opcoes.find((item) => Number(item) === 0)
65
+ if (zero !== undefined) return String(zero)
66
+ return String(opcoes[0] ?? 0)
67
+ }
68
+
69
+ function construirValoresIniciaisAvaliacao(campos = []) {
70
+ const init = {}
71
+ ;(campos || []).forEach((campo) => {
72
+ init[campo.coluna] = valorPadraoCampoAvaliacao(campo)
73
+ })
74
+ return init
75
+ }
76
+
77
  function formatarExtrapoladasNaFronteira(aval) {
78
  const qtdExtrapolacoes = Number(aval?.qtd_extrapolacoes ?? 0)
79
  const houveExtrapolacao = Boolean(aval?.houve_extrapolacao) || qtdExtrapolacoes > 0
 
94
  return formatarNumero(fronteira, 2)
95
  }
96
 
97
+ function formatarEstimativaKnn(aval) {
98
+ const disponivel = Boolean(aval?.knn_disponivel)
99
+ const estimado = Number(aval?.knn_estimado)
100
+ if (!disponivel || !Number.isFinite(estimado)) return '-'
101
+ return formatarMoeda(estimado)
102
+ }
103
+
104
+ function formatarEstimativaKnnCsv(aval) {
105
+ const disponivel = Boolean(aval?.knn_disponivel)
106
+ const estimado = Number(aval?.knn_estimado)
107
+ if (!disponivel || !Number.isFinite(estimado)) return '-'
108
+ return formatarNumero(estimado, 2)
109
+ }
110
+
111
  function classificarExtrapolacao(info) {
112
  const status = String(info?.status || 'ok')
113
  const percentual = Number(info?.percentual || 0)
114
  const direcao = String(info?.direcao || '')
115
  const seta = direcao === 'acima' ? '↑' : (direcao === 'abaixo' ? '↓' : '')
116
+ if (status === 'ok') return ''
 
 
117
  if (status === 'dicotomica' || status === 'codigo_alocado' || status === 'percentual') return '—'
118
+ if (status === 'warning') return `⚠️${seta} ${formatarNumero(percentual, 1)}%`
119
+ if (status === 'grave') {
120
+ if (info?.valor_invalido) return '❌'
121
+ return `❌${seta} ${formatarNumero(percentual, 1)}%`
122
+ }
123
+ if (info?.valor_invalido) return '❌'
124
  return status
125
  }
126
 
 
133
  .replaceAll("'", '&#39;')
134
  }
135
 
 
 
 
 
 
 
 
136
  function normalizarGrau(value) {
137
  const texto = String(value || '').toLowerCase()
138
  if (texto.includes('grau iii')) return 'Grau III'
 
142
  return String(value || '-')
143
  }
144
 
 
 
 
 
 
145
  function popupPrecisaoHtml(aval) {
146
  const amplitude = Number(aval?.amplitude)
147
  const amplitudeTexto = Number.isFinite(amplitude) ? `${formatarNumero(amplitude, 1)}%` : '—'
148
  const grau = normalizarGrau(aval?.precisao)
149
  const regras = [
150
+ ['≤30%', 'Grau III'],
151
+ ['≤40%', 'Grau II'],
152
+ ['≤50%', 'Grau I'],
153
+ ['>50%', 'Sem enquadramento'],
154
  ]
155
 
156
+ const linhas = regras.map(([faixa, nome]) => {
157
  const ativo = nome === grau
158
+ const bg = ativo ? 'background: #f1f3f5;' : ''
159
  const fw = ativo ? 'font-weight: 600;' : ''
160
  const marca = ativo ? ' ◀' : ''
161
  return (
162
  `<tr style="border-bottom: 1px solid #f0f0f0; ${bg}">`
163
  + `<td style="padding: 2px 6px; ${fw}">${faixa}</td>`
164
+ + `<td style="padding: 2px 6px; ${fw}">${nome}${marca}</td>`
165
  + '</tr>'
166
  )
167
  }).join('')
 
186
 
187
  function popupFundamentacaoHtml(aval) {
188
  const grau = normalizarGrau(aval?.fundamentacao)
 
189
  const qtdExtrapolacoes = Number(aval?.qtd_extrapolacoes ?? 0)
190
  const houveExtrapolacao = Boolean(aval?.houve_extrapolacao) || qtdExtrapolacoes > 0
191
  const percExt = Number(aval?.perc_ext)
 
210
  if (!houveExtrapolacao) {
211
  return (
212
  header
213
+ + `<div>Nenhuma variável extrapolou os limites amostrais. <span style="font-weight: 600;">→ ${grau}</span></div>`
214
  )
215
  }
216
 
 
254
  ]
255
  const tabelaRegras = regras.map(([condicao, nome]) => {
256
  const ativo = nome === grau || (nome === 'Sem enq.' && grau === 'Sem enquadramento')
257
+ const bg = ativo ? 'background: #f1f3f5;' : ''
258
  const fw = ativo ? 'font-weight: 600;' : ''
259
  const marca = ativo ? ' ◀' : ''
260
  return (
 
272
  header
273
  + `<div style="margin-bottom: 3px;">${resumo}</div>`
274
  + `<div style="background: #f8f9fa; border-radius: 4px; padding: 4px 8px; margin-bottom: 4px; font-size: 11px;">${varsStr}</div>`
275
+ + `<div style="margin-bottom: 4px; font-size: 11px; color: #495057;">${motivo} <span style="font-weight: 600;">→ ${grau}</span></div>`
276
  + '<table style="width: 100%; border-collapse: collapse; font-size: 11px;">'
277
  + '<tr style="border-bottom: 1px solid #e9ecef;">'
278
  + '<th style="text-align: left; padding: 2px 6px; color: #6c757d;">Condição</th>'
 
375
  const [avaliacoesCards, setAvaliacoesCards] = useState([])
376
  const [baseCardId, setBaseCardId] = useState(BASE_COMPARACAO_SEM_BASE)
377
  const [confirmarLimpezaAvaliacoes, setConfirmarLimpezaAvaliacoes] = useState(false)
378
+ const [confirmarExclusaoCardId, setConfirmarExclusaoCardId] = useState('')
379
+ const [avaliacaoPopup, setAvaliacaoPopup] = useState(null)
380
+ const [knnDetalheAberto, setKnnDetalheAberto] = useState(false)
381
+ const [knnDetalheLoading, setKnnDetalheLoading] = useState(false)
382
+ const [knnDetalheErro, setKnnDetalheErro] = useState('')
383
+ const [knnDetalheCardTitulo, setKnnDetalheCardTitulo] = useState('')
384
+ const [knnDetalheMapaHtml, setKnnDetalheMapaHtml] = useState('')
385
+ const [knnDetalheAvaliando, setKnnDetalheAvaliando] = useState([])
386
+ const [knnDetalheTabela, setKnnDetalheTabela] = useState(null)
387
+ const [knnDetalheInfo, setKnnDetalheInfo] = useState(null)
388
 
389
  const uploadInputRef = useRef(null)
390
  const quickLoadHandledRef = useRef('')
 
576
  }
577
  }, [avaliacoesCards, baseCardId])
578
 
579
+ useEffect(() => {
580
+ if (!confirmarExclusaoCardId) return
581
+ if (!avaliacoesCards.some((item) => item.id === confirmarExclusaoCardId)) {
582
+ setConfirmarExclusaoCardId('')
583
+ }
584
+ }, [avaliacoesCards, confirmarExclusaoCardId])
585
+
586
  function resetCamposAvaliacao(campos = camposAvaliacao) {
587
+ valoresAvaliacaoRef.current = construirValoresIniciaisAvaliacao(campos)
 
 
 
 
588
  setAvaliacaoFormVersion((prev) => prev + 1)
589
  }
590
 
 
732
  }
733
  setAvaliacoesCards((prev) => [...prev, card])
734
  setBaseCardId((prev) => prev || card.id)
735
+ setConfirmarExclusaoCardId('')
736
  setConfirmarLimpezaAvaliacoes(false)
737
  })
738
  }
 
741
  resetCamposAvaliacao()
742
  }
743
 
744
+ function onSolicitarExclusaoCard(cardId) {
745
+ setConfirmarExclusaoCardId(String(cardId || ''))
746
+ }
747
+
748
+ function onConfirmarExclusaoCard(cardId) {
749
  setAvaliacoesCards((prev) => prev.filter((item) => item.id !== cardId))
750
+ setConfirmarExclusaoCardId('')
751
+ }
752
+
753
+ function onCancelarExclusaoCard() {
754
+ setConfirmarExclusaoCardId('')
755
  }
756
 
757
  function onLimparAvaliacoes() {
758
  setAvaliacoesCards([])
759
  setBaseCardId(BASE_COMPARACAO_SEM_BASE)
760
+ setConfirmarExclusaoCardId('')
761
  setConfirmarLimpezaAvaliacoes(false)
762
  }
763
 
764
+ function onPopupEnter(event, html) {
765
+ const conteudo = String(html || '').trim()
766
+ if (!conteudo) {
767
+ setAvaliacaoPopup(null)
768
+ return
769
+ }
770
+
771
+ const alvo = event?.currentTarget
772
+ if (!alvo || typeof alvo.getBoundingClientRect !== 'function') {
773
+ setAvaliacaoPopup({
774
+ html: conteudo,
775
+ left: 12,
776
+ top: 12,
777
+ width: 320,
778
+ })
779
+ return
780
+ }
781
+
782
+ const rect = alvo.getBoundingClientRect()
783
+ const vw = Math.max(window.innerWidth || 0, 320)
784
+ const vh = Math.max(window.innerHeight || 0, 320)
785
+ const largura = Math.min(520, Math.max(280, vw - 24))
786
+
787
+ const abreParaDireita = rect.left < (vw / 2)
788
+ let left = abreParaDireita ? (rect.right + 10) : (rect.left - largura - 10)
789
+ left = Math.max(12, Math.min(left, vw - largura - 12))
790
+
791
+ const topoMax = Math.max(12, vh - 220)
792
+ let top = rect.bottom + 8
793
+ top = Math.max(12, Math.min(top, topoMax))
794
+
795
+ setAvaliacaoPopup({
796
+ html: conteudo,
797
+ left,
798
+ top,
799
+ width: largura,
800
+ })
801
  }
802
 
803
  function onPopupLeave() {
804
+ setAvaliacaoPopup(null)
805
+ }
806
+
807
+ async function onAbrirDetalheKnn(card, indice) {
808
+ if (!sessionId || !card?.avaliacao?.valores_x) return
809
+ setKnnDetalheAberto(true)
810
+ setKnnDetalheLoading(true)
811
+ setKnnDetalheErro('')
812
+ setKnnDetalheCardTitulo(`Aval. ${Number(indice) + 1} — ${String(card?.modelo || 'Modelo')}`)
813
+ setKnnDetalheMapaHtml('')
814
+ setKnnDetalheAvaliando([])
815
+ setKnnDetalheTabela(null)
816
+ setKnnDetalheInfo(null)
817
+ try {
818
+ const resp = await api.evaluationKnnDetailsViz(sessionId, card.avaliacao.valores_x)
819
+ setKnnDetalheMapaHtml(String(resp?.mapa_html || ''))
820
+ setKnnDetalheAvaliando(Array.isArray(resp?.avaliando) ? resp.avaliando : [])
821
+ setKnnDetalheTabela(resp?.vizinhos_tabela || null)
822
+ setKnnDetalheInfo(resp?.knn || null)
823
+ } catch (err) {
824
+ setKnnDetalheErro(err?.message || 'Falha ao carregar detalhes do KNN.')
825
+ } finally {
826
+ setKnnDetalheLoading(false)
827
+ }
828
+ }
829
+
830
+ function onFecharDetalheKnn() {
831
+ setKnnDetalheAberto(false)
832
+ setKnnDetalheLoading(false)
833
+ setKnnDetalheErro('')
834
  }
835
 
836
  function calcularComparacaoBase(avaliacao) {
 
868
  'Fundamentacao',
869
  'QtdExtrapolacoes',
870
  'ExtrapoladasNaFronteira',
871
+ 'EstimativaKNN',
872
  ...variaveis.map((item) => `X_${item}`),
873
  ]
874
  const linhas = [cabecalho.join(';')]
 
892
  String(aval.fundamentacao || ''),
893
  String(aval.qtd_extrapolacoes ?? ''),
894
  formatarExtrapoladasNaFronteiraCsv(aval),
895
+ formatarEstimativaKnnCsv(aval),
896
  ]
897
  const camposVars = variaveis.map((variavel) => {
898
  const valor = aval?.valores_x?.[variavel]
 
906
  }
907
 
908
  const modeloPronto = Boolean(camposAvaliacao.length)
909
+ const mostrarComparacaoBase = Boolean(baseCard && avaliacoesCards.length > 1)
910
+ const indiceBaseSelecionada = mostrarComparacaoBase
911
+ ? (avaliacoesCards.findIndex((item) => item.id === baseCard?.id) + 1)
912
+ : 0
913
 
914
  return (
915
  <div className="tab-content">
 
1028
  <label>{campo.coluna}</label>
1029
  {campo.tipo === 'dicotomica' ? (
1030
  <select
1031
+ defaultValue={String(valoresAvaliacaoRef.current[campo.coluna] ?? valorPadraoCampoAvaliacao(campo))}
1032
  onChange={(event) => {
1033
  valoresAvaliacaoRef.current[campo.coluna] = event.target.value
1034
  }}
1035
  >
 
1036
  {(campo.opcoes || [0, 1]).map((opcao) => (
1037
  <option key={`op-avaliacao-${campo.coluna}-${opcao}`} value={String(opcao)}>
1038
  {opcao}
 
1104
  {avaliacoesCards.map((item, idx) => {
1105
  const aval = item.avaliacao || {}
1106
  const ehBase = item.id === baseCardId
1107
+ const exclusaoPendente = confirmarExclusaoCardId === item.id
1108
  const variaveis = Object.keys(aval.valores_x || {})
1109
  return (
1110
  <article key={item.id} className="avaliacao-modelos-card">
 
1114
  <span>{item.modelo}</span>
1115
  </div>
1116
  <div className="avaliacao-modelos-card-actions">
1117
+ {!exclusaoPendente ? (
1118
+ <button
1119
+ type="button"
1120
+ className="avaliacao-modelos-delete-btn"
1121
+ onClick={() => onSolicitarExclusaoCard(item.id)}
1122
+ disabled={loading}
1123
+ >
1124
+ Excluir
1125
+ </button>
1126
+ ) : (
1127
+ <div className="avaliacao-card-delete-confirm">
1128
+ <button
1129
+ type="button"
1130
+ className="avaliacao-modelos-delete-btn"
1131
+ onClick={() => onConfirmarExclusaoCard(item.id)}
1132
+ disabled={loading}
1133
+ >
1134
+ Confirmar
1135
+ </button>
1136
+ <button
1137
+ type="button"
1138
+ className="avaliacao-modelos-delete-cancel-btn"
1139
+ onClick={onCancelarExclusaoCard}
1140
+ disabled={loading}
1141
+ >
1142
+ Cancelar
1143
+ </button>
1144
+ </div>
1145
+ )}
1146
  </div>
1147
  </div>
1148
 
 
1150
  {formatarDataHoraIso(item.createdAt)}
1151
  </div>
1152
 
1153
+ <div className="avaliacao-modelos-linhas">
 
 
 
 
 
 
 
 
 
1154
  {variaveis.map((variavel) => (
1155
+ <div key={`${item.id}-var-${variavel}`} className="avaliacao-modelos-linha">
1156
  <span>{variavel}</span>
1157
  <span>
1158
  {formatarNumero(aval?.valores_x?.[variavel], 2)} {classificarExtrapolacao(aval?.extrapolacoes?.[variavel])}
1159
  </span>
1160
  </div>
1161
  ))}
 
1162
 
1163
+ <div className="avaliacao-modelos-linha avaliacao-modelos-linha-destaque">
1164
+ <span>Estimado</span>
1165
+ <strong>{formatarMoeda(aval.estimado)}</strong>
1166
+ </div>
1167
+
1168
+ <div className="avaliacao-modelos-linha">
1169
+ <span>Extrapoladas na fronteira</span>
1170
+ <span>
1171
+ {formatarExtrapoladasNaFronteira(aval)}{' '}
1172
+ <button
1173
+ type="button"
1174
+ className="avaliacao-popup-trigger"
1175
+ aria-label="Detalhes de extrapoladas na fronteira"
1176
+ onMouseEnter={(event) => onPopupEnter(event, popupFronteiraHtml(aval))}
1177
+ onMouseLeave={onPopupLeave}
1178
+ onFocus={(event) => onPopupEnter(event, popupFronteiraHtml(aval))}
1179
+ onBlur={onPopupLeave}
1180
+ >
1181
+
1182
+ </button>
1183
+ </span>
1184
  </div>
 
 
 
 
 
 
 
1185
 
1186
+ <div className="avaliacao-modelos-linha">
1187
+ <span>Estimativa KNN</span>
1188
+ <span>
1189
+ {formatarEstimativaKnn(aval)}{' '}
1190
+ <button
1191
+ type="button"
1192
+ className="avaliacao-knn-open-icon"
1193
+ title="Abrir detalhamento KNN"
1194
+ aria-label="Abrir detalhamento KNN"
1195
+ onClick={() => void onAbrirDetalheKnn(item, idx)}
1196
+ disabled={loading}
1197
+ >
1198
+
1199
+ </button>
1200
+ </span>
1201
+ </div>
1202
+
1203
+ {mostrarComparacaoBase ? (
1204
+ <div className="avaliacao-modelos-linha">
1205
+ <span>{`Estimado / Base (Aval. ${indiceBaseSelecionada})`}</span>
1206
+ <span>{ehBase ? 'Base' : calcularComparacaoBase(aval)}</span>
1207
+ </div>
1208
+ ) : null}
1209
+ <div className="avaliacao-modelos-linha">
1210
+ <span>CA −15%</span>
1211
+ <span>{formatarMoeda(aval.ca_inf)}</span>
1212
+ </div>
1213
+ <div className="avaliacao-modelos-linha">
1214
+ <span>CA +15%</span>
1215
+ <span>{formatarMoeda(aval.ca_sup)}</span>
1216
+ </div>
1217
+ <div className="avaliacao-modelos-linha">
1218
+ <span>IC 80% Inf.</span>
1219
+ <span>{formatarMoeda(aval.ic_inf)} ({`-${formatarNumero(aval.perc_inf, 1)}%`})</span>
1220
+ </div>
1221
+ <div className="avaliacao-modelos-linha">
1222
+ <span>IC 80% Sup.</span>
1223
+ <span>{formatarMoeda(aval.ic_sup)} ({`+${formatarNumero(aval.perc_sup, 1)}%`})</span>
1224
+ </div>
1225
+ <div className="avaliacao-modelos-linha">
1226
+ <span>Amplitude</span>
1227
+ <span>{formatarNumero(aval.amplitude, 1)}%</span>
1228
+ </div>
1229
+ <div className="avaliacao-modelos-linha">
1230
+ <span>Precisão</span>
1231
+ <span>
1232
+ {String(aval.precisao || '-')} {' '}
1233
+ <button
1234
+ type="button"
1235
+ className="avaliacao-popup-trigger"
1236
+ aria-label="Detalhes do enquadramento de precisão"
1237
+ onMouseEnter={(event) => onPopupEnter(event, popupPrecisaoHtml(aval))}
1238
+ onMouseLeave={onPopupLeave}
1239
+ onFocus={(event) => onPopupEnter(event, popupPrecisaoHtml(aval))}
1240
+ onBlur={onPopupLeave}
1241
+ >
1242
+
1243
+ </button>
1244
+ </span>
1245
+ </div>
1246
+ <div className="avaliacao-modelos-linha">
1247
+ <span>Fundamentação</span>
1248
+ <span>
1249
+ {String(aval.fundamentacao || '-')} {' '}
1250
+ <button
1251
+ type="button"
1252
+ className="avaliacao-popup-trigger"
1253
+ aria-label="Detalhes do enquadramento de fundamentação"
1254
+ onMouseEnter={(event) => onPopupEnter(event, popupFundamentacaoHtml(aval))}
1255
+ onMouseLeave={onPopupLeave}
1256
+ onFocus={(event) => onPopupEnter(event, popupFundamentacaoHtml(aval))}
1257
+ onBlur={onPopupLeave}
1258
+ >
1259
+
1260
+ </button>
1261
+ </span>
1262
+ </div>
1263
  </div>
1264
  </article>
1265
  )
 
1273
  </div>
1274
 
1275
  <LoadingOverlay show={loading} label="Processando dados..." />
1276
+ {avaliacaoPopup?.html ? (
1277
+ <div
1278
+ className="avaliacao-popup-overlay"
1279
+ style={{
1280
+ left: `${avaliacaoPopup.left}px`,
1281
+ top: `${avaliacaoPopup.top}px`,
1282
+ width: `${avaliacaoPopup.width}px`,
1283
+ }}
1284
+ dangerouslySetInnerHTML={{ __html: avaliacaoPopup.html }}
1285
+ />
1286
+ ) : null}
1287
+ {knnDetalheAberto ? (
1288
+ <div className="pesquisa-modal-backdrop" onClick={(event) => {
1289
+ if (event.target === event.currentTarget) onFecharDetalheKnn()
1290
+ }}
1291
+ >
1292
+ <div className="pesquisa-modal avaliacao-knn-modal">
1293
+ <div className="pesquisa-modal-head">
1294
+ <div>
1295
+ <h4>Detalhamento KNN</h4>
1296
+ <p>{knnDetalheCardTitulo || 'Avaliação selecionada'}</p>
1297
+ </div>
1298
+ <button type="button" className="pesquisa-modal-close" onClick={onFecharDetalheKnn}>
1299
+ Fechar
1300
+ </button>
1301
+ </div>
1302
+ <div className="pesquisa-modal-body">
1303
+ {knnDetalheLoading ? (
1304
+ <div className="empty-box">Carregando detalhes do KNN...</div>
1305
+ ) : null}
1306
+ {knnDetalheErro ? (
1307
+ <div className="error-line">{knnDetalheErro}</div>
1308
+ ) : null}
1309
+ {!knnDetalheLoading && !knnDetalheErro ? (
1310
+ <>
1311
+ <div className="subpanel avaliacao-knn-detalhes-box">
1312
+ <h4>Dados do KNN</h4>
1313
+ <div className="avaliacao-knn-resumo-grid">
1314
+ <div className="avaliacao-knn-resumo-item">
1315
+ <span>Estimativa KNN</span>
1316
+ <strong>{formatarEstimativaKnn(knnDetalheInfo || {})}</strong>
1317
+ </div>
1318
+ <div className="avaliacao-knn-resumo-item">
1319
+ <span>Método</span>
1320
+ <strong>{Boolean(knnDetalheInfo?.knn_geo_aplicado) ? 'Características + localização' : 'Somente características'}</strong>
1321
+ </div>
1322
+ <div className="avaliacao-knn-resumo-item">
1323
+ <span>k dinâmico</span>
1324
+ <strong>{Number.isFinite(Number(knnDetalheInfo?.knn_k)) ? String(knnDetalheInfo.knn_k) : '—'}</strong>
1325
+ </div>
1326
+ <div className="avaliacao-knn-resumo-item">
1327
+ <span>Base válida</span>
1328
+ <strong>{Number.isFinite(Number(knnDetalheInfo?.knn_n_validos)) ? String(knnDetalheInfo.knn_n_validos) : '—'}</strong>
1329
+ </div>
1330
+ <div className="avaliacao-knn-resumo-item">
1331
+ <span>Peso geo (a)</span>
1332
+ <strong>{Number.isFinite(Number(knnDetalheInfo?.knn_alpha_geo)) ? formatarNumero(knnDetalheInfo.knn_alpha_geo, 2) : '—'}</strong>
1333
+ </div>
1334
+ <div className="avaliacao-knn-resumo-item">
1335
+ <span>Status</span>
1336
+ <strong>{Boolean(knnDetalheInfo?.knn_disponivel) ? 'Disponível' : 'Indisponível'}</strong>
1337
+ </div>
1338
+ </div>
1339
+ {Array.isArray(knnDetalheInfo?.knn_colunas) && knnDetalheInfo.knn_colunas.length ? (
1340
+ <div className="avaliacao-knn-resumo-colunas">
1341
+ <b>Características usadas:</b> {knnDetalheInfo.knn_colunas.join(', ')}
1342
+ </div>
1343
+ ) : null}
1344
+ {String(knnDetalheInfo?.knn_motivo || '').trim() ? (
1345
+ <div className="section1-empty-hint">{String(knnDetalheInfo.knn_motivo || '').trim()}</div>
1346
+ ) : null}
1347
+ </div>
1348
+
1349
+ <div className="avaliacao-knn-legenda">
1350
+ <span><b>Mercado (base completa):</b> azul</span>
1351
+ <span><b>Selecionados KNN:</b> vermelho</span>
1352
+ </div>
1353
+
1354
+ <div className="avaliacao-knn-map-wrap">
1355
+ <MapFrame html={knnDetalheMapaHtml} />
1356
+ </div>
1357
+
1358
+ <div className="subpanel avaliacao-knn-detalhes-box">
1359
+ <h4>Valores do avaliando</h4>
1360
+ <div className="avaliacao-knn-avaliando-grid">
1361
+ {(knnDetalheAvaliando || []).map((item) => (
1362
+ <div key={`knn-aval-${String(item?.variavel || '')}`} className="avaliacao-knn-avaliando-item">
1363
+ <span>{String(item?.variavel || '-')}</span>
1364
+ <strong>{formatarValorTabelaKnn(item?.variavel, item?.valor)}</strong>
1365
+ </div>
1366
+ ))}
1367
+ </div>
1368
+ </div>
1369
+
1370
+ <div className="subpanel avaliacao-knn-detalhes-box">
1371
+ <h4>Linhas da base de mercado selecionadas no KNN</h4>
1372
+ {knnDetalheTabela?.columns?.length ? (
1373
+ <div className="table-wrapper avaliacao-knn-table-wrapper">
1374
+ <table>
1375
+ <thead>
1376
+ <tr>
1377
+ {knnDetalheTabela.columns.map((coluna) => (
1378
+ <th key={`th-knn-${coluna}`}>{String(coluna)}</th>
1379
+ ))}
1380
+ </tr>
1381
+ </thead>
1382
+ <tbody>
1383
+ {(knnDetalheTabela.rows || []).map((linha, idxLinha) => (
1384
+ <tr key={`tr-knn-${idxLinha}`}>
1385
+ {knnDetalheTabela.columns.map((coluna) => (
1386
+ <td key={`td-knn-${idxLinha}-${coluna}`}>
1387
+ {formatarValorTabelaKnn(coluna, linha?.[coluna])}
1388
+ </td>
1389
+ ))}
1390
+ </tr>
1391
+ ))}
1392
+ </tbody>
1393
+ </table>
1394
+ </div>
1395
+ ) : (
1396
+ <div className="empty-box">Sem vizinhos disponíveis para esta avaliação.</div>
1397
+ )}
1398
+ </div>
1399
+ </>
1400
+ ) : null}
1401
+ </div>
1402
+ </div>
1403
+ </div>
1404
  ) : null}
1405
  {error ? <div className="error-line">{error}</div> : null}
1406
  </div>
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -216,6 +216,52 @@ function formatNumberBr(value, maximumFractionDigits = 4) {
216
  })
217
  }
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  function quantileLinear(sortedValues, q) {
220
  if (!Array.isArray(sortedValues) || sortedValues.length === 0) return null
221
  const clampedQ = Math.min(1, Math.max(0, Number(q)))
@@ -856,8 +902,17 @@ export default function ElaboracaoTab({ sessionId }) {
856
  const [avaliacaoPendente, setAvaliacaoPendente] = useState(false)
857
  const [confirmarLimpezaAvaliacoes, setConfirmarLimpezaAvaliacoes] = useState(false)
858
  const [resultadoAvaliacaoHtml, setResultadoAvaliacaoHtml] = useState('')
 
859
  const [baseChoices, setBaseChoices] = useState([])
860
  const [baseValue, setBaseValue] = useState('')
 
 
 
 
 
 
 
 
861
 
862
  const [nomeArquivoExport, setNomeArquivoExport] = useState('')
863
  const [avaliadores, setAvaliadores] = useState([])
@@ -2081,11 +2136,7 @@ export default function ElaboracaoTab({ sessionId }) {
2081
  setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(filtros))
2082
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot(outliersTexto, reincluirTexto))
2083
  setCamposAvaliacao(resp.avaliacao_campos || [])
2084
- const init = {}
2085
- ;(resp.avaliacao_campos || []).forEach((campo) => {
2086
- init[campo.coluna] = ''
2087
- })
2088
- valoresAvaliacaoRef.current = init
2089
  setAvaliacaoFormVersion((prev) => prev + 1)
2090
  setAvaliacaoPendente(false)
2091
  setConfirmarLimpezaAvaliacoes(false)
@@ -2863,6 +2914,16 @@ export default function ElaboracaoTab({ sessionId }) {
2863
  await onRestartIterationWithTexts('', joinSelection(outliersAnteriores))
2864
  }
2865
 
 
 
 
 
 
 
 
 
 
 
2866
  async function onClearHistory() {
2867
  if (!sessionId) return
2868
  await withBusy(async () => {
@@ -2902,6 +2963,7 @@ export default function ElaboracaoTab({ sessionId }) {
2902
  await withBusy(async () => {
2903
  const resp = await api.evaluationCalculateElab(sessionId, valoresAvaliacaoRef.current, baseValue || null)
2904
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
 
2905
  setBaseChoices(resp.base_choices || [])
2906
  setBaseValue(resp.base_value || '')
2907
  setConfirmarLimpezaAvaliacoes(false)
@@ -2910,11 +2972,7 @@ export default function ElaboracaoTab({ sessionId }) {
2910
  }
2911
 
2912
  function onResetCamposAvaliacao() {
2913
- const limpo = {}
2914
- camposAvaliacao.forEach((campo) => {
2915
- limpo[campo.coluna] = ''
2916
- })
2917
- valoresAvaliacaoRef.current = limpo
2918
  setAvaliacaoFormVersion((prev) => prev + 1)
2919
  setAvaliacaoPendente(false)
2920
  }
@@ -2924,6 +2982,7 @@ export default function ElaboracaoTab({ sessionId }) {
2924
  await withBusy(async () => {
2925
  const resp = await api.evaluationClearElab(sessionId)
2926
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
 
2927
  setBaseChoices(resp.base_choices || [])
2928
  setBaseValue(resp.base_value || '')
2929
  setConfirmarLimpezaAvaliacoes(false)
@@ -2935,13 +2994,57 @@ export default function ElaboracaoTab({ sessionId }) {
2935
  await withBusy(async () => {
2936
  const resp = await api.evaluationDeleteElab(sessionId, indice ? String(indice) : null, baseValue || null)
2937
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
 
2938
  setBaseChoices(resp.base_choices || [])
2939
  setBaseValue(resp.base_value || '')
2940
  setConfirmarLimpezaAvaliacoes(false)
2941
  })
2942
  }
2943
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2944
  function onAvaliacaoResultadoClick(event) {
 
 
 
 
 
 
 
 
2945
  const ativarExclusao = event.target.closest('[data-avaliacao-delete-arm]')
2946
  if (ativarExclusao) {
2947
  const indice = ativarExclusao.getAttribute('data-avaliacao-delete-index')
@@ -5445,13 +5548,12 @@ export default function ElaboracaoTab({ sessionId }) {
5445
  <label>{campo.coluna}</label>
5446
  {campo.tipo === 'dicotomica' ? (
5447
  <select
5448
- defaultValue={String(valoresAvaliacaoRef.current[campo.coluna] ?? '')}
5449
  onChange={(e) => {
5450
  valoresAvaliacaoRef.current[campo.coluna] = e.target.value
5451
  setAvaliacaoPendente(true)
5452
  }}
5453
  >
5454
- <option value="">Selecione</option>
5455
  {(campo.opcoes || [0, 1]).map((opcao) => (
5456
  <option key={`op-${campo.coluna}-${opcao}`} value={String(opcao)}>
5457
  {opcao}
@@ -5558,6 +5660,124 @@ export default function ElaboracaoTab({ sessionId }) {
5558
  {disabledHint.text}
5559
  </div>
5560
  ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5561
  <LoadingOverlay show={loading} label="Processando dados..." />
5562
  {error ? <div className="error-line">{error}</div> : null}
5563
  </div>
 
216
  })
217
  }
218
 
219
+ function formatCurrencyBr(value) {
220
+ const num = Number(value)
221
+ if (!Number.isFinite(num)) return '-'
222
+ return num.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
223
+ }
224
+
225
+ function formatarValorTabelaKnn(coluna, valor) {
226
+ if (valor === null || valor === undefined || valor === '') return '-'
227
+ const nome = String(coluna || '').toLowerCase()
228
+ if (typeof valor === 'number' && Number.isFinite(valor)) {
229
+ if (nome.includes('lat') || nome.includes('lon')) {
230
+ return valor.toLocaleString('pt-BR', {
231
+ minimumFractionDigits: 6,
232
+ maximumFractionDigits: 6,
233
+ })
234
+ }
235
+ if (nome.includes('distancia')) {
236
+ return valor.toLocaleString('pt-BR', {
237
+ minimumFractionDigits: 4,
238
+ maximumFractionDigits: 4,
239
+ })
240
+ }
241
+ return valor.toLocaleString('pt-BR', {
242
+ minimumFractionDigits: 2,
243
+ maximumFractionDigits: 2,
244
+ })
245
+ }
246
+ return String(valor)
247
+ }
248
+
249
+ function valorPadraoCampoAvaliacao(campo) {
250
+ if (String(campo?.tipo || '') !== 'dicotomica') return ''
251
+ const opcoes = Array.isArray(campo?.opcoes) && campo.opcoes.length ? campo.opcoes : [0, 1]
252
+ const zero = opcoes.find((item) => Number(item) === 0)
253
+ if (zero !== undefined) return String(zero)
254
+ return String(opcoes[0] ?? 0)
255
+ }
256
+
257
+ function construirValoresIniciaisAvaliacao(campos = []) {
258
+ const init = {}
259
+ ;(campos || []).forEach((campo) => {
260
+ init[campo.coluna] = valorPadraoCampoAvaliacao(campo)
261
+ })
262
+ return init
263
+ }
264
+
265
  function quantileLinear(sortedValues, q) {
266
  if (!Array.isArray(sortedValues) || sortedValues.length === 0) return null
267
  const clampedQ = Math.min(1, Math.max(0, Number(q)))
 
902
  const [avaliacaoPendente, setAvaliacaoPendente] = useState(false)
903
  const [confirmarLimpezaAvaliacoes, setConfirmarLimpezaAvaliacoes] = useState(false)
904
  const [resultadoAvaliacaoHtml, setResultadoAvaliacaoHtml] = useState('')
905
+ const [avaliacoesResultado, setAvaliacoesResultado] = useState([])
906
  const [baseChoices, setBaseChoices] = useState([])
907
  const [baseValue, setBaseValue] = useState('')
908
+ const [knnDetalheAberto, setKnnDetalheAberto] = useState(false)
909
+ const [knnDetalheLoading, setKnnDetalheLoading] = useState(false)
910
+ const [knnDetalheErro, setKnnDetalheErro] = useState('')
911
+ const [knnDetalheCardTitulo, setKnnDetalheCardTitulo] = useState('')
912
+ const [knnDetalheMapaHtml, setKnnDetalheMapaHtml] = useState('')
913
+ const [knnDetalheAvaliando, setKnnDetalheAvaliando] = useState([])
914
+ const [knnDetalheTabela, setKnnDetalheTabela] = useState(null)
915
+ const [knnDetalheInfo, setKnnDetalheInfo] = useState(null)
916
 
917
  const [nomeArquivoExport, setNomeArquivoExport] = useState('')
918
  const [avaliadores, setAvaliadores] = useState([])
 
2136
  setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(filtros))
2137
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot(outliersTexto, reincluirTexto))
2138
  setCamposAvaliacao(resp.avaliacao_campos || [])
2139
+ valoresAvaliacaoRef.current = construirValoresIniciaisAvaliacao(resp.avaliacao_campos || [])
 
 
 
 
2140
  setAvaliacaoFormVersion((prev) => prev + 1)
2141
  setAvaliacaoPendente(false)
2142
  setConfirmarLimpezaAvaliacoes(false)
 
2914
  await onRestartIterationWithTexts('', joinSelection(outliersAnteriores))
2915
  }
2916
 
2917
+ useEffect(() => {
2918
+ if (resultadoAvaliacaoHtml) return
2919
+ setAvaliacoesResultado([])
2920
+ if (knnDetalheAberto) {
2921
+ setKnnDetalheAberto(false)
2922
+ setKnnDetalheLoading(false)
2923
+ setKnnDetalheErro('')
2924
+ }
2925
+ }, [resultadoAvaliacaoHtml, knnDetalheAberto])
2926
+
2927
  async function onClearHistory() {
2928
  if (!sessionId) return
2929
  await withBusy(async () => {
 
2963
  await withBusy(async () => {
2964
  const resp = await api.evaluationCalculateElab(sessionId, valoresAvaliacaoRef.current, baseValue || null)
2965
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
2966
+ setAvaliacoesResultado(Array.isArray(resp?.avaliacoes) ? resp.avaliacoes : [])
2967
  setBaseChoices(resp.base_choices || [])
2968
  setBaseValue(resp.base_value || '')
2969
  setConfirmarLimpezaAvaliacoes(false)
 
2972
  }
2973
 
2974
  function onResetCamposAvaliacao() {
2975
+ valoresAvaliacaoRef.current = construirValoresIniciaisAvaliacao(camposAvaliacao)
 
 
 
 
2976
  setAvaliacaoFormVersion((prev) => prev + 1)
2977
  setAvaliacaoPendente(false)
2978
  }
 
2982
  await withBusy(async () => {
2983
  const resp = await api.evaluationClearElab(sessionId)
2984
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
2985
+ setAvaliacoesResultado(Array.isArray(resp?.avaliacoes) ? resp.avaliacoes : [])
2986
  setBaseChoices(resp.base_choices || [])
2987
  setBaseValue(resp.base_value || '')
2988
  setConfirmarLimpezaAvaliacoes(false)
 
2994
  await withBusy(async () => {
2995
  const resp = await api.evaluationDeleteElab(sessionId, indice ? String(indice) : null, baseValue || null)
2996
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
2997
+ setAvaliacoesResultado(Array.isArray(resp?.avaliacoes) ? resp.avaliacoes : [])
2998
  setBaseChoices(resp.base_choices || [])
2999
  setBaseValue(resp.base_value || '')
3000
  setConfirmarLimpezaAvaliacoes(false)
3001
  })
3002
  }
3003
 
3004
+ async function onAbrirDetalheKnnElab(indiceRaw) {
3005
+ if (!sessionId) return
3006
+ const idx = Number(indiceRaw) - 1
3007
+ if (!Number.isInteger(idx) || idx < 0) return
3008
+ const avaliacao = Array.isArray(avaliacoesResultado) ? avaliacoesResultado[idx] : null
3009
+ if (!avaliacao || typeof avaliacao !== 'object' || !avaliacao.valores_x) return
3010
+
3011
+ setKnnDetalheAberto(true)
3012
+ setKnnDetalheLoading(true)
3013
+ setKnnDetalheErro('')
3014
+ setKnnDetalheCardTitulo(`Aval. ${idx + 1}`)
3015
+ setKnnDetalheMapaHtml('')
3016
+ setKnnDetalheAvaliando([])
3017
+ setKnnDetalheTabela(null)
3018
+ setKnnDetalheInfo(null)
3019
+
3020
+ try {
3021
+ const resp = await api.evaluationKnnDetailsElab(sessionId, avaliacao.valores_x)
3022
+ setKnnDetalheMapaHtml(String(resp?.mapa_html || ''))
3023
+ setKnnDetalheAvaliando(Array.isArray(resp?.avaliando) ? resp.avaliando : [])
3024
+ setKnnDetalheTabela(resp?.vizinhos_tabela || null)
3025
+ setKnnDetalheInfo(resp?.knn || null)
3026
+ } catch (err) {
3027
+ setKnnDetalheErro(err?.message || 'Falha ao carregar detalhes do KNN.')
3028
+ } finally {
3029
+ setKnnDetalheLoading(false)
3030
+ }
3031
+ }
3032
+
3033
+ function onFecharDetalheKnnElab() {
3034
+ setKnnDetalheAberto(false)
3035
+ setKnnDetalheLoading(false)
3036
+ setKnnDetalheErro('')
3037
+ }
3038
+
3039
  function onAvaliacaoResultadoClick(event) {
3040
+ const ativarDetalheKnn = event.target.closest('[data-avaliacao-knn-open]')
3041
+ if (ativarDetalheKnn) {
3042
+ const indice = ativarDetalheKnn.getAttribute('data-avaliacao-knn-index')
3043
+ if (!indice) return
3044
+ void onAbrirDetalheKnnElab(indice)
3045
+ return
3046
+ }
3047
+
3048
  const ativarExclusao = event.target.closest('[data-avaliacao-delete-arm]')
3049
  if (ativarExclusao) {
3050
  const indice = ativarExclusao.getAttribute('data-avaliacao-delete-index')
 
5548
  <label>{campo.coluna}</label>
5549
  {campo.tipo === 'dicotomica' ? (
5550
  <select
5551
+ defaultValue={String(valoresAvaliacaoRef.current[campo.coluna] ?? valorPadraoCampoAvaliacao(campo))}
5552
  onChange={(e) => {
5553
  valoresAvaliacaoRef.current[campo.coluna] = e.target.value
5554
  setAvaliacaoPendente(true)
5555
  }}
5556
  >
 
5557
  {(campo.opcoes || [0, 1]).map((opcao) => (
5558
  <option key={`op-${campo.coluna}-${opcao}`} value={String(opcao)}>
5559
  {opcao}
 
5660
  {disabledHint.text}
5661
  </div>
5662
  ) : null}
5663
+ {knnDetalheAberto ? (
5664
+ <div className="pesquisa-modal-backdrop" onClick={(event) => {
5665
+ if (event.target === event.currentTarget) onFecharDetalheKnnElab()
5666
+ }}
5667
+ >
5668
+ <div className="pesquisa-modal avaliacao-knn-modal">
5669
+ <div className="pesquisa-modal-head">
5670
+ <div>
5671
+ <h4>Detalhamento KNN</h4>
5672
+ <p>{knnDetalheCardTitulo || 'Avaliação selecionada'}</p>
5673
+ </div>
5674
+ <button type="button" className="pesquisa-modal-close" onClick={onFecharDetalheKnnElab}>
5675
+ Fechar
5676
+ </button>
5677
+ </div>
5678
+ <div className="pesquisa-modal-body">
5679
+ {knnDetalheLoading ? (
5680
+ <div className="empty-box">Carregando detalhes do KNN...</div>
5681
+ ) : null}
5682
+ {knnDetalheErro ? (
5683
+ <div className="error-line">{knnDetalheErro}</div>
5684
+ ) : null}
5685
+ {!knnDetalheLoading && !knnDetalheErro ? (
5686
+ <>
5687
+ <div className="subpanel avaliacao-knn-detalhes-box">
5688
+ <h4>Dados do KNN</h4>
5689
+ <div className="avaliacao-knn-resumo-grid">
5690
+ <div className="avaliacao-knn-resumo-item">
5691
+ <span>Estimativa KNN</span>
5692
+ <strong>{formatCurrencyBr(knnDetalheInfo?.knn_estimado)}</strong>
5693
+ </div>
5694
+ <div className="avaliacao-knn-resumo-item">
5695
+ <span>Método</span>
5696
+ <strong>{Boolean(knnDetalheInfo?.knn_geo_aplicado) ? 'Características + localização' : 'Somente características'}</strong>
5697
+ </div>
5698
+ <div className="avaliacao-knn-resumo-item">
5699
+ <span>k dinâmico</span>
5700
+ <strong>{Number.isFinite(Number(knnDetalheInfo?.knn_k)) ? String(knnDetalheInfo.knn_k) : '—'}</strong>
5701
+ </div>
5702
+ <div className="avaliacao-knn-resumo-item">
5703
+ <span>Base válida</span>
5704
+ <strong>{Number.isFinite(Number(knnDetalheInfo?.knn_n_validos)) ? String(knnDetalheInfo.knn_n_validos) : '—'}</strong>
5705
+ </div>
5706
+ <div className="avaliacao-knn-resumo-item">
5707
+ <span>Peso geo (a)</span>
5708
+ <strong>{Number.isFinite(Number(knnDetalheInfo?.knn_alpha_geo)) ? formatNumberBr(knnDetalheInfo.knn_alpha_geo, 2) : '—'}</strong>
5709
+ </div>
5710
+ <div className="avaliacao-knn-resumo-item">
5711
+ <span>Status</span>
5712
+ <strong>{Boolean(knnDetalheInfo?.knn_disponivel) ? 'Disponível' : 'Indisponível'}</strong>
5713
+ </div>
5714
+ </div>
5715
+ {Array.isArray(knnDetalheInfo?.knn_colunas) && knnDetalheInfo.knn_colunas.length ? (
5716
+ <div className="avaliacao-knn-resumo-colunas">
5717
+ <b>Características usadas:</b> {knnDetalheInfo.knn_colunas.join(', ')}
5718
+ </div>
5719
+ ) : null}
5720
+ {String(knnDetalheInfo?.knn_motivo || '').trim() ? (
5721
+ <div className="section1-empty-hint">{String(knnDetalheInfo.knn_motivo || '').trim()}</div>
5722
+ ) : null}
5723
+ </div>
5724
+
5725
+ <div className="avaliacao-knn-legenda">
5726
+ <span><b>Mercado (base completa):</b> azul</span>
5727
+ <span><b>Selecionados KNN:</b> vermelho</span>
5728
+ </div>
5729
+
5730
+ <div className="avaliacao-knn-map-wrap">
5731
+ <MapFrame html={knnDetalheMapaHtml} />
5732
+ </div>
5733
+
5734
+ <div className="subpanel avaliacao-knn-detalhes-box">
5735
+ <h4>Valores do avaliando</h4>
5736
+ <div className="avaliacao-knn-avaliando-grid">
5737
+ {(knnDetalheAvaliando || []).map((item) => (
5738
+ <div key={`knn-elab-aval-${String(item?.variavel || '')}`} className="avaliacao-knn-avaliando-item">
5739
+ <span>{String(item?.variavel || '-')}</span>
5740
+ <strong>{formatarValorTabelaKnn(item?.variavel, item?.valor)}</strong>
5741
+ </div>
5742
+ ))}
5743
+ </div>
5744
+ </div>
5745
+
5746
+ <div className="subpanel avaliacao-knn-detalhes-box">
5747
+ <h4>Linhas da base de mercado selecionadas no KNN</h4>
5748
+ {knnDetalheTabela?.columns?.length ? (
5749
+ <div className="table-wrapper avaliacao-knn-table-wrapper">
5750
+ <table>
5751
+ <thead>
5752
+ <tr>
5753
+ {knnDetalheTabela.columns.map((coluna) => (
5754
+ <th key={`th-knn-elab-${coluna}`}>{String(coluna)}</th>
5755
+ ))}
5756
+ </tr>
5757
+ </thead>
5758
+ <tbody>
5759
+ {(knnDetalheTabela.rows || []).map((linha, idxLinha) => (
5760
+ <tr key={`tr-knn-elab-${idxLinha}`}>
5761
+ {knnDetalheTabela.columns.map((coluna) => (
5762
+ <td key={`td-knn-elab-${idxLinha}-${coluna}`}>
5763
+ {formatarValorTabelaKnn(coluna, linha?.[coluna])}
5764
+ </td>
5765
+ ))}
5766
+ </tr>
5767
+ ))}
5768
+ </tbody>
5769
+ </table>
5770
+ </div>
5771
+ ) : (
5772
+ <div className="empty-box">Sem vizinhos disponíveis para esta avaliação.</div>
5773
+ )}
5774
+ </div>
5775
+ </>
5776
+ ) : null}
5777
+ </div>
5778
+ </div>
5779
+ </div>
5780
+ ) : null}
5781
  <LoadingOverlay show={loading} label="Processando dados..." />
5782
  {error ? <div className="error-line">{error}</div> : null}
5783
  </div>
frontend/src/styles.css CHANGED
@@ -2392,17 +2392,20 @@ button.pesquisa-coluna-remove:hover {
2392
  display: grid;
2393
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
2394
  gap: 12px;
 
2395
  }
2396
 
2397
  .avaliacao-modelos-card {
2398
- border: 1px solid #d7e3ef;
2399
  border-radius: 12px;
2400
  background: #fff;
2401
- box-shadow: 0 2px 6px rgba(34, 52, 70, 0.08);
2402
  padding: 10px 11px;
2403
- display: grid;
 
2404
  gap: 8px;
2405
  min-width: 0;
 
2406
  }
2407
 
2408
  .avaliacao-modelos-card-head {
@@ -2421,13 +2424,13 @@ button.pesquisa-coluna-remove:hover {
2421
  }
2422
 
2423
  .avaliacao-modelos-card-title strong {
2424
- color: #2f465c;
2425
  font-family: 'Sora', sans-serif;
2426
  font-size: 0.87rem;
2427
  }
2428
 
2429
  .avaliacao-modelos-card-title span {
2430
- color: #4f667d;
2431
  font-size: 0.79rem;
2432
  font-weight: 600;
2433
  line-height: 1.2;
@@ -2437,7 +2440,7 @@ button.pesquisa-coluna-remove:hover {
2437
  }
2438
 
2439
  .avaliacao-modelos-card-subtitle {
2440
- color: #70869b;
2441
  font-size: 0.75rem;
2442
  }
2443
 
@@ -2447,24 +2450,43 @@ button.pesquisa-coluna-remove:hover {
2447
  flex: 0 0 auto;
2448
  }
2449
 
 
 
 
 
 
 
 
2450
  .avaliacao-modelos-delete-btn {
2451
  min-height: 28px;
2452
  padding: 3px 8px;
2453
  font-size: 0.74rem;
2454
- --btn-bg-start: #fff4f6;
2455
- --btn-bg-end: #fee9ed;
2456
- --btn-border: #e3adb8;
2457
- --btn-shadow-soft: rgba(178, 47, 64, 0.1);
2458
- --btn-shadow-strong: rgba(178, 47, 64, 0.16);
2459
- color: #a63446;
 
 
 
 
 
 
 
 
 
 
 
 
2460
  }
2461
 
2462
  .avaliacao-modelos-card-base {
2463
- color: #3c546a;
2464
  font-size: 0.8rem;
2465
- border: 1px solid #e3ebf3;
2466
  border-radius: 8px;
2467
- background: #fbfdff;
2468
  padding: 6px 8px;
2469
  }
2470
 
@@ -2489,15 +2511,15 @@ button.pesquisa-coluna-remove:hover {
2489
  grid-template-columns: minmax(88px, auto) minmax(0, 1fr);
2490
  gap: 6px;
2491
  align-items: center;
2492
- border: 1px solid #edf2f7;
2493
  border-radius: 7px;
2494
- background: #fcfdff;
2495
  padding: 5px 7px;
2496
  font-size: 0.78rem;
2497
  }
2498
 
2499
  .avaliacao-modelos-vars-item span:first-child {
2500
- color: #526a80;
2501
  font-weight: 700;
2502
  min-width: 0;
2503
  overflow-wrap: anywhere;
@@ -2505,7 +2527,7 @@ button.pesquisa-coluna-remove:hover {
2505
  }
2506
 
2507
  .avaliacao-modelos-vars-item span:last-child {
2508
- color: #2e475d;
2509
  text-align: right;
2510
  min-width: 0;
2511
  overflow-wrap: anywhere;
@@ -2515,16 +2537,95 @@ button.pesquisa-coluna-remove:hover {
2515
  .avaliacao-modelos-metrics {
2516
  display: grid;
2517
  gap: 3px;
2518
- color: #40596f;
2519
  font-size: 0.78rem;
2520
  }
2521
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2522
  .avaliacao-modelos-graus {
2523
  display: grid;
2524
  gap: 3px;
2525
  padding-top: 2px;
2526
- border-top: 1px solid #ecf2f8;
2527
  font-size: 0.79rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2528
  }
2529
 
2530
  .avaliacao-grau-item {
@@ -2548,11 +2649,8 @@ button.pesquisa-coluna-remove:hover {
2548
 
2549
  .avaliacao-popup-overlay {
2550
  position: fixed;
2551
- left: 50%;
2552
- top: 50%;
2553
- transform: translate(-50%, -50%);
2554
  z-index: 3600;
2555
- width: min(520px, calc(100vw - 24px));
2556
  background: #fff;
2557
  border: 1px solid #dee2e6;
2558
  border-radius: 8px;
@@ -2569,6 +2667,97 @@ button.pesquisa-coluna-remove:hover {
2569
  pointer-events: none;
2570
  }
2571
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2572
  label {
2573
  font-weight: 700;
2574
  color: #394a5e;
 
2392
  display: grid;
2393
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
2394
  gap: 12px;
2395
+ align-items: stretch;
2396
  }
2397
 
2398
  .avaliacao-modelos-card {
2399
+ border: 1px solid #d5d9de;
2400
  border-radius: 12px;
2401
  background: #fff;
2402
+ box-shadow: 0 2px 6px rgba(20, 20, 20, 0.05);
2403
  padding: 10px 11px;
2404
+ display: flex;
2405
+ flex-direction: column;
2406
  gap: 8px;
2407
  min-width: 0;
2408
+ height: 100%;
2409
  }
2410
 
2411
  .avaliacao-modelos-card-head {
 
2424
  }
2425
 
2426
  .avaliacao-modelos-card-title strong {
2427
+ color: #232629;
2428
  font-family: 'Sora', sans-serif;
2429
  font-size: 0.87rem;
2430
  }
2431
 
2432
  .avaliacao-modelos-card-title span {
2433
+ color: #454b51;
2434
  font-size: 0.79rem;
2435
  font-weight: 600;
2436
  line-height: 1.2;
 
2440
  }
2441
 
2442
  .avaliacao-modelos-card-subtitle {
2443
+ color: #5a6066;
2444
  font-size: 0.75rem;
2445
  }
2446
 
 
2450
  flex: 0 0 auto;
2451
  }
2452
 
2453
+ .avaliacao-card-delete-confirm {
2454
+ display: inline-flex;
2455
+ align-items: center;
2456
+ gap: 6px;
2457
+ flex-wrap: wrap;
2458
+ }
2459
+
2460
  .avaliacao-modelos-delete-btn {
2461
  min-height: 28px;
2462
  padding: 3px 8px;
2463
  font-size: 0.74rem;
2464
+ --btn-bg-start: #f6f7f8;
2465
+ --btn-bg-end: #eceff2;
2466
+ --btn-border: #c9d0d7;
2467
+ --btn-shadow-soft: rgba(36, 41, 46, 0.08);
2468
+ --btn-shadow-strong: rgba(36, 41, 46, 0.14);
2469
+ color: #1f252b;
2470
+ }
2471
+
2472
+ .avaliacao-modelos-delete-cancel-btn {
2473
+ min-height: 28px;
2474
+ padding: 3px 8px;
2475
+ font-size: 0.74rem;
2476
+ --btn-bg-start: #ffffff;
2477
+ --btn-bg-end: #f5f7f9;
2478
+ --btn-border: #c9d0d7;
2479
+ --btn-shadow-soft: rgba(36, 41, 46, 0.06);
2480
+ --btn-shadow-strong: rgba(36, 41, 46, 0.1);
2481
+ color: #1f252b;
2482
  }
2483
 
2484
  .avaliacao-modelos-card-base {
2485
+ color: #2f3438;
2486
  font-size: 0.8rem;
2487
+ border: 1px solid #e0e4e8;
2488
  border-radius: 8px;
2489
+ background: #fafbfc;
2490
  padding: 6px 8px;
2491
  }
2492
 
 
2511
  grid-template-columns: minmax(88px, auto) minmax(0, 1fr);
2512
  gap: 6px;
2513
  align-items: center;
2514
+ border: 1px solid #e8ebef;
2515
  border-radius: 7px;
2516
+ background: #fcfcfd;
2517
  padding: 5px 7px;
2518
  font-size: 0.78rem;
2519
  }
2520
 
2521
  .avaliacao-modelos-vars-item span:first-child {
2522
+ color: #32373c;
2523
  font-weight: 700;
2524
  min-width: 0;
2525
  overflow-wrap: anywhere;
 
2527
  }
2528
 
2529
  .avaliacao-modelos-vars-item span:last-child {
2530
+ color: #2b3034;
2531
  text-align: right;
2532
  min-width: 0;
2533
  overflow-wrap: anywhere;
 
2537
  .avaliacao-modelos-metrics {
2538
  display: grid;
2539
  gap: 3px;
2540
+ color: #2e3338;
2541
  font-size: 0.78rem;
2542
  }
2543
 
2544
+ .avaliacao-knn-open-icon {
2545
+ all: unset;
2546
+ display: inline-flex;
2547
+ align-items: center;
2548
+ justify-content: center;
2549
+ width: 18px;
2550
+ height: 18px;
2551
+ border-radius: 999px;
2552
+ border: 1px solid #9fd3ac;
2553
+ background: #eefaf1;
2554
+ color: #2a7f43;
2555
+ font-size: 0.85em;
2556
+ line-height: 1;
2557
+ cursor: pointer;
2558
+ transition: background 0.15s ease, border-color 0.15s ease;
2559
+ }
2560
+
2561
+ .avaliacao-knn-open-icon:hover {
2562
+ background: #e2f6e7;
2563
+ border-color: #73bb87;
2564
+ }
2565
+
2566
+ .avaliacao-modelos-card .avaliacao-knn-open-icon {
2567
+ border-color: #c8cfd7;
2568
+ background: #f6f7f9;
2569
+ color: #1f252b;
2570
+ }
2571
+
2572
+ .avaliacao-modelos-card .avaliacao-knn-open-icon:hover {
2573
+ background: #eceff3;
2574
+ border-color: #aeb7c2;
2575
+ }
2576
+
2577
+ .avaliacao-knn-open-icon:disabled {
2578
+ opacity: 0.55;
2579
+ cursor: not-allowed;
2580
+ }
2581
+
2582
  .avaliacao-modelos-graus {
2583
  display: grid;
2584
  gap: 3px;
2585
  padding-top: 2px;
2586
+ border-top: 1px solid #e6e9ed;
2587
  font-size: 0.79rem;
2588
+ margin-top: auto;
2589
+ }
2590
+
2591
+ .avaliacao-modelos-linhas {
2592
+ display: grid;
2593
+ gap: 4px;
2594
+ }
2595
+
2596
+ .avaliacao-modelos-linha {
2597
+ display: grid;
2598
+ grid-template-columns: minmax(118px, auto) minmax(0, 1fr);
2599
+ gap: 7px;
2600
+ align-items: center;
2601
+ border: 1px solid #e8ebef;
2602
+ border-radius: 7px;
2603
+ background: #fcfcfd;
2604
+ padding: 5px 7px;
2605
+ font-size: 0.78rem;
2606
+ color: #22272c;
2607
+ }
2608
+
2609
+ .avaliacao-modelos-linha span:first-child {
2610
+ font-weight: 700;
2611
+ color: #30353a;
2612
+ min-width: 0;
2613
+ overflow-wrap: anywhere;
2614
+ }
2615
+
2616
+ .avaliacao-modelos-linha span:last-child {
2617
+ text-align: right;
2618
+ min-width: 0;
2619
+ overflow-wrap: anywhere;
2620
+ }
2621
+
2622
+ .avaliacao-modelos-linha-destaque {
2623
+ background: #f3f4f6;
2624
+ border-color: #d8dce1;
2625
+ }
2626
+
2627
+ .avaliacao-modelos-linha-destaque strong {
2628
+ color: #111315;
2629
  }
2630
 
2631
  .avaliacao-grau-item {
 
2649
 
2650
  .avaliacao-popup-overlay {
2651
  position: fixed;
 
 
 
2652
  z-index: 3600;
2653
+ max-width: calc(100vw - 24px);
2654
  background: #fff;
2655
  border: 1px solid #dee2e6;
2656
  border-radius: 8px;
 
2667
  pointer-events: none;
2668
  }
2669
 
2670
+ .avaliacao-knn-modal {
2671
+ width: min(1180px, 100%);
2672
+ }
2673
+
2674
+ .avaliacao-knn-legenda {
2675
+ display: flex;
2676
+ flex-wrap: wrap;
2677
+ gap: 12px;
2678
+ font-size: 0.83rem;
2679
+ color: #445d74;
2680
+ margin-bottom: 2px;
2681
+ }
2682
+
2683
+ .avaliacao-knn-map-wrap .map-frame {
2684
+ min-height: 420px;
2685
+ height: 420px;
2686
+ }
2687
+
2688
+ .avaliacao-knn-detalhes-box {
2689
+ margin-bottom: 0;
2690
+ }
2691
+
2692
+ .avaliacao-knn-resumo-grid {
2693
+ display: grid;
2694
+ grid-template-columns: repeat(auto-fit, minmax(165px, 1fr));
2695
+ gap: 6px;
2696
+ }
2697
+
2698
+ .avaliacao-knn-resumo-item {
2699
+ border: 1px solid #e2ebf3;
2700
+ border-radius: 8px;
2701
+ background: #fafdff;
2702
+ padding: 6px 8px;
2703
+ display: grid;
2704
+ gap: 2px;
2705
+ }
2706
+
2707
+ .avaliacao-knn-resumo-item span {
2708
+ color: #4b647a;
2709
+ font-size: 0.76rem;
2710
+ font-weight: 700;
2711
+ }
2712
+
2713
+ .avaliacao-knn-resumo-item strong {
2714
+ color: #2f4a60;
2715
+ font-size: 0.84rem;
2716
+ }
2717
+
2718
+ .avaliacao-knn-resumo-colunas {
2719
+ margin-top: 7px;
2720
+ color: #4a6379;
2721
+ font-size: 0.82rem;
2722
+ }
2723
+
2724
+ .avaliacao-knn-avaliando-grid {
2725
+ display: grid;
2726
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
2727
+ gap: 6px;
2728
+ }
2729
+
2730
+ .avaliacao-knn-avaliando-item {
2731
+ border: 1px solid #e2ebf3;
2732
+ border-radius: 8px;
2733
+ background: #fafdff;
2734
+ padding: 6px 8px;
2735
+ display: grid;
2736
+ gap: 2px;
2737
+ }
2738
+
2739
+ .avaliacao-knn-avaliando-item span {
2740
+ color: #4b647a;
2741
+ font-size: 0.76rem;
2742
+ font-weight: 700;
2743
+ }
2744
+
2745
+ .avaliacao-knn-avaliando-item strong {
2746
+ color: #2f4a60;
2747
+ font-size: 0.84rem;
2748
+ }
2749
+
2750
+ .avaliacao-knn-table-wrapper {
2751
+ max-height: min(44vh, 420px);
2752
+ }
2753
+
2754
+ @media (max-width: 900px) {
2755
+ .avaliacao-knn-map-wrap .map-frame {
2756
+ min-height: 340px;
2757
+ height: 340px;
2758
+ }
2759
+ }
2760
+
2761
  label {
2762
  font-weight: 700;
2763
  color: #394a5e;