Guilherme Silberfarb Costa commited on
Commit
4e2aace
·
1 Parent(s): c88d3e9

correcao de mapas

Browse files
backend/app/api/elaboracao.py CHANGED
@@ -143,6 +143,7 @@ class ExportModeloPayload(SessionPayload):
143
  class UpdateMapaPayload(SessionPayload):
144
  variavel_mapa: str | None = None
145
  modo_mapa: str | None = None
 
146
 
147
 
148
  class ColunaDataMercadoPayload(SessionPayload):
@@ -468,7 +469,12 @@ def map_update(payload: UpdateMapaPayload) -> dict[str, Any]:
468
  @router.post("/residuos/map/update")
469
  def residuos_map_update(payload: UpdateMapaPayload) -> dict[str, Any]:
470
  session = session_store.get(payload.session_id)
471
- return elaboracao_service.atualizar_mapa_residuos(session, payload.variavel_mapa, payload.modo_mapa)
 
 
 
 
 
472
 
473
 
474
  @router.post("/market-date/preview")
 
143
  class UpdateMapaPayload(SessionPayload):
144
  variavel_mapa: str | None = None
145
  modo_mapa: str | None = None
146
+ escala_extremo_abs: float | None = None
147
 
148
 
149
  class ColunaDataMercadoPayload(SessionPayload):
 
469
  @router.post("/residuos/map/update")
470
  def residuos_map_update(payload: UpdateMapaPayload) -> dict[str, Any]:
471
  session = session_store.get(payload.session_id)
472
+ return elaboracao_service.atualizar_mapa_residuos(
473
+ session,
474
+ payload.variavel_mapa,
475
+ payload.modo_mapa,
476
+ payload.escala_extremo_abs,
477
+ )
478
 
479
 
480
  @router.post("/market-date/preview")
backend/app/core/elaboracao/charts.py CHANGED
@@ -7,6 +7,7 @@ import numpy as np
7
  import pandas as pd
8
  import plotly.graph_objects as go
9
  from plotly.subplots import make_subplots
 
10
  from scipy import stats
11
  from scipy.interpolate import griddata
12
  from scipy.spatial import ConvexHull, QhullError
@@ -15,6 +16,7 @@ from folium import plugins
15
  import branca.colormap as cm
16
  from branca.element import Element
17
  from html import escape
 
18
  from app.core.map_layers import add_bairros_layer, add_indice_marker, add_zoom_responsive_circle_markers
19
 
20
  # ============================================================
@@ -606,6 +608,159 @@ def _mascara_dentro_poligono(x_grid: np.ndarray, y_grid: np.ndarray, poligono: n
606
  return inside
607
 
608
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  def _normalizar_stops_cor(
610
  cor_stops: list[float] | None,
611
  colors: list[str],
@@ -974,6 +1129,20 @@ def criar_mapa(
974
  # Camada de índices (oculta por padrão, ativável pelo controle de camadas)
975
  mostrar_indices = (not modo_calor and not modo_superficie) and len(df_mapa) <= 800
976
  camada_indices = folium.FeatureGroup(name="Índices", show=False) if mostrar_indices else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
977
 
978
  if modo_superficie:
979
  superficie_ok = _adicionar_superficie_continua(
@@ -1074,7 +1243,7 @@ def criar_mapa(
1074
  ).add_to(m)
1075
  elif not modo_superficie:
1076
  # Adiciona pontos
1077
- for idx, row in df_mapa.iterrows():
1078
  # Cor do ponto
1079
  if colormap and cor_col:
1080
  cor = colormap(row[cor_col])
@@ -1091,19 +1260,20 @@ def criar_mapa(
1091
  peso = 3 if idx == indice_destacado else 1
1092
 
1093
  # Popup com informações
1094
- popup_html = f"<b>Índice: {idx}</b><br>"
1095
- if len(df_mapa) <= 1200:
1096
- for col in df_mapa.columns:
1097
- if str(col).lower() not in ['lat', 'latitude', 'lon', 'longitude']:
1098
- val = row[col]
1099
- if isinstance(val, (int, float)):
1100
- popup_html += f"{col}: {val:.2f}<br>"
1101
- else:
1102
- popup_html += f"{col}: {val}<br>"
1103
- elif tamanho_col and tamanho_col in df_mapa.columns:
1104
- val = row[tamanho_col]
1105
- val_str = f"{val:.2f}" if isinstance(val, (int, float)) else str(val)
1106
- popup_html += f"{tamanho_col}: {val_str}<br>"
 
1107
 
1108
  # Tooltip (hover): índice + variável selecionada no dropdown
1109
  tooltip_html = (
@@ -1121,9 +1291,9 @@ def criar_mapa(
1121
  tooltip_html += "</div>"
1122
 
1123
  marcador = folium.CircleMarker(
1124
- location=[row[lat_real], row[lon_real]],
1125
  radius=raio,
1126
- popup=folium.Popup(popup_html, max_width=300, auto_pan=False),
1127
  tooltip=folium.Tooltip(tooltip_html, sticky=True),
1128
  color='black',
1129
  weight=peso,
@@ -1137,8 +1307,8 @@ def criar_mapa(
1137
  if mostrar_indices and camada_indices is not None:
1138
  add_indice_marker(
1139
  camada_indices,
1140
- lat=float(row[lat_real]),
1141
- lon=float(row[lon_real]),
1142
  indice=idx,
1143
  )
1144
 
 
7
  import pandas as pd
8
  import plotly.graph_objects as go
9
  from plotly.subplots import make_subplots
10
+ import math
11
  from scipy import stats
12
  from scipy.interpolate import griddata
13
  from scipy.spatial import ConvexHull, QhullError
 
16
  import branca.colormap as cm
17
  from branca.element import Element
18
  from html import escape
19
+ from typing import Any
20
  from app.core.map_layers import add_bairros_layer, add_indice_marker, add_zoom_responsive_circle_markers
21
 
22
  # ============================================================
 
608
  return inside
609
 
610
 
611
+ def _aplicar_jitter_sobrepostos(
612
+ df_mapa: pd.DataFrame,
613
+ lat_col: str,
614
+ lon_col: str,
615
+ lat_plot_col: str,
616
+ lon_plot_col: str,
617
+ ) -> pd.DataFrame:
618
+ """
619
+ Aplica jitter visual apenas em pontos com coordenadas idênticas.
620
+ Mantém as coordenadas originais intactas para cálculos e filtros.
621
+ """
622
+ df_plot = df_mapa.copy()
623
+ df_plot[lat_plot_col] = pd.to_numeric(df_plot[lat_col], errors="coerce")
624
+ df_plot[lon_plot_col] = pd.to_numeric(df_plot[lon_col], errors="coerce")
625
+
626
+ if len(df_plot) <= 1:
627
+ return df_plot
628
+
629
+ chave_lat = df_plot[lat_col].round(7)
630
+ chave_lon = df_plot[lon_col].round(7)
631
+ grupos = df_plot.groupby([chave_lat, chave_lon], sort=False)
632
+
633
+ passo_metros = 4.0
634
+ max_raio_metros = 22.0
635
+ metros_por_grau_lat = 111_320.0
636
+
637
+ for _, idx_labels in grupos.indices.items():
638
+ if len(idx_labels) <= 1:
639
+ continue
640
+ idx_list = list(idx_labels)
641
+ base_lat = float(df_plot.at[idx_list[0], lat_plot_col])
642
+ base_lon = float(df_plot.at[idx_list[0], lon_plot_col])
643
+ if not np.isfinite(base_lat) or not np.isfinite(base_lon):
644
+ continue
645
+
646
+ seed_val = int((abs(base_lat) * 1_000_000.0) + (abs(base_lon) * 1_000_000.0) * 3.0) % 360
647
+ angulo_base = math.radians(seed_val)
648
+ cos_lat = max(abs(math.cos(math.radians(base_lat))), 1e-6)
649
+ metros_por_grau_lon = metros_por_grau_lat * cos_lat
650
+
651
+ for pos, idx_label in enumerate(idx_list):
652
+ if pos == 0:
653
+ continue
654
+
655
+ pos_ring = pos - 1
656
+ ring = 1
657
+ while pos_ring >= (6 * ring):
658
+ pos_ring -= 6 * ring
659
+ ring += 1
660
+
661
+ slots_ring = max(6 * ring, 1)
662
+ angulo = angulo_base + (2.0 * math.pi * (pos_ring / slots_ring))
663
+ raio_m = min(ring * passo_metros, max_raio_metros)
664
+
665
+ delta_lat = (raio_m * math.sin(angulo)) / metros_por_grau_lat
666
+ delta_lon = (raio_m * math.cos(angulo)) / metros_por_grau_lon
667
+
668
+ df_plot.at[idx_label, lat_plot_col] = base_lat + delta_lat
669
+ df_plot.at[idx_label, lon_plot_col] = base_lon + delta_lon
670
+
671
+ return df_plot
672
+
673
+
674
+ def _montar_popup_registro_em_colunas(
675
+ idx: Any,
676
+ row: pd.Series,
677
+ allowed_cols: list[str],
678
+ max_itens_coluna: int = 8,
679
+ popup_uid: str | None = None,
680
+ ) -> tuple[str, int]:
681
+ itens: list[tuple[str, str]] = []
682
+ for col in allowed_cols:
683
+ if col not in row.index:
684
+ continue
685
+ col_txt = str(col)
686
+ col_low = col_txt.lower()
687
+ if col_txt.startswith("__mesa_"):
688
+ continue
689
+ if col_low in {"lat", "latitude", "lon", "longitude"}:
690
+ continue
691
+ val = row[col]
692
+ if isinstance(val, (int, float, np.floating, np.integer)) and np.isfinite(val):
693
+ val_fmt = f"{float(val):.2f}"
694
+ else:
695
+ val_fmt = str(val)
696
+ itens.append((col_txt, val_fmt))
697
+
698
+ if not itens:
699
+ return f"<b>Índice: {escape(str(idx))}</b>", 320
700
+
701
+ paginas = [itens[i:i + max_itens_coluna] for i in range(0, len(itens), max_itens_coluna)]
702
+ popup_uid = popup_uid or f"mesa-pop-{abs(hash(str(idx))) % 10_000_000}"
703
+
704
+ pages_html = []
705
+ botoes_html = []
706
+ for pagina_idx, pagina_itens in enumerate(paginas):
707
+ trs = "".join([
708
+ "<tr style='border-bottom:1px solid #e9ecef;'>"
709
+ f"<td style='padding:4px 8px 4px 0; color:#6c757d; font-weight:500;'>{escape(c)}</td>"
710
+ f"<td style='padding:4px 0; text-align:right; color:#495057;'>{escape(v)}</td>"
711
+ "</tr>"
712
+ for c, v in pagina_itens
713
+ ])
714
+ display = "block" if pagina_idx == 0 else "none"
715
+ pages_html.append(
716
+ f"<div class='mesa-popup-page' data-page='{pagina_idx}' style='display:{display};'>"
717
+ f"<table style='border-collapse:collapse; font-size:12px; width:100%;'>{trs}</table>"
718
+ "</div>"
719
+ )
720
+ botao_style = (
721
+ "border:1px solid #9fb4c8; background:#eaf1f7; border-radius:6px; "
722
+ "padding:2px 7px; font-size:11px; cursor:pointer; color:#2f4b66;"
723
+ if pagina_idx == 0
724
+ else
725
+ "border:1px solid #ced8e2; background:#fff; border-radius:6px; "
726
+ "padding:2px 7px; font-size:11px; cursor:pointer; color:#4e6479;"
727
+ )
728
+ onclick = (
729
+ f"var root=document.getElementById('{popup_uid}');"
730
+ "if(!root){return false;}"
731
+ "var pages=root.querySelectorAll('.mesa-popup-page');"
732
+ "for(var i=0;i<pages.length;i++){pages[i].style.display=(i==="
733
+ f"{pagina_idx}"
734
+ ")?'block':'none';}"
735
+ "var btns=root.querySelectorAll('[data-page-btn]');"
736
+ "for(var j=0;j<btns.length;j++){btns[j].style.background='#fff';btns[j].style.borderColor='#ced8e2';btns[j].style.color='#4e6479';}"
737
+ "this.style.background='#eaf1f7';this.style.borderColor='#9fb4c8';this.style.color='#2f4b66';"
738
+ "return false;"
739
+ )
740
+ botoes_html.append(
741
+ f"<button type='button' data-page-btn='1' style=\"{botao_style}\" onclick=\"{onclick}\">"
742
+ f"{pagina_idx + 1}</button>"
743
+ )
744
+
745
+ controls_html = ""
746
+ if len(paginas) > 1:
747
+ controls_html = (
748
+ "<div class='mesa-popup-controls' style='display:flex; gap:6px; flex-wrap:wrap; margin-top:8px; align-items:center;'>"
749
+ f"<span style='font-size:11px; color:#5d7388; margin-right:2px;'>Páginas:</span>{''.join(botoes_html)}"
750
+ "</div>"
751
+ )
752
+
753
+ popup_html = (
754
+ f"<div id='{popup_uid}' style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden;\">"
755
+ "<div style=\"background:#6c757d; color:white; padding:10px 15px; font-weight:600;\">Dados do Registro</div>"
756
+ "<div style=\"padding:12px 15px; background:#f8f9fa;\">"
757
+ f"<div class='mesa-popup-pages'>{''.join(pages_html)}</div>"
758
+ f"{controls_html}"
759
+ "</div></div>"
760
+ )
761
+ return popup_html, 430
762
+
763
+
764
  def _normalizar_stops_cor(
765
  cor_stops: list[float] | None,
766
  colors: list[str],
 
1129
  # Camada de índices (oculta por padrão, ativável pelo controle de camadas)
1130
  mostrar_indices = (not modo_calor and not modo_superficie) and len(df_mapa) <= 800
1131
  camada_indices = folium.FeatureGroup(name="Índices", show=False) if mostrar_indices else None
1132
+ lat_plot_col = "__mesa_lat_plot__"
1133
+ lon_plot_col = "__mesa_lon_plot__"
1134
+ if not modo_calor and not modo_superficie:
1135
+ df_plot_pontos = _aplicar_jitter_sobrepostos(
1136
+ df_mapa,
1137
+ lat_col=lat_real,
1138
+ lon_col=lon_real,
1139
+ lat_plot_col=lat_plot_col,
1140
+ lon_plot_col=lon_plot_col,
1141
+ )
1142
+ else:
1143
+ df_plot_pontos = df_mapa.copy()
1144
+ df_plot_pontos[lat_plot_col] = df_plot_pontos[lat_real]
1145
+ df_plot_pontos[lon_plot_col] = df_plot_pontos[lon_real]
1146
 
1147
  if modo_superficie:
1148
  superficie_ok = _adicionar_superficie_continua(
 
1243
  ).add_to(m)
1244
  elif not modo_superficie:
1245
  # Adiciona pontos
1246
+ for marker_ordem, (idx, row) in enumerate(df_plot_pontos.iterrows()):
1247
  # Cor do ponto
1248
  if colormap and cor_col:
1249
  cor = colormap(row[cor_col])
 
1260
  peso = 3 if idx == indice_destacado else 1
1261
 
1262
  # Popup com informações
1263
+ popup_cols: list[str]
1264
+ if len(df_plot_pontos) <= 1200:
1265
+ popup_cols = [str(c) for c in df_plot_pontos.columns]
1266
+ elif tamanho_col and tamanho_col in df_plot_pontos.columns:
1267
+ popup_cols = [str(tamanho_col)]
1268
+ else:
1269
+ popup_cols = []
1270
+ popup_html, popup_width = _montar_popup_registro_em_colunas(
1271
+ idx,
1272
+ row,
1273
+ popup_cols,
1274
+ max_itens_coluna=8,
1275
+ popup_uid=f"mesa-pop-{marker_ordem}",
1276
+ )
1277
 
1278
  # Tooltip (hover): índice + variável selecionada no dropdown
1279
  tooltip_html = (
 
1291
  tooltip_html += "</div>"
1292
 
1293
  marcador = folium.CircleMarker(
1294
+ location=[row[lat_plot_col], row[lon_plot_col]],
1295
  radius=raio,
1296
+ popup=folium.Popup(popup_html, max_width=popup_width, auto_pan=False),
1297
  tooltip=folium.Tooltip(tooltip_html, sticky=True),
1298
  color='black',
1299
  weight=peso,
 
1307
  if mostrar_indices and camada_indices is not None:
1308
  add_indice_marker(
1309
  camada_indices,
1310
+ lat=float(row[lat_plot_col]),
1311
+ lon=float(row[lon_plot_col]),
1312
  indice=idx,
1313
  )
1314
 
backend/app/core/visualizacao/app.py CHANGED
@@ -11,6 +11,7 @@ import os
11
  import re
12
  import traceback
13
  from datetime import datetime
 
14
 
15
 
16
  # Importações para gráficos (trazidas de graficos.py)
@@ -670,6 +671,135 @@ def formatar_escalas_html(escalas_raw):
670
 
671
  return f"""<div class="dai-card">{criar_titulo_secao_html("Escalas / Transformações")}<div class="dai-cards-grid" style="grid-template-columns: repeat(auto-fill, minmax({largura_min}px, 1fr));">{cards_html}</div></div>"""
672
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  # ============================================================
674
  # FUNÇÃO: GERAR MAPA FOLIUM (com suporte a dimensionamento por variável)
675
  # ============================================================
@@ -786,6 +916,15 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
786
 
787
  mostrar_indices = len(df_mapa) <= 800
788
  camada_indices = folium.FeatureGroup(name="Índices", show=False) if mostrar_indices else None
 
 
 
 
 
 
 
 
 
789
 
790
  tooltip_col = None
791
  tooltip_key = None
@@ -800,7 +939,7 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
800
  df_mapa[tooltip_key] = serie_tooltip
801
 
802
  # Adiciona pontos
803
- for idx, row in df_mapa.iterrows():
804
  # Cor do ponto
805
  if colormap and cor_key and pd.notna(row[cor_key]):
806
  cor = colormap(row[cor_key])
@@ -830,19 +969,11 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
830
  val_fmt = str(val)
831
  itens.append((col, val_fmt))
832
 
833
- MAX_ITENS = 8
834
- colunas_html = []
835
- for i in range(0, len(itens), MAX_ITENS):
836
- chunk = itens[i:i+MAX_ITENS]
837
- trs = "".join([f"<tr style='border-bottom: 1px solid #e9ecef;'><td style='padding:4px 8px 4px 0; color:#6c757d; font-weight:500;'>{c}</td><td style='padding:4px 0; text-align:right; color:#495057;'>{v}</td></tr>" for c, v in chunk])
838
- style = "border-left: 2px solid #dee2e6; padding-left: 20px;" if i > 0 else ""
839
- colunas_html.append(f"<div style='flex: 0 0 auto; {style}'><table style='border-collapse:collapse; font-size:12px;'>{trs}</table></div>")
840
-
841
- popup_html = f"""<div style="font-family:'Segoe UI'; border-radius:8px; overflow:hidden;"><div style="background:#6c757d; color:white; padding:10px 15px; font-weight:600;">Dados do Registro</div><div style="padding:12px 15px; background:#f8f9fa;"><div style="display:flex; gap:20px;">{"".join(colunas_html)}</div></div></div>"""
842
-
843
  # Tooltip (hover): índice + variável selecionada no dropdown (ou dependente como fallback)
844
  # Usa coluna "index" (original, gerada pelo reset_index) quando disponível
845
  idx_display = int(row["index"]) if "index" in row.index else idx
 
 
846
  tooltip_html = (
847
  "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px;"
848
  " line-height:1.7; padding:2px 4px;'>"
@@ -865,9 +996,9 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
865
  tooltip_html += "</div>"
866
 
867
  marcador = folium.CircleMarker(
868
- location=[row[lat_key], row[lon_key]],
869
  radius=raio,
870
- popup=folium.Popup(popup_html, max_width=280 * len(colunas_html)),
871
  tooltip=folium.Tooltip(tooltip_html, sticky=True),
872
  color='black',
873
  weight=1,
@@ -880,8 +1011,8 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
880
  if mostrar_indices and camada_indices is not None:
881
  add_indice_marker(
882
  camada_indices,
883
- lat=float(row[lat_key]),
884
- lon=float(row[lon_key]),
885
  indice=idx_display,
886
  )
887
 
 
11
  import re
12
  import traceback
13
  from datetime import datetime
14
+ from html import escape
15
 
16
 
17
  # Importações para gráficos (trazidas de graficos.py)
 
671
 
672
  return f"""<div class="dai-card">{criar_titulo_secao_html("Escalas / Transformações")}<div class="dai-cards-grid" style="grid-template-columns: repeat(auto-fill, minmax({largura_min}px, 1fr));">{cards_html}</div></div>"""
673
 
674
+
675
+ def _aplicar_jitter_sobrepostos(df_mapa, lat_col, lon_col, lat_plot_col, lon_plot_col):
676
+ """
677
+ Aplica jitter visual mínimo para separar pontos com coordenadas idênticas.
678
+ Não altera as coordenadas originais da base.
679
+ """
680
+ df_plot = df_mapa.copy()
681
+ df_plot[lat_plot_col] = pd.to_numeric(df_plot[lat_col], errors="coerce")
682
+ df_plot[lon_plot_col] = pd.to_numeric(df_plot[lon_col], errors="coerce")
683
+
684
+ if len(df_plot) <= 1:
685
+ return df_plot
686
+
687
+ chave_lat = df_plot[lat_col].round(7)
688
+ chave_lon = df_plot[lon_col].round(7)
689
+ grupos = df_plot.groupby([chave_lat, chave_lon], sort=False)
690
+
691
+ passo_metros = 4.0
692
+ max_raio_metros = 22.0
693
+ metros_por_grau_lat = 111_320.0
694
+
695
+ for _, idx_labels in grupos.indices.items():
696
+ if len(idx_labels) <= 1:
697
+ continue
698
+ idx_list = list(idx_labels)
699
+ base_lat = float(df_plot.at[idx_list[0], lat_plot_col])
700
+ base_lon = float(df_plot.at[idx_list[0], lon_plot_col])
701
+ if not np.isfinite(base_lat) or not np.isfinite(base_lon):
702
+ continue
703
+
704
+ seed_val = int((abs(base_lat) * 1_000_000.0) + (abs(base_lon) * 1_000_000.0) * 3.0) % 360
705
+ angulo_base = math.radians(seed_val)
706
+ cos_lat = max(abs(math.cos(math.radians(base_lat))), 1e-6)
707
+ metros_por_grau_lon = metros_por_grau_lat * cos_lat
708
+
709
+ for pos, idx_label in enumerate(idx_list):
710
+ if pos == 0:
711
+ continue
712
+
713
+ pos_ring = pos - 1
714
+ ring = 1
715
+ while pos_ring >= (6 * ring):
716
+ pos_ring -= 6 * ring
717
+ ring += 1
718
+
719
+ slots_ring = max(6 * ring, 1)
720
+ angulo = angulo_base + (2.0 * math.pi * (pos_ring / slots_ring))
721
+ raio_m = min(ring * passo_metros, max_raio_metros)
722
+
723
+ delta_lat = (raio_m * math.sin(angulo)) / metros_por_grau_lat
724
+ delta_lon = (raio_m * math.cos(angulo)) / metros_por_grau_lon
725
+
726
+ df_plot.at[idx_label, lat_plot_col] = base_lat + delta_lat
727
+ df_plot.at[idx_label, lon_plot_col] = base_lon + delta_lon
728
+
729
+ return df_plot
730
+
731
+
732
+ def _montar_popup_registro_paginado(itens, popup_uid, max_itens_pagina=8):
733
+ if not itens:
734
+ html = (
735
+ "<div style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden;\">"
736
+ "<div style=\"background:#6c757d; color:white; padding:10px 15px; font-weight:600;\">Dados do Registro</div>"
737
+ "<div style=\"padding:12px 15px; background:#f8f9fa; color:#6c757d; font-size:12px;\">Sem variáveis para exibir.</div>"
738
+ "</div>"
739
+ )
740
+ return html, 360
741
+
742
+ paginas = [itens[i:i + max_itens_pagina] for i in range(0, len(itens), max_itens_pagina)]
743
+ pages_html = []
744
+ botoes_html = []
745
+
746
+ for page_idx, page_items in enumerate(paginas):
747
+ trs = "".join([
748
+ "<tr style='border-bottom:1px solid #e9ecef;'>"
749
+ f"<td style='padding:4px 8px 4px 0; color:#6c757d; font-weight:500;'>{escape(str(c))}</td>"
750
+ f"<td style='padding:4px 0; text-align:right; color:#495057;'>{escape(str(v))}</td>"
751
+ "</tr>"
752
+ for c, v in page_items
753
+ ])
754
+ display = "block" if page_idx == 0 else "none"
755
+ pages_html.append(
756
+ f"<div class='mesa-popup-page' data-page='{page_idx}' style='display:{display};'>"
757
+ f"<table style='border-collapse:collapse; font-size:12px; width:100%;'>{trs}</table>"
758
+ "</div>"
759
+ )
760
+ botao_style = (
761
+ "border:1px solid #9fb4c8; background:#eaf1f7; border-radius:6px; "
762
+ "padding:2px 7px; font-size:11px; cursor:pointer; color:#2f4b66;"
763
+ if page_idx == 0
764
+ else
765
+ "border:1px solid #ced8e2; background:#fff; border-radius:6px; "
766
+ "padding:2px 7px; font-size:11px; cursor:pointer; color:#4e6479;"
767
+ )
768
+ onclick = (
769
+ f"var root=document.getElementById('{popup_uid}');"
770
+ "if(!root){return false;}"
771
+ "var pages=root.querySelectorAll('.mesa-popup-page');"
772
+ "for(var i=0;i<pages.length;i++){pages[i].style.display=(i==="
773
+ f"{page_idx}"
774
+ ")?'block':'none';}"
775
+ "var btns=root.querySelectorAll('[data-page-btn]');"
776
+ "for(var j=0;j<btns.length;j++){btns[j].style.background='#fff';btns[j].style.borderColor='#ced8e2';btns[j].style.color='#4e6479';}"
777
+ "this.style.background='#eaf1f7';this.style.borderColor='#9fb4c8';this.style.color='#2f4b66';"
778
+ "return false;"
779
+ )
780
+ botoes_html.append(
781
+ f"<button type='button' data-page-btn='1' style=\"{botao_style}\" onclick=\"{onclick}\">"
782
+ f"{page_idx + 1}</button>"
783
+ )
784
+
785
+ controls_html = ""
786
+ if len(paginas) > 1:
787
+ controls_html = (
788
+ "<div class='mesa-popup-controls' style='display:flex; gap:6px; flex-wrap:wrap; margin-top:8px; align-items:center;'>"
789
+ f"<span style='font-size:11px; color:#5d7388; margin-right:2px;'>Páginas:</span>{''.join(botoes_html)}"
790
+ "</div>"
791
+ )
792
+
793
+ html = (
794
+ f"<div id='{popup_uid}' style=\"font-family:'Segoe UI'; border-radius:8px; overflow:hidden;\">"
795
+ "<div style=\"background:#6c757d; color:white; padding:10px 15px; font-weight:600;\">Dados do Registro</div>"
796
+ "<div style=\"padding:12px 15px; background:#f8f9fa;\">"
797
+ f"<div class='mesa-popup-pages'>{''.join(pages_html)}</div>"
798
+ f"{controls_html}"
799
+ "</div></div>"
800
+ )
801
+ return html, 430
802
+
803
  # ============================================================
804
  # FUNÇÃO: GERAR MAPA FOLIUM (com suporte a dimensionamento por variável)
805
  # ============================================================
 
916
 
917
  mostrar_indices = len(df_mapa) <= 800
918
  camada_indices = folium.FeatureGroup(name="Índices", show=False) if mostrar_indices else None
919
+ lat_plot_key = "__mesa_lat_plot__"
920
+ lon_plot_key = "__mesa_lon_plot__"
921
+ df_plot_pontos = _aplicar_jitter_sobrepostos(
922
+ df_mapa,
923
+ lat_col=lat_key,
924
+ lon_col=lon_key,
925
+ lat_plot_col=lat_plot_key,
926
+ lon_plot_col=lon_plot_key,
927
+ )
928
 
929
  tooltip_col = None
930
  tooltip_key = None
 
939
  df_mapa[tooltip_key] = serie_tooltip
940
 
941
  # Adiciona pontos
942
+ for marker_ordem, (idx, row) in enumerate(df_plot_pontos.iterrows()):
943
  # Cor do ponto
944
  if colormap and cor_key and pd.notna(row[cor_key]):
945
  cor = colormap(row[cor_key])
 
969
  val_fmt = str(val)
970
  itens.append((col, val_fmt))
971
 
 
 
 
 
 
 
 
 
 
 
972
  # Tooltip (hover): índice + variável selecionada no dropdown (ou dependente como fallback)
973
  # Usa coluna "index" (original, gerada pelo reset_index) quando disponível
974
  idx_display = int(row["index"]) if "index" in row.index else idx
975
+ popup_uid = f"mesa-pop-{marker_ordem}"
976
+ popup_html, popup_width = _montar_popup_registro_paginado(itens, popup_uid, max_itens_pagina=8)
977
  tooltip_html = (
978
  "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px;"
979
  " line-height:1.7; padding:2px 4px;'>"
 
996
  tooltip_html += "</div>"
997
 
998
  marcador = folium.CircleMarker(
999
+ location=[row[lat_plot_key], row[lon_plot_key]],
1000
  radius=raio,
1001
+ popup=folium.Popup(popup_html, max_width=popup_width),
1002
  tooltip=folium.Tooltip(tooltip_html, sticky=True),
1003
  color='black',
1004
  weight=1,
 
1011
  if mostrar_indices and camada_indices is not None:
1012
  add_indice_marker(
1013
  camada_indices,
1014
+ lat=float(row[lat_plot_key]),
1015
+ lon=float(row[lon_plot_key]),
1016
  indice=idx_display,
1017
  )
1018
 
backend/app/services/elaboracao_service.py CHANGED
@@ -2114,7 +2114,45 @@ def atualizar_mapa(session: SessionState, var_mapa: str | None, modo_mapa: str |
2114
  return {"mapa_html": mapa_html}
2115
 
2116
 
2117
- def atualizar_mapa_residuos(session: SessionState, var_mapa: str | None, modo_mapa: str | None = None) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2118
  tabela_metricas = session.tabela_metricas_estado
2119
  if tabela_metricas is None or tabela_metricas.empty:
2120
  raise HTTPException(status_code=400, detail="Ajuste o modelo para gerar métricas de resíduos")
@@ -2127,23 +2165,35 @@ def atualizar_mapa_residuos(session: SessionState, var_mapa: str | None, modo_ma
2127
  modo = str(modo_mapa or "pontos").strip().lower()
2128
  if modo not in {"pontos", "calor", "superficie"}:
2129
  modo = "pontos"
 
 
 
 
 
 
 
 
 
 
 
 
2130
 
2131
  mapa_html = charts.criar_mapa(
2132
  df,
2133
  tamanho_col=var_escolhida,
2134
  modo=modo,
2135
- cor_vmin=-5.0,
2136
- cor_vmax=5.0,
2137
- cor_caption="Resíduo Pad. (escala fixa -5 a +5)",
2138
  cor_colors=["#2e7d32", "#f1c40f", "#ffffff", "#f1c40f", "#c62828"],
2139
- cor_stops=[-5.0, -2.0, 0.0, 2.0, 5.0],
2140
- cor_tick_values=[-5.0, -4.0, -3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
2141
- cor_tick_labels=["-5", "-4", "-3", "-2", "-1", "0", "1", "2", "3", "4", "5"],
2142
  )
2143
  return {
2144
  "mapa_html": mapa_html,
2145
  "variavel_mapa": var_escolhida,
2146
  "modo_mapa": modo,
 
2147
  }
2148
 
2149
 
 
2114
  return {"mapa_html": mapa_html}
2115
 
2116
 
2117
+ def _normalizar_extremo_abs_residuos(valor: float | None) -> float | None:
2118
+ if valor is None:
2119
+ return None
2120
+ try:
2121
+ extremo = float(valor)
2122
+ except (TypeError, ValueError):
2123
+ return None
2124
+ if not np.isfinite(extremo):
2125
+ return None
2126
+ if abs(extremo) <= 0:
2127
+ return None
2128
+ return float(np.clip(abs(extremo), 0.5, 20.0))
2129
+
2130
+
2131
+ def _montar_ticks_residuos(extremo_abs: float) -> tuple[list[float], list[str]]:
2132
+ inteiro = int(round(extremo_abs))
2133
+ if np.isclose(extremo_abs, inteiro) and inteiro <= 12:
2134
+ valores = [float(v) for v in range(-inteiro, inteiro + 1)] if inteiro > 0 else [-1.0, 0.0, 1.0]
2135
+ else:
2136
+ valores = [float(v) for v in np.linspace(-extremo_abs, extremo_abs, 11)]
2137
+
2138
+ def _label(v: float) -> str:
2139
+ if np.isclose(v, round(v)):
2140
+ return str(int(round(v)))
2141
+ return f"{v:.2f}".rstrip("0").rstrip(".")
2142
+
2143
+ labels = [_label(v) for v in valores]
2144
+ if labels:
2145
+ labels[0] = f"<= {labels[0]}"
2146
+ labels[-1] = f">= {labels[-1]}"
2147
+ return valores, labels
2148
+
2149
+
2150
+ def atualizar_mapa_residuos(
2151
+ session: SessionState,
2152
+ var_mapa: str | None,
2153
+ modo_mapa: str | None = None,
2154
+ escala_extremo_abs: float | None = None,
2155
+ ) -> dict[str, Any]:
2156
  tabela_metricas = session.tabela_metricas_estado
2157
  if tabela_metricas is None or tabela_metricas.empty:
2158
  raise HTTPException(status_code=400, detail="Ajuste o modelo para gerar métricas de resíduos")
 
2165
  modo = str(modo_mapa or "pontos").strip().lower()
2166
  if modo not in {"pontos", "calor", "superficie"}:
2167
  modo = "pontos"
2168
+ extremo_abs = _normalizar_extremo_abs_residuos(escala_extremo_abs)
2169
+ cor_vmin = None if extremo_abs is None else -extremo_abs
2170
+ cor_vmax = None if extremo_abs is None else extremo_abs
2171
+ ticks_valores, ticks_labels = (None, None)
2172
+ if extremo_abs is not None:
2173
+ ticks_valores, ticks_labels = _montar_ticks_residuos(extremo_abs)
2174
+ extremo_txt = None if extremo_abs is None else f"{extremo_abs:.2f}".rstrip("0").rstrip(".")
2175
+ caption = (
2176
+ "Resíduo Pad. (escala livre conforme limites dos dados)"
2177
+ if extremo_abs is None
2178
+ else f"Resíduo Pad. (escala fixa -{extremo_txt} a +{extremo_txt})"
2179
+ )
2180
 
2181
  mapa_html = charts.criar_mapa(
2182
  df,
2183
  tamanho_col=var_escolhida,
2184
  modo=modo,
2185
+ cor_vmin=cor_vmin,
2186
+ cor_vmax=cor_vmax,
2187
+ cor_caption=caption,
2188
  cor_colors=["#2e7d32", "#f1c40f", "#ffffff", "#f1c40f", "#c62828"],
2189
+ cor_tick_values=ticks_valores,
2190
+ cor_tick_labels=ticks_labels,
 
2191
  )
2192
  return {
2193
  "mapa_html": mapa_html,
2194
  "variavel_mapa": var_escolhida,
2195
  "modo_mapa": modo,
2196
+ "escala_extremo_abs": extremo_abs,
2197
  }
2198
 
2199
 
frontend/src/App.jsx CHANGED
@@ -18,7 +18,7 @@ const TABS = [
18
  ]
19
 
20
  export default function App() {
21
- const [activeTab, setActiveTab] = useState(TABS[0].key)
22
  const [showStartupIntro, setShowStartupIntro] = useState(true)
23
  const [sessionId, setSessionId] = useState('')
24
  const [bootError, setBootError] = useState('')
@@ -61,7 +61,7 @@ export default function App() {
61
  setLogsEvents([])
62
  setLogsError('')
63
  setLogsPage(1)
64
- setActiveTab(TABS[0].key)
65
  setShowStartupIntro(true)
66
  setAuthError(message)
67
  }
 
18
  ]
19
 
20
  export default function App() {
21
+ const [activeTab, setActiveTab] = useState('')
22
  const [showStartupIntro, setShowStartupIntro] = useState(true)
23
  const [sessionId, setSessionId] = useState('')
24
  const [bootError, setBootError] = useState('')
 
61
  setLogsEvents([])
62
  setLogsError('')
63
  setLogsPage(1)
64
+ setActiveTab('')
65
  setShowStartupIntro(true)
66
  setAuthError(message)
67
  }
frontend/src/api.js CHANGED
@@ -230,10 +230,11 @@ export const api = {
230
  variavel_mapa: variavelMapa,
231
  modo_mapa: modoMapa,
232
  }),
233
- updateElaboracaoResiduosMap: (sessionId, variavelMapa, modoMapa = 'pontos') => postJson('/api/elaboracao/residuos/map/update', {
234
  session_id: sessionId,
235
  variavel_mapa: variavelMapa,
236
  modo_mapa: modoMapa,
 
237
  }),
238
  previewMarketDateColumn: (sessionId, colunaData) => postJson('/api/elaboracao/market-date/preview', { session_id: sessionId, coluna_data: colunaData }),
239
  applyMarketDateColumn: (sessionId, colunaData) => postJson('/api/elaboracao/market-date/apply', { session_id: sessionId, coluna_data: colunaData }),
 
230
  variavel_mapa: variavelMapa,
231
  modo_mapa: modoMapa,
232
  }),
233
+ updateElaboracaoResiduosMap: (sessionId, variavelMapa, modoMapa = 'pontos', escalaExtremoAbs = null) => postJson('/api/elaboracao/residuos/map/update', {
234
  session_id: sessionId,
235
  variavel_mapa: variavelMapa,
236
  modo_mapa: modoMapa,
237
+ escala_extremo_abs: escalaExtremoAbs,
238
  }),
239
  previewMarketDateColumn: (sessionId, colunaData) => postJson('/api/elaboracao/market-date/preview', { session_id: sessionId, coluna_data: colunaData }),
240
  applyMarketDateColumn: (sessionId, colunaData) => postJson('/api/elaboracao/market-date/apply', { session_id: sessionId, coluna_data: colunaData }),
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -33,6 +33,9 @@ const MAPA_MODO_PONTOS = 'pontos'
33
  const MAPA_MODO_CALOR = 'calor'
34
  const MAPA_MODO_SUPERFICIE = 'superficie'
35
  const MAPA_RESIDUOS_VARIAVEL = 'Resíduo Pad.'
 
 
 
36
  const OUTLIER_RECURSIVO_TOOLTIP = 'Aplicar com recursividade executa os mesmos filtros em ciclos sucessivos: nos bastidores, simula a exclusão dos índices encontrados, recalcula o ajuste do modelo e as métricas de outlier e reaplica os filtros, repetindo até não surgir nenhum índice novo. Para você, o resultado prático é que o campo "A excluir" é preenchido automaticamente com o conjunto total de índices encontrados nessa simulação recursiva.'
37
 
38
  function grauBadgeClass(value) {
@@ -166,6 +169,36 @@ function formatMetric4(value) {
166
  return Number.isFinite(num) ? num.toFixed(4) : '-'
167
  }
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  function sleep(ms) {
170
  return new Promise((resolve) => {
171
  window.setTimeout(resolve, ms)
@@ -704,6 +737,7 @@ export default function ElaboracaoTab({ sessionId }) {
704
  const [mapaGerado, setMapaGerado] = useState(false)
705
  const [mapaResiduosHtml, setMapaResiduosHtml] = useState('')
706
  const [mapaResiduosModo, setMapaResiduosModo] = useState(MAPA_MODO_PONTOS)
 
707
  const [mapaResiduosGerado, setMapaResiduosGerado] = useState(false)
708
 
709
  const [coordsInfo, setCoordsInfo] = useState(null)
@@ -992,6 +1026,46 @@ export default function ElaboracaoTab({ sessionId }) {
992
 
993
  return Array.from(indices)
994
  }, [fit?.tabela_metricas, filtros, outlierHighlightIndexColumn])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
995
  const transformacaoAplicadaYBadge = useMemo(
996
  () => formatTransformacaoBadge(transformacoesAplicadas?.transformacao_y),
997
  [transformacoesAplicadas],
@@ -1675,6 +1749,7 @@ export default function ElaboracaoTab({ sessionId }) {
1675
  setSecao13InterativoEixoYResiduo('residuo_pad')
1676
  setSecao13InterativoEixoYColuna('')
1677
  setMapaResiduosModo(MAPA_MODO_PONTOS)
 
1678
  setMapaResiduosHtml('')
1679
  setMapaResiduosGerado(false)
1680
  const transformacaoYAplicada = resp.transformacao_y || transformacaoY
@@ -1824,6 +1899,7 @@ export default function ElaboracaoTab({ sessionId }) {
1824
  setMapaResiduosGerado(false)
1825
  setMapaResiduosHtml('')
1826
  setMapaResiduosModo(MAPA_MODO_PONTOS)
 
1827
  setGeoAuto200(true)
1828
  setSelectedSheet('')
1829
  setRequiresSheet(false)
@@ -1854,6 +1930,7 @@ export default function ElaboracaoTab({ sessionId }) {
1854
  setMapaResiduosGerado(false)
1855
  setMapaResiduosHtml('')
1856
  setMapaResiduosModo(MAPA_MODO_PONTOS)
 
1857
  setGeoAuto200(true)
1858
  setSelectedSheet('')
1859
  setRequiresSheet(false)
@@ -1913,6 +1990,7 @@ export default function ElaboracaoTab({ sessionId }) {
1913
  setMapaResiduosGerado(false)
1914
  setMapaResiduosHtml('')
1915
  setMapaResiduosModo(MAPA_MODO_PONTOS)
 
1916
  setGeoAuto200(true)
1917
  const resp = await api.confirmSheet(sessionId, selectedSheet)
1918
  setTipoFonteDados('tabular')
@@ -2677,17 +2755,48 @@ export default function ElaboracaoTab({ sessionId }) {
2677
  async function onMapaResiduosModoChange(value) {
2678
  setMapaResiduosModo(value)
2679
  if (!sessionId || !mapaResiduosGerado) return
 
2680
  await withBusy(async () => {
2681
- const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, value)
2682
  setMapaResiduosHtml(resp.mapa_html || '')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2683
  })
2684
  }
2685
 
2686
  async function onGerarMapaResiduos() {
2687
  if (!sessionId) return
 
2688
  await withBusy(async () => {
2689
- const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, mapaResiduosModo)
2690
  setMapaResiduosHtml(resp.mapa_html || '')
 
 
 
 
 
 
2691
  setMapaResiduosGerado(true)
2692
  })
2693
  }
@@ -4693,62 +4802,122 @@ export default function ElaboracaoTab({ sessionId }) {
4693
  </SectionBlock>
4694
 
4695
  <SectionBlock step="16" title="Analisar Resíduos" subtitle="Métricas para identificação de observações influentes.">
4696
- <details className="dados-mapa-details" open>
4697
- <summary>Mapa de resíduos padronizados</summary>
4698
- {!mapaResiduosGerado ? (
4699
- <div className="empty-box">
4700
- <div className="row">
4701
- <button type="button" className="btn-gerar-mapa" onClick={onGerarMapaResiduos} disabled={loading}>
4702
- Gerar Mapa de Resíduos Padronizados
4703
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4704
  </div>
4705
- <div className="section1-empty-hint">O mapa de resíduos padronizados será carregado somente após solicitação explícita.</div>
4706
  </div>
4707
  ) : (
4708
- <>
4709
- <div className="dados-mapa-controls">
4710
- <div className="dados-mapa-control-field">
4711
- <label>Visualização</label>
4712
- <select value={mapaResiduosModo} onChange={(e) => onMapaResiduosModoChange(e.target.value)}>
4713
- <option value={MAPA_MODO_PONTOS}>Pontos</option>
4714
- <option value={MAPA_MODO_CALOR}>Mapa de calor</option>
4715
- <option value={MAPA_MODO_SUPERFICIE}>Superfície contínua</option>
4716
- </select>
 
 
 
 
 
4717
  </div>
 
4718
  </div>
4719
- <div className="download-actions-bar">
4720
- <button
4721
- type="button"
4722
- className="btn-download-subtle"
4723
- onClick={onDownloadMapaSecao16}
4724
- disabled={loading || downloadingAssets || !mapaResiduosHtml}
4725
- >
4726
- Fazer download
4727
- </button>
4728
- </div>
4729
- <MapFrame html={mapaResiduosHtml} />
4730
- </>
4731
- )}
4732
- </details>
4733
- <div className="download-actions-bar">
4734
- <button
4735
- type="button"
4736
- className="btn-download-subtle"
4737
- onClick={() => onDownloadTableCsv(fit.tabela_metricas, 'secao16_tabela_metricas')}
4738
- disabled={loading || downloadingAssets || !fit.tabela_metricas}
4739
- >
4740
- Fazer download
4741
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4742
  </div>
4743
- <div className="outlier-highlight-note">
4744
- Linhas amarelas indicam observações que atendem aos filtros definidos na seção 17.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4745
  </div>
4746
- <DataTable
4747
- table={fit.tabela_metricas}
4748
- maxHeight={320}
4749
- highlightedRowIndices={outlierRowsHighlight}
4750
- highlightIndexColumn={outlierHighlightIndexColumn}
4751
- />
4752
  </SectionBlock>
4753
 
4754
  <SectionBlock step="17" title="Exclusão ou Reinclusão de Outliers" subtitle="Filtre índices, revise e atualize o modelo.">
 
33
  const MAPA_MODO_CALOR = 'calor'
34
  const MAPA_MODO_SUPERFICIE = 'superficie'
35
  const MAPA_RESIDUOS_VARIAVEL = 'Resíduo Pad.'
36
+ const MAPA_RESIDUOS_EXTREMO_LIVRE = 'livre'
37
+ const MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT = MAPA_RESIDUOS_EXTREMO_LIVRE
38
+ const MAPA_RESIDUOS_EXTREMO_ABS_OPTIONS = [2, 3, 4, 5, 6, 7, 8, 10]
39
  const OUTLIER_RECURSIVO_TOOLTIP = 'Aplicar com recursividade executa os mesmos filtros em ciclos sucessivos: nos bastidores, simula a exclusão dos índices encontrados, recalcula o ajuste do modelo e as métricas de outlier e reaplica os filtros, repetindo até não surgir nenhum índice novo. Para você, o resultado prático é que o campo "A excluir" é preenchido automaticamente com o conjunto total de índices encontrados nessa simulação recursiva.'
40
 
41
  function grauBadgeClass(value) {
 
169
  return Number.isFinite(num) ? num.toFixed(4) : '-'
170
  }
171
 
172
+ function formatNumberBr(value, maximumFractionDigits = 4) {
173
+ const num = Number(value)
174
+ if (!Number.isFinite(num)) return '-'
175
+ return num.toLocaleString('pt-BR', {
176
+ minimumFractionDigits: 0,
177
+ maximumFractionDigits,
178
+ })
179
+ }
180
+
181
+ function quantileLinear(sortedValues, q) {
182
+ if (!Array.isArray(sortedValues) || sortedValues.length === 0) return null
183
+ const clampedQ = Math.min(1, Math.max(0, Number(q)))
184
+ const pos = (sortedValues.length - 1) * clampedQ
185
+ const low = Math.floor(pos)
186
+ const high = Math.ceil(pos)
187
+ const lowValue = Number(sortedValues[low])
188
+ const highValue = Number(sortedValues[high])
189
+ if (!Number.isFinite(lowValue) || !Number.isFinite(highValue)) return null
190
+ if (low === high) return lowValue
191
+ return lowValue + ((highValue - lowValue) * (pos - low))
192
+ }
193
+
194
+ function parseMapaResiduosExtremoAbs(value) {
195
+ const raw = String(value ?? '').trim().toLowerCase()
196
+ if (!raw || raw === MAPA_RESIDUOS_EXTREMO_LIVRE) return null
197
+ const parsed = Number(raw)
198
+ if (!Number.isFinite(parsed) || parsed <= 0) return null
199
+ return parsed
200
+ }
201
+
202
  function sleep(ms) {
203
  return new Promise((resolve) => {
204
  window.setTimeout(resolve, ms)
 
737
  const [mapaGerado, setMapaGerado] = useState(false)
738
  const [mapaResiduosHtml, setMapaResiduosHtml] = useState('')
739
  const [mapaResiduosModo, setMapaResiduosModo] = useState(MAPA_MODO_PONTOS)
740
+ const [mapaResiduosExtremoAbs, setMapaResiduosExtremoAbs] = useState(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
741
  const [mapaResiduosGerado, setMapaResiduosGerado] = useState(false)
742
 
743
  const [coordsInfo, setCoordsInfo] = useState(null)
 
1026
 
1027
  return Array.from(indices)
1028
  }, [fit?.tabela_metricas, filtros, outlierHighlightIndexColumn])
1029
+ const resumoResiduoPadStats = useMemo(() => {
1030
+ const rows = fit?.tabela_metricas?.rows
1031
+ if (!Array.isArray(rows) || rows.length === 0) return null
1032
+
1033
+ const colunasCandidatas = ['Resíduo Pad.', 'Residuo Pad.', 'Resíduo Padronizado', 'Residuo Padronizado']
1034
+ let colunaResiduo = ''
1035
+ for (let i = 0; i < colunasCandidatas.length; i += 1) {
1036
+ const candidata = colunasCandidatas[i]
1037
+ if (rows.some((row) => row && typeof row === 'object' && Object.prototype.hasOwnProperty.call(row, candidata))) {
1038
+ colunaResiduo = candidata
1039
+ break
1040
+ }
1041
+ }
1042
+ if (!colunaResiduo) return null
1043
+
1044
+ const valores = rows
1045
+ .map((row) => toFiniteNumber(row?.[colunaResiduo]))
1046
+ .filter((item) => item !== null)
1047
+ .map((item) => Number(item))
1048
+
1049
+ if (valores.length === 0) return null
1050
+ const ordenados = [...valores].sort((a, b) => a - b)
1051
+ const min = ordenados[0]
1052
+ const max = ordenados[ordenados.length - 1]
1053
+ const q1 = quantileLinear(ordenados, 0.25)
1054
+ const mediana = quantileLinear(ordenados, 0.5)
1055
+ const q3 = quantileLinear(ordenados, 0.75)
1056
+
1057
+ return {
1058
+ minimo: min,
1059
+ q1,
1060
+ mediana,
1061
+ q3,
1062
+ maximo: max,
1063
+ }
1064
+ }, [fit?.tabela_metricas?.rows])
1065
+ const mapaResiduosExtremoAbsAtivo = useMemo(
1066
+ () => parseMapaResiduosExtremoAbs(mapaResiduosExtremoAbs),
1067
+ [mapaResiduosExtremoAbs],
1068
+ )
1069
  const transformacaoAplicadaYBadge = useMemo(
1070
  () => formatTransformacaoBadge(transformacoesAplicadas?.transformacao_y),
1071
  [transformacoesAplicadas],
 
1749
  setSecao13InterativoEixoYResiduo('residuo_pad')
1750
  setSecao13InterativoEixoYColuna('')
1751
  setMapaResiduosModo(MAPA_MODO_PONTOS)
1752
+ setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
1753
  setMapaResiduosHtml('')
1754
  setMapaResiduosGerado(false)
1755
  const transformacaoYAplicada = resp.transformacao_y || transformacaoY
 
1899
  setMapaResiduosGerado(false)
1900
  setMapaResiduosHtml('')
1901
  setMapaResiduosModo(MAPA_MODO_PONTOS)
1902
+ setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
1903
  setGeoAuto200(true)
1904
  setSelectedSheet('')
1905
  setRequiresSheet(false)
 
1930
  setMapaResiduosGerado(false)
1931
  setMapaResiduosHtml('')
1932
  setMapaResiduosModo(MAPA_MODO_PONTOS)
1933
+ setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
1934
  setGeoAuto200(true)
1935
  setSelectedSheet('')
1936
  setRequiresSheet(false)
 
1990
  setMapaResiduosGerado(false)
1991
  setMapaResiduosHtml('')
1992
  setMapaResiduosModo(MAPA_MODO_PONTOS)
1993
+ setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
1994
  setGeoAuto200(true)
1995
  const resp = await api.confirmSheet(sessionId, selectedSheet)
1996
  setTipoFonteDados('tabular')
 
2755
  async function onMapaResiduosModoChange(value) {
2756
  setMapaResiduosModo(value)
2757
  if (!sessionId || !mapaResiduosGerado) return
2758
+ const extremoAbs = parseMapaResiduosExtremoAbs(mapaResiduosExtremoAbs)
2759
  await withBusy(async () => {
2760
+ const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, value, extremoAbs)
2761
  setMapaResiduosHtml(resp.mapa_html || '')
2762
+ const extremoResp = Number(resp?.escala_extremo_abs)
2763
+ if (Number.isFinite(extremoResp) && extremoResp > 0) {
2764
+ setMapaResiduosExtremoAbs(String(Number(extremoResp)))
2765
+ } else {
2766
+ setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_LIVRE)
2767
+ }
2768
+ })
2769
+ }
2770
+
2771
+ async function onMapaResiduosExtremoAbsChange(value) {
2772
+ const raw = String(value || MAPA_RESIDUOS_EXTREMO_LIVRE)
2773
+ setMapaResiduosExtremoAbs(raw)
2774
+ const extremoAbs = parseMapaResiduosExtremoAbs(raw)
2775
+ if (!sessionId || !mapaResiduosGerado) return
2776
+ await withBusy(async () => {
2777
+ const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, mapaResiduosModo, extremoAbs)
2778
+ setMapaResiduosHtml(resp.mapa_html || '')
2779
+ const extremoResp = Number(resp?.escala_extremo_abs)
2780
+ if (Number.isFinite(extremoResp) && extremoResp > 0) {
2781
+ setMapaResiduosExtremoAbs(String(Number(extremoResp)))
2782
+ } else {
2783
+ setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_LIVRE)
2784
+ }
2785
  })
2786
  }
2787
 
2788
  async function onGerarMapaResiduos() {
2789
  if (!sessionId) return
2790
+ const extremoAbs = parseMapaResiduosExtremoAbs(mapaResiduosExtremoAbs)
2791
  await withBusy(async () => {
2792
+ const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, mapaResiduosModo, extremoAbs)
2793
  setMapaResiduosHtml(resp.mapa_html || '')
2794
+ const extremoResp = Number(resp?.escala_extremo_abs)
2795
+ if (Number.isFinite(extremoResp) && extremoResp > 0) {
2796
+ setMapaResiduosExtremoAbs(String(Number(extremoResp)))
2797
+ } else {
2798
+ setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_LIVRE)
2799
+ }
2800
  setMapaResiduosGerado(true)
2801
  })
2802
  }
 
4802
  </SectionBlock>
4803
 
4804
  <SectionBlock step="16" title="Analisar Resíduos" subtitle="Métricas para identificação de observações influentes.">
4805
+ <div className="sec16-subsection">
4806
+ <h5 className="sec16-subtitle">Resumo Estatístico dos Resíduos Padronizados</h5>
4807
+ {resumoResiduoPadStats ? (
4808
+ <div className="residuos-stats-box">
4809
+ <div className="residuos-stats-grid">
4810
+ <div className="residuos-stats-item">
4811
+ <span>Mínimo</span>
4812
+ <strong>{formatNumberBr(resumoResiduoPadStats.minimo)}</strong>
4813
+ </div>
4814
+ <div className="residuos-stats-item">
4815
+ <span>1º Quartil</span>
4816
+ <strong>{formatNumberBr(resumoResiduoPadStats.q1)}</strong>
4817
+ </div>
4818
+ <div className="residuos-stats-item">
4819
+ <span>Mediana</span>
4820
+ <strong>{formatNumberBr(resumoResiduoPadStats.mediana)}</strong>
4821
+ </div>
4822
+ <div className="residuos-stats-item">
4823
+ <span>3º Quartil</span>
4824
+ <strong>{formatNumberBr(resumoResiduoPadStats.q3)}</strong>
4825
+ </div>
4826
+ <div className="residuos-stats-item">
4827
+ <span>Máximo</span>
4828
+ <strong>{formatNumberBr(resumoResiduoPadStats.maximo)}</strong>
4829
+ </div>
4830
  </div>
 
4831
  </div>
4832
  ) : (
4833
+ <div className="section1-empty-hint">Resumo indisponível para os resíduos padronizados.</div>
4834
+ )}
4835
+ </div>
4836
+
4837
+ <div className="sec16-subsection">
4838
+ <h5 className="sec16-subtitle">Mapa de Resíduos Padronizados</h5>
4839
+ <details className="dados-mapa-details" open>
4840
+ <summary>Mostrar/Ocultar mapa</summary>
4841
+ {!mapaResiduosGerado ? (
4842
+ <div className="empty-box">
4843
+ <div className="row">
4844
+ <button type="button" className="btn-gerar-mapa" onClick={onGerarMapaResiduos} disabled={loading}>
4845
+ Gerar Mapa de Resíduos Padronizados
4846
+ </button>
4847
  </div>
4848
+ <div className="section1-empty-hint">O mapa de resíduos padronizados será carregado somente após solicitação explícita.</div>
4849
  </div>
4850
+ ) : (
4851
+ <>
4852
+ <div className="dados-mapa-controls">
4853
+ <div className="dados-mapa-control-field">
4854
+ <label>Visualização</label>
4855
+ <select value={mapaResiduosModo} onChange={(e) => onMapaResiduosModoChange(e.target.value)}>
4856
+ <option value={MAPA_MODO_PONTOS}>Pontos</option>
4857
+ <option value={MAPA_MODO_CALOR}>Mapa de calor</option>
4858
+ <option value={MAPA_MODO_SUPERFICIE}>Superfície contínua</option>
4859
+ </select>
4860
+ </div>
4861
+ <div className="dados-mapa-control-field">
4862
+ <label>Extremos da escala (abs.)</label>
4863
+ <select
4864
+ value={String(mapaResiduosExtremoAbs)}
4865
+ onChange={(e) => {
4866
+ void onMapaResiduosExtremoAbsChange(e.target.value)
4867
+ }}
4868
+ >
4869
+ <option value={MAPA_RESIDUOS_EXTREMO_LIVRE}>Livre (limites dos dados)</option>
4870
+ {MAPA_RESIDUOS_EXTREMO_ABS_OPTIONS.map((valor) => (
4871
+ <option key={`mapa-res-ext-${valor}`} value={String(valor)}>
4872
+ ±{formatNumberBr(valor, 1)}
4873
+ </option>
4874
+ ))}
4875
+ </select>
4876
+ </div>
4877
+ </div>
4878
+ <div className="residuos-map-scale-hint">
4879
+ {mapaResiduosExtremoAbsAtivo === null
4880
+ ? 'Escala livre: os extremos seguem os limites observados dos resíduos padronizados.'
4881
+ : `Escala fixa: valores ≤ -${formatNumberBr(mapaResiduosExtremoAbsAtivo, 2)} e ≥ ${formatNumberBr(mapaResiduosExtremoAbsAtivo, 2)} usam as cores máximas.`}
4882
+ </div>
4883
+ <div className="download-actions-bar">
4884
+ <button
4885
+ type="button"
4886
+ className="btn-download-subtle"
4887
+ onClick={onDownloadMapaSecao16}
4888
+ disabled={loading || downloadingAssets || !mapaResiduosHtml}
4889
+ >
4890
+ Fazer download
4891
+ </button>
4892
+ </div>
4893
+ <MapFrame html={mapaResiduosHtml} />
4894
+ </>
4895
+ )}
4896
+ </details>
4897
  </div>
4898
+
4899
+ <div className="sec16-subsection">
4900
+ <h5 className="sec16-subtitle">Dados de Resíduos</h5>
4901
+ <div className="download-actions-bar">
4902
+ <button
4903
+ type="button"
4904
+ className="btn-download-subtle"
4905
+ onClick={() => onDownloadTableCsv(fit.tabela_metricas, 'secao16_tabela_metricas')}
4906
+ disabled={loading || downloadingAssets || !fit.tabela_metricas}
4907
+ >
4908
+ Fazer download
4909
+ </button>
4910
+ </div>
4911
+ <div className="outlier-highlight-note">
4912
+ Linhas amarelas indicam observações que atendem aos filtros definidos na seção 17.
4913
+ </div>
4914
+ <DataTable
4915
+ table={fit.tabela_metricas}
4916
+ maxHeight={320}
4917
+ highlightedRowIndices={outlierRowsHighlight}
4918
+ highlightIndexColumn={outlierHighlightIndexColumn}
4919
+ />
4920
  </div>
 
 
 
 
 
 
4921
  </SectionBlock>
4922
 
4923
  <SectionBlock step="17" title="Exclusão ou Reinclusão de Outliers" subtitle="Filtre índices, revise e atualize o modelo.">
frontend/src/styles.css CHANGED
@@ -927,6 +927,57 @@ textarea {
927
  margin-top: 6px;
928
  }
929
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
930
  .dispersao-config-row {
931
  align-items: flex-end;
932
  gap: 14px;
 
927
  margin-top: 6px;
928
  }
929
 
930
+ .sec16-subsection {
931
+ border: 1px solid #dfe7ef;
932
+ border-radius: 10px;
933
+ background: #fbfdff;
934
+ padding: 10px;
935
+ margin-bottom: 10px;
936
+ }
937
+
938
+ .sec16-subtitle {
939
+ margin: 0 0 8px;
940
+ font-family: 'Sora', sans-serif;
941
+ font-size: 0.86rem;
942
+ color: #2e4459;
943
+ }
944
+
945
+ .residuos-stats-box {
946
+ margin-bottom: 0;
947
+ }
948
+
949
+ .residuos-stats-grid {
950
+ display: grid;
951
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
952
+ gap: 8px;
953
+ }
954
+
955
+ .residuos-stats-item {
956
+ border: 1px solid #dce6ef;
957
+ background: #f7fafc;
958
+ border-radius: 8px;
959
+ padding: 6px 8px;
960
+ display: grid;
961
+ gap: 1px;
962
+ }
963
+
964
+ .residuos-stats-item span {
965
+ font-size: 0.72rem;
966
+ color: #667d92;
967
+ }
968
+
969
+ .residuos-stats-item strong {
970
+ font-family: 'Sora', sans-serif;
971
+ font-size: 0.84rem;
972
+ color: #2a3f53;
973
+ }
974
+
975
+ .residuos-map-scale-hint {
976
+ font-size: 0.74rem;
977
+ color: #5b7085;
978
+ margin: -4px 0 6px;
979
+ }
980
+
981
  .dispersao-config-row {
982
  align-items: flex-end;
983
  gap: 14px;