Guilherme Silberfarb Costa commited on
Commit
b0eaf10
·
1 Parent(s): c4b0839

alteracoes generalizadas de mapas e residuos

Browse files
backend/app/api/elaboracao.py CHANGED
@@ -36,6 +36,7 @@ class GeocodePayload(SessionPayload):
36
 
37
  class CorrecaoGeo(BaseModel):
38
  linha: int
 
39
  numero_corrigido: str | None = None
40
 
41
 
@@ -77,7 +78,10 @@ class FitModelPayload(SessionPayload):
77
 
78
 
79
  class DispersaoPayload(SessionPayload):
80
- tipo: str
 
 
 
81
 
82
 
83
  class TransformPreviewPayload(SessionPayload):
@@ -128,6 +132,7 @@ class ExportModeloPayload(SessionPayload):
128
 
129
  class UpdateMapaPayload(SessionPayload):
130
  variavel_mapa: str | None = None
 
131
 
132
 
133
  class ColunaDataMercadoPayload(SessionPayload):
@@ -270,7 +275,13 @@ def fit_model(payload: FitModelPayload) -> dict[str, Any]:
270
  @router.post("/model-dispersao")
271
  def model_dispersao(payload: DispersaoPayload) -> dict[str, Any]:
272
  session = session_store.get(payload.session_id)
273
- return elaboracao_service.gerar_grafico_dispersao_modelo(session, payload.tipo)
 
 
 
 
 
 
274
 
275
 
276
  @router.post("/transform-preview")
@@ -427,7 +438,13 @@ def export_base(session_id: str, filtered: bool = True) -> FileResponse:
427
  @router.post("/map/update")
428
  def map_update(payload: UpdateMapaPayload) -> dict[str, Any]:
429
  session = session_store.get(payload.session_id)
430
- return elaboracao_service.atualizar_mapa(session, payload.variavel_mapa)
 
 
 
 
 
 
431
 
432
 
433
  @router.post("/market-date/preview")
 
36
 
37
  class CorrecaoGeo(BaseModel):
38
  linha: int
39
+ cdlog_corrigido: str | None = None
40
  numero_corrigido: str | None = None
41
 
42
 
 
78
 
79
 
80
  class DispersaoPayload(SessionPayload):
81
+ eixo_x: str = "transformado"
82
+ eixo_y_tipo: str = "y_transformado"
83
+ eixo_y_residuo: str | None = None
84
+ eixo_y_coluna: str | None = None
85
 
86
 
87
  class TransformPreviewPayload(SessionPayload):
 
132
 
133
  class UpdateMapaPayload(SessionPayload):
134
  variavel_mapa: str | None = None
135
+ modo_mapa: str | None = None
136
 
137
 
138
  class ColunaDataMercadoPayload(SessionPayload):
 
275
  @router.post("/model-dispersao")
276
  def model_dispersao(payload: DispersaoPayload) -> dict[str, Any]:
277
  session = session_store.get(payload.session_id)
278
+ return elaboracao_service.gerar_grafico_dispersao_modelo(
279
+ session,
280
+ eixo_x=payload.eixo_x,
281
+ eixo_y_tipo=payload.eixo_y_tipo,
282
+ eixo_y_residuo=payload.eixo_y_residuo,
283
+ eixo_y_coluna=payload.eixo_y_coluna,
284
+ )
285
 
286
 
287
  @router.post("/transform-preview")
 
438
  @router.post("/map/update")
439
  def map_update(payload: UpdateMapaPayload) -> dict[str, Any]:
440
  session = session_store.get(payload.session_id)
441
+ return elaboracao_service.atualizar_mapa(session, payload.variavel_mapa, payload.modo_mapa)
442
+
443
+
444
+ @router.post("/residuos/map/update")
445
+ def residuos_map_update(payload: UpdateMapaPayload) -> dict[str, Any]:
446
+ session = session_store.get(payload.session_id)
447
+ return elaboracao_service.atualizar_mapa_residuos(session, payload.variavel_mapa, payload.modo_mapa)
448
 
449
 
450
  @router.post("/market-date/preview")
backend/app/core/elaboracao/charts.py CHANGED
@@ -8,10 +8,13 @@ import pandas as pd
8
  import plotly.graph_objects as go
9
  from plotly.subplots import make_subplots
10
  from scipy import stats
 
 
11
  import folium
12
  from folium import plugins
13
  import branca.colormap as cm
14
  from branca.element import Element
 
15
 
16
  # ============================================================
17
  # CONSTANTES DE ESTILO
@@ -568,7 +571,272 @@ def criar_painel_diagnostico(resultado_modelo):
568
  # MAPA FOLIUM
569
  # ============================================================
570
 
571
- def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=None, tamanho_col=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
  """
573
  Cria mapa Folium com os dados.
574
 
@@ -579,6 +847,7 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=
579
  cor_col: coluna para colorir os pontos (opcional)
580
  indice_destacado: índice do ponto a destacar (opcional)
581
  tamanho_col: coluna numérica para dimensionar os círculos (opcional)
 
582
 
583
  Retorna:
584
  HTML do mapa
@@ -639,8 +908,20 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=
639
  )
640
 
641
  # Camadas base
642
- folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True).add_to(m)
643
- folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True).add_to(m)
 
 
 
 
 
 
 
 
 
 
 
 
644
 
645
  # Se tamanho_col fornecido mas cor_col não, usa mesma variável para cor
646
  if tamanho_col and not cor_col:
@@ -648,16 +929,34 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=
648
 
649
  # Colormap se houver coluna de cor (verde → vermelho)
650
  colormap = None
651
- if cor_col and cor_col in df_mapa.columns:
652
- vmin = df_mapa[cor_col].min()
653
- vmax = df_mapa[cor_col].max()
654
- colormap = cm.LinearColormap(
655
- colors=["#2ecc71", "#a8e06c", "#f1c40f", "#e67e22", "#e74c3c"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
  vmin=vmin,
657
  vmax=vmax,
658
- caption=cor_col
659
  )
660
- colormap.add_to(m)
 
 
 
 
661
 
662
  # Escala de tamanho proporcional
663
  raio_min, raio_max = 3, 18
@@ -671,82 +970,199 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=
671
  tamanho_func = lambda v: (raio_min + raio_max) / 2
672
 
673
  # Camada de índices (oculta por padrão, ativável pelo controle de camadas)
674
- mostrar_indices = len(df_mapa) <= 800
675
  camada_indices = folium.FeatureGroup(name="Índices", show=False) if mostrar_indices else None
676
 
677
- # Adiciona pontos
678
- for idx, row in df_mapa.iterrows():
679
- # Cor do ponto
680
- if colormap and cor_col:
681
- cor = colormap(row[cor_col])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
682
  else:
683
- cor = COR_PRINCIPAL
684
-
685
- # Calcula raio
686
- if idx == indice_destacado:
687
- raio = raio_max + 4
688
- elif tamanho_func:
689
- raio = tamanho_func(row[tamanho_col])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
  else:
691
- raio = 4
692
- peso = 3 if idx == indice_destacado else 1
693
-
694
- # Popup com informações
695
- popup_html = f"<b>Índice: {idx}</b><br>"
696
- if len(df_mapa) <= 1200:
697
- for col in df_mapa.columns:
698
- if str(col).lower() not in ['lat', 'latitude', 'lon', 'longitude']:
699
- val = row[col]
700
- if isinstance(val, (int, float)):
701
- popup_html += f"{col}: {val:.2f}<br>"
702
- else:
703
- popup_html += f"{col}: {val}<br>"
704
- elif tamanho_col and tamanho_col in df_mapa.columns:
705
- val = row[tamanho_col]
706
- val_str = f"{val:.2f}" if isinstance(val, (int, float)) else str(val)
707
- popup_html += f"{tamanho_col}: {val_str}<br>"
708
-
709
- # Tooltip (hover): índice + variável selecionada no dropdown
710
- tooltip_html = (
711
- "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px;"
712
- " line-height:1.7; padding:2px 4px;'>"
713
- f"<b>Índice {idx}</b>"
714
- )
715
- if tamanho_col and tamanho_col in df_mapa.columns:
716
- val_t = row[tamanho_col]
717
- val_str = f"{val_t:.2f}" if isinstance(val_t, (int, float)) else str(val_t)
718
- tooltip_html += (
719
- f"<br><span style='color:#555;'>{tamanho_col}:</span>"
720
- f" <b>{val_str}</b>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
721
  )
722
- tooltip_html += "</div>"
723
-
724
- folium.CircleMarker(
725
- location=[row[lat_real], row[lon_real]],
726
- radius=raio,
727
- popup=folium.Popup(popup_html, max_width=300),
728
- tooltip=folium.Tooltip(tooltip_html, sticky=True),
729
- color='black',
730
- weight=peso,
731
- fill=True,
732
- fillColor=cor,
733
- fillOpacity=0.8 if idx == indice_destacado else 0.6
734
- ).add_to(m)
735
-
736
- # Label com índice (na camada togglável)
737
- if mostrar_indices and camada_indices is not None:
738
- folium.Marker(
739
- location=[row[lat_real], row[lon_real]],
740
- icon=folium.DivIcon(
741
- html=f'<div style="font-size:16px; font-weight:bold; color:#333; text-align:center; line-height:{int(raio*2)}px; width:{int(raio*2)}px; margin-left:-{int(raio)}px; margin-top:-{int(raio)}px;">{idx}</div>',
742
- icon_size=(int(raio*2), int(raio*2)),
743
- icon_anchor=(int(raio), int(raio))
744
  )
745
- ).add_to(camada_indices)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
746
 
747
  if mostrar_indices and camada_indices is not None:
748
  camada_indices.add_to(m)
749
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
750
  # Controles
751
  folium.LayerControl().add_to(m)
752
  plugins.Fullscreen().add_to(m)
 
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
13
  import folium
14
  from folium import plugins
15
  import branca.colormap as cm
16
  from branca.element import Element
17
+ from html import escape
18
 
19
  # ============================================================
20
  # CONSTANTES DE ESTILO
 
571
  # MAPA FOLIUM
572
  # ============================================================
573
 
574
+
575
+ def _contorno_convexo_lng_lat(lons: np.ndarray, lats: np.ndarray) -> np.ndarray | None:
576
+ pontos = np.column_stack([lons, lats])
577
+ if pontos.shape[0] < 3:
578
+ return None
579
+ pontos_unicos = np.unique(np.round(pontos, 10), axis=0)
580
+ if pontos_unicos.shape[0] < 3:
581
+ return None
582
+ try:
583
+ hull = ConvexHull(pontos_unicos)
584
+ except QhullError:
585
+ return None
586
+ return pontos_unicos[hull.vertices]
587
+
588
+
589
+ def _mascara_dentro_poligono(x_grid: np.ndarray, y_grid: np.ndarray, poligono: np.ndarray | None) -> np.ndarray:
590
+ if poligono is None or len(poligono) < 3:
591
+ return np.ones_like(x_grid, dtype=bool)
592
+
593
+ xv = poligono[:, 0]
594
+ yv = poligono[:, 1]
595
+ inside = np.zeros_like(x_grid, dtype=bool)
596
+ x_prev = xv[-1]
597
+ y_prev = yv[-1]
598
+
599
+ for x_cur, y_cur in zip(xv, yv):
600
+ cruzou = ((y_cur > y_grid) != (y_prev > y_grid))
601
+ x_intersec = (x_prev - x_cur) * (y_grid - y_cur) / ((y_prev - y_cur) + 1e-12) + x_cur
602
+ inside ^= cruzou & (x_grid < x_intersec)
603
+ x_prev, y_prev = x_cur, y_cur
604
+
605
+ return inside
606
+
607
+
608
+ def _normalizar_stops_cor(
609
+ cor_stops: list[float] | None,
610
+ colors: list[str],
611
+ vmin: float,
612
+ vmax: float,
613
+ ) -> list[float] | None:
614
+ if not isinstance(cor_stops, list) or len(cor_stops) != len(colors):
615
+ return None
616
+ try:
617
+ stops = [float(item) for item in cor_stops]
618
+ except (TypeError, ValueError):
619
+ return None
620
+ if any(not np.isfinite(item) for item in stops):
621
+ return None
622
+ if any(stops[i] > stops[i + 1] for i in range(len(stops) - 1)):
623
+ return None
624
+ if np.isfinite(vmin) and np.isfinite(vmax) and vmax > vmin:
625
+ stops = [min(max(item, vmin), vmax) for item in stops]
626
+ for idx in range(1, len(stops)):
627
+ if stops[idx] < stops[idx - 1]:
628
+ stops[idx] = stops[idx - 1]
629
+ stops[0] = vmin
630
+ stops[-1] = vmax
631
+ return stops
632
+
633
+
634
+ def _adicionar_legenda_personalizada(
635
+ m: folium.Map,
636
+ caption: str,
637
+ colormap: cm.LinearColormap,
638
+ tick_values: list[float],
639
+ tick_labels: list[str],
640
+ ) -> None:
641
+ if len(tick_values) != len(tick_labels) or len(tick_values) == 0:
642
+ return
643
+
644
+ vmin = float(min(tick_values))
645
+ vmax = float(max(tick_values))
646
+ if np.isclose(vmin, vmax):
647
+ vmax = vmin + 1.0
648
+
649
+ grad_parts = []
650
+ for pct in range(0, 101, 2):
651
+ valor = vmin + (vmax - vmin) * (pct / 100.0)
652
+ grad_parts.append(f"{colormap(float(valor))} {pct}%")
653
+ grad_css = ", ".join(grad_parts)
654
+
655
+ ticks_html = []
656
+ for idx, (valor, label) in enumerate(zip(tick_values, tick_labels)):
657
+ left = (float(valor) - vmin) / (vmax - vmin) * 100.0
658
+ if idx == 0:
659
+ transform = "translateX(0)"
660
+ elif idx == len(tick_values) - 1:
661
+ transform = "translateX(-100%)"
662
+ else:
663
+ transform = "translateX(-50%)"
664
+ ticks_html.append(
665
+ f"<div style='position:absolute; left:{left:.4f}%; top:0; transform:{transform}; font-size:10px; "
666
+ f"line-height:1; color:#2b3a4a; white-space:nowrap;'>{escape(str(label))}</div>"
667
+ )
668
+
669
+ legenda_html = (
670
+ "<div style='position:fixed; top:14px; right:14px; z-index:9999;"
671
+ " background:rgba(255,255,255,0.96); border:1px solid #b7c8d8; border-radius:10px;"
672
+ " box-shadow:0 2px 10px rgba(0,0,0,0.12); padding:8px 10px;'>"
673
+ "<div style='width:360px; max-width:40vw;'>"
674
+ "<div style='position:relative; height:12px; margin-bottom:4px;'>"
675
+ f"{''.join(ticks_html)}"
676
+ "</div>"
677
+ f"<div title='{escape(caption)}' style='height:12px; border-radius:6px; border:1px solid #8aa1b6; "
678
+ f"background:linear-gradient(90deg, {grad_css});'></div>"
679
+ "</div>"
680
+ "</div>"
681
+ )
682
+ m.get_root().html.add_child(Element(legenda_html))
683
+
684
+
685
+ def _adicionar_superficie_continua(
686
+ m: folium.Map,
687
+ df_mapa: pd.DataFrame,
688
+ lat_col: str,
689
+ lon_col: str,
690
+ valor_col: str,
691
+ cor_vmin: float | None = None,
692
+ cor_vmax: float | None = None,
693
+ cor_caption: str | None = None,
694
+ cor_colors: list[str] | None = None,
695
+ cor_stops: list[float] | None = None,
696
+ adicionar_legenda: bool = True,
697
+ ) -> bool:
698
+ lats = pd.to_numeric(df_mapa[lat_col], errors="coerce").to_numpy(dtype=float)
699
+ lons = pd.to_numeric(df_mapa[lon_col], errors="coerce").to_numpy(dtype=float)
700
+ valores = pd.to_numeric(df_mapa[valor_col], errors="coerce").to_numpy(dtype=float)
701
+ mask_valid = np.isfinite(lats) & np.isfinite(lons) & np.isfinite(valores)
702
+
703
+ if mask_valid.sum() < 6:
704
+ return False
705
+
706
+ lats = lats[mask_valid]
707
+ lons = lons[mask_valid]
708
+ valores = valores[mask_valid]
709
+
710
+ contorno = _contorno_convexo_lng_lat(lons, lats)
711
+ if contorno is None:
712
+ return False
713
+
714
+ lon_min = float(np.min(contorno[:, 0]))
715
+ lon_max = float(np.max(contorno[:, 0]))
716
+ lat_min = float(np.min(contorno[:, 1]))
717
+ lat_max = float(np.max(contorno[:, 1]))
718
+ if np.isclose(lon_min, lon_max) or np.isclose(lat_min, lat_max):
719
+ return False
720
+
721
+ n_obs = len(valores)
722
+ if n_obs <= 400:
723
+ n_grid = 46
724
+ elif n_obs <= 1200:
725
+ n_grid = 40
726
+ else:
727
+ n_grid = 34
728
+
729
+ grid_lon = np.linspace(lon_min, lon_max, n_grid)
730
+ grid_lat = np.linspace(lat_min, lat_max, n_grid)
731
+ mesh_lon, mesh_lat = np.meshgrid(grid_lon, grid_lat)
732
+
733
+ pontos = np.column_stack([lons, lats])
734
+ try:
735
+ superficie = griddata(pontos, valores, (mesh_lon, mesh_lat), method="linear")
736
+ except Exception:
737
+ return False
738
+
739
+ if superficie is None:
740
+ return False
741
+
742
+ superficie = np.asarray(superficie, dtype=float)
743
+ if np.isnan(superficie).all():
744
+ try:
745
+ superficie = griddata(pontos, valores, (mesh_lon, mesh_lat), method="nearest")
746
+ except Exception:
747
+ return False
748
+ if superficie is None:
749
+ return False
750
+ superficie = np.asarray(superficie, dtype=float)
751
+ elif np.isnan(superficie).any():
752
+ try:
753
+ nearest = griddata(pontos, valores, (mesh_lon, mesh_lat), method="nearest")
754
+ except Exception:
755
+ nearest = None
756
+ if nearest is not None:
757
+ nearest = np.asarray(nearest, dtype=float)
758
+ superficie = np.where(np.isfinite(superficie), superficie, nearest)
759
+
760
+ mascara = _mascara_dentro_poligono(mesh_lon, mesh_lat, contorno)
761
+ superficie = np.where(mascara, superficie, np.nan)
762
+ if not np.isfinite(superficie).any():
763
+ return False
764
+
765
+ vmin = float(cor_vmin) if cor_vmin is not None and np.isfinite(cor_vmin) else float(np.nanmin(superficie))
766
+ vmax = float(cor_vmax) if cor_vmax is not None and np.isfinite(cor_vmax) else float(np.nanmax(superficie))
767
+ if not np.isfinite(vmin) or not np.isfinite(vmax):
768
+ return False
769
+ if np.isclose(vmin, vmax):
770
+ vmax = vmin + 1.0
771
+
772
+ colors = cor_colors if isinstance(cor_colors, list) and len(cor_colors) >= 2 else ["#2ecc71", "#a8e06c", "#f1c40f", "#e67e22", "#e74c3c"]
773
+ color_index = _normalizar_stops_cor(cor_stops, colors, vmin, vmax)
774
+ caption = str(cor_caption or f"{valor_col} (superfície)")
775
+ colormap_kwargs = dict(
776
+ colors=colors,
777
+ vmin=vmin,
778
+ vmax=vmax,
779
+ caption=caption,
780
+ )
781
+ if color_index is not None:
782
+ colormap_kwargs["index"] = color_index
783
+ colormap = cm.LinearColormap(**colormap_kwargs)
784
+ if adicionar_legenda:
785
+ colormap.add_to(m)
786
+
787
+ centros_lon = (grid_lon[:-1] + grid_lon[1:]) / 2.0
788
+ centros_lat = (grid_lat[:-1] + grid_lat[1:]) / 2.0
789
+ centro_mesh_lon, centro_mesh_lat = np.meshgrid(centros_lon, centros_lat)
790
+ mascara_centros = _mascara_dentro_poligono(centro_mesh_lon, centro_mesh_lat, contorno)
791
+
792
+ valores_celula = (
793
+ superficie[:-1, :-1]
794
+ + superficie[1:, :-1]
795
+ + superficie[:-1, 1:]
796
+ + superficie[1:, 1:]
797
+ ) / 4.0
798
+
799
+ camada_superficie = folium.FeatureGroup(name="Superfície contínua", show=True)
800
+ for i in range(valores_celula.shape[0]):
801
+ for j in range(valores_celula.shape[1]):
802
+ if not mascara_centros[i, j]:
803
+ continue
804
+ valor = valores_celula[i, j]
805
+ if not np.isfinite(valor):
806
+ continue
807
+ cor = colormap(float(valor))
808
+ valor_fmt = f"{float(valor):.3f}".replace(".", ",")
809
+ folium.Rectangle(
810
+ bounds=[
811
+ [float(grid_lat[i]), float(grid_lon[j])],
812
+ [float(grid_lat[i + 1]), float(grid_lon[j + 1])],
813
+ ],
814
+ stroke=False,
815
+ fill=True,
816
+ fill_color=cor,
817
+ fill_opacity=0.6,
818
+ tooltip=folium.Tooltip(f"Resíduo interpolado: {valor_fmt}", sticky=False),
819
+ ).add_to(camada_superficie)
820
+
821
+ camada_superficie.add_to(m)
822
+ return True
823
+
824
+ def criar_mapa(
825
+ df,
826
+ lat_col="lat",
827
+ lon_col="lon",
828
+ cor_col=None,
829
+ indice_destacado=None,
830
+ tamanho_col=None,
831
+ modo="pontos",
832
+ cor_vmin: float | None = None,
833
+ cor_vmax: float | None = None,
834
+ cor_caption: str | None = None,
835
+ cor_colors: list[str] | None = None,
836
+ cor_stops: list[float] | None = None,
837
+ cor_tick_values: list[float] | None = None,
838
+ cor_tick_labels: list[str] | None = None,
839
+ ):
840
  """
841
  Cria mapa Folium com os dados.
842
 
 
847
  cor_col: coluna para colorir os pontos (opcional)
848
  indice_destacado: índice do ponto a destacar (opcional)
849
  tamanho_col: coluna numérica para dimensionar os círculos (opcional)
850
+ modo: "pontos", "calor" (heatmap) ou "superficie" (interpolada)
851
 
852
  Retorna:
853
  HTML do mapa
 
908
  )
909
 
910
  # Camadas base
911
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(m)
912
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(m)
913
+
914
+ modo_normalizado = str(modo or "pontos").strip().lower()
915
+ modo_calor = (
916
+ modo_normalizado == "calor"
917
+ and tamanho_col is not None
918
+ and tamanho_col in df_mapa.columns
919
+ )
920
+ modo_superficie = (
921
+ modo_normalizado == "superficie"
922
+ and tamanho_col is not None
923
+ and tamanho_col in df_mapa.columns
924
+ )
925
 
926
  # Se tamanho_col fornecido mas cor_col não, usa mesma variável para cor
927
  if tamanho_col and not cor_col:
 
929
 
930
  # Colormap se houver coluna de cor (verde → vermelho)
931
  colormap = None
932
+ colors = cor_colors if isinstance(cor_colors, list) and len(cor_colors) >= 2 else ["#2ecc71", "#a8e06c", "#f1c40f", "#e67e22", "#e74c3c"]
933
+ usar_legenda_personalizada = (
934
+ isinstance(cor_tick_values, list)
935
+ and isinstance(cor_tick_labels, list)
936
+ and len(cor_tick_values) > 0
937
+ and len(cor_tick_values) == len(cor_tick_labels)
938
+ )
939
+ if not modo_calor and not modo_superficie and cor_col and cor_col in df_mapa.columns:
940
+ vmin = float(cor_vmin) if cor_vmin is not None and np.isfinite(cor_vmin) else pd.to_numeric(df_mapa[cor_col], errors="coerce").min()
941
+ vmax = float(cor_vmax) if cor_vmax is not None and np.isfinite(cor_vmax) else pd.to_numeric(df_mapa[cor_col], errors="coerce").max()
942
+ if not np.isfinite(vmin):
943
+ vmin = pd.to_numeric(df_mapa[cor_col], errors="coerce").min()
944
+ if not np.isfinite(vmax):
945
+ vmax = pd.to_numeric(df_mapa[cor_col], errors="coerce").max()
946
+ if np.isclose(vmin, vmax):
947
+ vmax = float(vmin) + 1.0
948
+ color_index = _normalizar_stops_cor(cor_stops, colors, float(vmin), float(vmax))
949
+ colormap_kwargs = dict(
950
+ colors=colors,
951
  vmin=vmin,
952
  vmax=vmax,
953
+ caption=str(cor_caption or cor_col)
954
  )
955
+ if color_index is not None:
956
+ colormap_kwargs["index"] = color_index
957
+ colormap = cm.LinearColormap(**colormap_kwargs)
958
+ if not usar_legenda_personalizada:
959
+ colormap.add_to(m)
960
 
961
  # Escala de tamanho proporcional
962
  raio_min, raio_max = 3, 18
 
970
  tamanho_func = lambda v: (raio_min + raio_max) / 2
971
 
972
  # Camada de índices (oculta por padrão, ativável pelo controle de camadas)
973
+ mostrar_indices = (not modo_calor and not modo_superficie) and len(df_mapa) <= 800
974
  camada_indices = folium.FeatureGroup(name="Índices", show=False) if mostrar_indices else None
975
 
976
+ if modo_superficie:
977
+ superficie_ok = _adicionar_superficie_continua(
978
+ m,
979
+ df_mapa,
980
+ lat_real,
981
+ lon_real,
982
+ tamanho_col,
983
+ cor_vmin=cor_vmin,
984
+ cor_vmax=cor_vmax,
985
+ cor_caption=cor_caption,
986
+ cor_colors=cor_colors,
987
+ cor_stops=cor_stops,
988
+ adicionar_legenda=not usar_legenda_personalizada,
989
+ )
990
+ if not superficie_ok:
991
+ modo_superficie = False
992
+ modo_calor = True
993
+
994
+ if modo_calor:
995
+ pesos = pd.to_numeric(df_mapa[tamanho_col], errors="coerce")
996
+ mask_pesos = np.isfinite(pesos.to_numpy())
997
+ df_calor = df_mapa.loc[mask_pesos, [lat_real, lon_real]].copy()
998
+ if not df_calor.empty:
999
+ pesos_validos = pesos.loc[df_calor.index].to_numpy(dtype=float)
1000
+ fixed_scale = (
1001
+ cor_vmin is not None
1002
+ and cor_vmax is not None
1003
+ and np.isfinite(cor_vmin)
1004
+ and np.isfinite(cor_vmax)
1005
+ and float(cor_vmax) > float(cor_vmin)
1006
+ )
1007
+ if fixed_scale:
1008
+ peso_min = float(cor_vmin)
1009
+ peso_max = float(cor_vmax)
1010
+ pesos_clip = np.clip(pesos_validos, peso_min, peso_max)
1011
+ pesos_norm = (pesos_clip - peso_min) / (peso_max - peso_min)
1012
+ else:
1013
+ peso_min = float(np.min(pesos_validos))
1014
+ peso_max = float(np.max(pesos_validos))
1015
+ if np.isfinite(peso_min) and np.isfinite(peso_max):
1016
+ if peso_max > peso_min:
1017
+ pesos_norm = 0.1 + 0.9 * (pesos_validos - peso_min) / (peso_max - peso_min)
1018
+ else:
1019
+ pesos_norm = np.ones_like(pesos_validos)
1020
+ else:
1021
+ pesos_norm = np.ones_like(pesos_validos)
1022
+ heat_data = np.column_stack([
1023
+ df_calor[lat_real].to_numpy(dtype=float),
1024
+ df_calor[lon_real].to_numpy(dtype=float),
1025
+ pesos_norm,
1026
+ ]).tolist()
1027
  else:
1028
+ heat_data = df_mapa[[lat_real, lon_real]].to_numpy(dtype=float).tolist()
1029
+
1030
+ gradient = None
1031
+ if (
1032
+ cor_vmin is not None
1033
+ and cor_vmax is not None
1034
+ and np.isfinite(cor_vmin)
1035
+ and np.isfinite(cor_vmax)
1036
+ and float(cor_vmax) > float(cor_vmin)
1037
+ and len(colors) >= 2
1038
+ ):
1039
+ color_index = _normalizar_stops_cor(cor_stops, colors, float(cor_vmin), float(cor_vmax))
1040
+ if color_index is not None:
1041
+ grad_pairs = []
1042
+ for stop, color in zip(color_index, colors):
1043
+ ratio = (float(stop) - float(cor_vmin)) / (float(cor_vmax) - float(cor_vmin))
1044
+ grad_pairs.append((float(np.clip(ratio, 0.0, 1.0)), color))
1045
+ grad_pairs.append((0.0, colors[0]))
1046
+ grad_pairs.append((1.0, colors[-1]))
1047
+ gradient = {}
1048
+ for key, color in sorted(grad_pairs, key=lambda item: item[0]):
1049
+ gradient[key] = color
1050
+ elif len(colors) == 2:
1051
+ gradient = {0.0: colors[0], 1.0: colors[1]}
1052
+ else:
1053
+ gradient = {i / (len(colors) - 1): colors[i] for i in range(len(colors))}
1054
+ if gradient is None:
1055
+ plugins.HeatMap(
1056
+ heat_data,
1057
+ name="Mapa de calor",
1058
+ radius=20,
1059
+ blur=18,
1060
+ min_opacity=0.28,
1061
+ max_zoom=17,
1062
+ ).add_to(m)
1063
  else:
1064
+ plugins.HeatMap(
1065
+ heat_data,
1066
+ name="Mapa de calor",
1067
+ radius=20,
1068
+ blur=18,
1069
+ min_opacity=0.28,
1070
+ max_zoom=17,
1071
+ gradient=gradient,
1072
+ ).add_to(m)
1073
+ elif not modo_superficie:
1074
+ # Adiciona pontos
1075
+ for idx, row in df_mapa.iterrows():
1076
+ # Cor do ponto
1077
+ if colormap and cor_col:
1078
+ cor = colormap(row[cor_col])
1079
+ else:
1080
+ cor = COR_PRINCIPAL
1081
+
1082
+ # Calcula raio
1083
+ if idx == indice_destacado:
1084
+ raio = raio_max + 4
1085
+ elif tamanho_func:
1086
+ raio = tamanho_func(row[tamanho_col])
1087
+ else:
1088
+ raio = 4
1089
+ peso = 3 if idx == indice_destacado else 1
1090
+
1091
+ # Popup com informações
1092
+ popup_html = f"<b>Índice: {idx}</b><br>"
1093
+ if len(df_mapa) <= 1200:
1094
+ for col in df_mapa.columns:
1095
+ if str(col).lower() not in ['lat', 'latitude', 'lon', 'longitude']:
1096
+ val = row[col]
1097
+ if isinstance(val, (int, float)):
1098
+ popup_html += f"{col}: {val:.2f}<br>"
1099
+ else:
1100
+ popup_html += f"{col}: {val}<br>"
1101
+ elif tamanho_col and tamanho_col in df_mapa.columns:
1102
+ val = row[tamanho_col]
1103
+ val_str = f"{val:.2f}" if isinstance(val, (int, float)) else str(val)
1104
+ popup_html += f"{tamanho_col}: {val_str}<br>"
1105
+
1106
+ # Tooltip (hover): índice + variável selecionada no dropdown
1107
+ tooltip_html = (
1108
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px;"
1109
+ " line-height:1.7; padding:2px 4px;'>"
1110
+ f"<b>Índice {idx}</b>"
1111
  )
1112
+ if tamanho_col and tamanho_col in df_mapa.columns:
1113
+ val_t = row[tamanho_col]
1114
+ val_str = f"{val_t:.2f}" if isinstance(val_t, (int, float)) else str(val_t)
1115
+ tooltip_html += (
1116
+ f"<br><span style='color:#555;'>{tamanho_col}:</span>"
1117
+ f" <b>{val_str}</b>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1118
  )
1119
+ tooltip_html += "</div>"
1120
+
1121
+ folium.CircleMarker(
1122
+ location=[row[lat_real], row[lon_real]],
1123
+ radius=raio,
1124
+ popup=folium.Popup(popup_html, max_width=300, auto_pan=False),
1125
+ tooltip=folium.Tooltip(tooltip_html, sticky=True),
1126
+ color='black',
1127
+ weight=peso,
1128
+ fill=True,
1129
+ fillColor=cor,
1130
+ fillOpacity=0.8 if idx == indice_destacado else 0.6
1131
+ ).add_to(m)
1132
+
1133
+ # Label com índice (na camada togglável)
1134
+ if mostrar_indices and camada_indices is not None:
1135
+ folium.Marker(
1136
+ location=[row[lat_real], row[lon_real]],
1137
+ icon=folium.DivIcon(
1138
+ html=f'<div style="font-size:16px; font-weight:bold; color:#333; text-align:center; line-height:{int(raio*2)}px; width:{int(raio*2)}px; margin-left:-{int(raio)}px; margin-top:-{int(raio)}px;">{idx}</div>',
1139
+ icon_size=(int(raio*2), int(raio*2)),
1140
+ icon_anchor=(int(raio), int(raio))
1141
+ )
1142
+ ).add_to(camada_indices)
1143
 
1144
  if mostrar_indices and camada_indices is not None:
1145
  camada_indices.add_to(m)
1146
 
1147
+ if usar_legenda_personalizada and cor_col and cor_col in df_mapa.columns:
1148
+ vmin_leg = float(cor_vmin) if cor_vmin is not None and np.isfinite(cor_vmin) else pd.to_numeric(df_mapa[cor_col], errors="coerce").min()
1149
+ vmax_leg = float(cor_vmax) if cor_vmax is not None and np.isfinite(cor_vmax) else pd.to_numeric(df_mapa[cor_col], errors="coerce").max()
1150
+ if np.isclose(vmin_leg, vmax_leg):
1151
+ vmax_leg = float(vmin_leg) + 1.0
1152
+ color_index_leg = _normalizar_stops_cor(cor_stops, colors, float(vmin_leg), float(vmax_leg))
1153
+ legenda_kwargs = dict(
1154
+ colors=colors,
1155
+ vmin=vmin_leg,
1156
+ vmax=vmax_leg,
1157
+ caption=str(cor_caption or cor_col),
1158
+ )
1159
+ if color_index_leg is not None:
1160
+ legenda_kwargs["index"] = color_index_leg
1161
+ colormap_legenda = cm.LinearColormap(**legenda_kwargs)
1162
+ tick_values = [float(v) for v in cor_tick_values]
1163
+ tick_labels = [str(v) for v in cor_tick_labels]
1164
+ _adicionar_legenda_personalizada(m, str(cor_caption or cor_col), colormap_legenda, tick_values, tick_labels)
1165
+
1166
  # Controles
1167
  folium.LayerControl().add_to(m)
1168
  plugins.Fullscreen().add_to(m)
backend/app/core/elaboracao/geocodificacao.py CHANGED
@@ -227,6 +227,25 @@ def geocodificar(df, col_cdlog, col_num, auto_200=False):
227
  falhas = []
228
  ajustados = []
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  for idx, cdlog, numero_raw in df[["_idx", "__geo_cdlog", "__geo_num"]].itertuples(index=False, name=None):
231
  numero = int(numero_raw)
232
  segmentos = segmentos_por_cdlog.get(cdlog)
@@ -270,7 +289,20 @@ def geocodificar(df, col_cdlog, col_num, auto_200=False):
270
  cond = valid_mask & (ini_vals <= numero) & (fim_vals >= numero)
271
 
272
  if cond.any():
273
- pos = int(np.flatnonzero(cond)[0])
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  linha = segmentos.iloc[pos]
275
  geom = linha.geometry
276
  ini = ini_vals[pos]
@@ -283,7 +315,7 @@ def geocodificar(df, col_cdlog, col_num, auto_200=False):
283
 
284
  frac = (numero - ini) / (fim - ini)
285
  frac = max(0.0, min(1.0, frac))
286
- ponto = geom.interpolate(geom.length * frac)
287
  lons.append(ponto.x)
288
  lats.append(ponto.y)
289
  continue
@@ -324,9 +356,26 @@ def geocodificar(df, col_cdlog, col_num, auto_200=False):
324
  if numero_para_interpolar is not None and min_pos is not None:
325
  cond2 = valid_mask & (ini_vals <= numero_para_interpolar) & (fim_vals >= numero_para_interpolar)
326
  if cond2.any():
327
- pos2 = int(np.flatnonzero(cond2)[0])
328
  else:
329
- pos2 = min_pos
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
  linha = segmentos.iloc[pos2]
332
  geom = linha.geometry
@@ -339,7 +388,7 @@ def geocodificar(df, col_cdlog, col_num, auto_200=False):
339
  else:
340
  frac = (numero_para_interpolar - ini_v) / (fim_v - ini_v)
341
  frac = max(0.0, min(1.0, frac))
342
- ponto = geom.interpolate(geom.length * frac)
343
  lons.append(ponto.x)
344
  lats.append(ponto.y)
345
  continue
@@ -392,12 +441,22 @@ def geocodificar(df, col_cdlog, col_num, auto_200=False):
392
 
393
  df = df.drop(columns=["__geo_cdlog", "__geo_num"], errors="ignore")
394
 
395
- df_falhas = pd.DataFrame(
396
- falhas,
397
- columns=["_idx", "cdlog", "numero_atual", "motivo", "sugestoes", "numero_corrigido"]
398
- ) if falhas else pd.DataFrame(
399
- columns=["_idx", "cdlog", "numero_atual", "motivo", "sugestoes", "numero_corrigido"]
400
- )
 
 
 
 
 
 
 
 
 
 
401
 
402
  return df, df_falhas, ajustados
403
 
@@ -407,7 +466,7 @@ def aplicar_correcoes_e_regeodificar(df_original, df_falhas, col_cdlog, col_num,
407
 
408
  Args:
409
  df_original: DataFrame acumulado da rodada anterior (com coluna _idx e col_num já corrigido)
410
- df_falhas: DataFrame editável com coluna 'numero_corrigido' preenchida pelo usuário
411
  col_cdlog: Nome da coluna CDLOG
412
  col_num: Nome da coluna número predial
413
  auto_200: Repassado para geocodificar()
@@ -423,22 +482,52 @@ def aplicar_correcoes_e_regeodificar(df_original, df_falhas, col_cdlog, col_num,
423
  df["_idx"] = range(len(df))
424
 
425
  # Aplica correções de forma vetorizada por _idx.
426
- manuais: list[int] = []
427
  if isinstance(df_falhas, pd.DataFrame) and not df_falhas.empty and "_idx" in df_falhas.columns:
428
- correcoes = df_falhas.loc[:, ["_idx", "numero_corrigido"]].copy()
429
- correcoes["__novo_num"] = pd.to_numeric(correcoes["numero_corrigido"], errors="coerce")
430
- correcoes = correcoes.dropna(subset=["_idx", "__novo_num"])
431
- if not correcoes.empty:
432
- correcoes["__novo_num"] = correcoes["__novo_num"].astype(int)
433
- manuais = correcoes["_idx"].tolist()
434
-
435
- mapa = correcoes.drop_duplicates(subset=["_idx"], keep="last").set_index("_idx")["__novo_num"]
436
- novos = df["_idx"].map(mapa)
437
- mask = novos.notna()
438
- if mask.any():
439
- df.loc[mask, col_num] = novos[mask].astype(int).to_numpy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
 
441
  df_resultado, df_falhas_novas, ajustados = geocodificar(df, col_cdlog, col_num, auto_200)
 
442
  return df_resultado, df_falhas_novas, ajustados, manuais
443
 
444
 
@@ -485,7 +574,7 @@ def formatar_status_geocodificacao(df_resultado, df_falhas, ajustados, manuais=N
485
  if n_falhas > 0:
486
  linhas.append(
487
  f'<span style="color:#c0392b">✘ {n_falhas} com falha — '
488
- f'preencha "Nº Corrigido" na tabela abaixo e aplique as correções</span>'
489
  )
490
  else:
491
  linhas.append('<span style="color:#1a7a1a">Nenhuma falha restante.</span>')
@@ -502,10 +591,10 @@ def preparar_display_falhas(df_falhas):
502
 
503
  Retorna:
504
  html_str: Tabela HTML legível (read-only) com as falhas
505
- df_correcoes: DataFrame com colunas ["Nº Linha", "Nº Corrigido"] para edição pelo usuário
506
  """
507
  if df_falhas is None or df_falhas.empty:
508
- return "", pd.DataFrame(columns=["Nº Linha", "Nº Corrigido"])
509
 
510
  linhas = [
511
  '<div style="overflow-x:auto;margin-top:8px">',
@@ -530,9 +619,17 @@ def preparar_display_falhas(df_falhas):
530
  )
531
  linhas.append('</tbody></table></div>')
532
 
 
 
 
 
 
533
  df_correcoes = pd.DataFrame({
534
  "Nº Linha": df_falhas["_idx"].tolist(),
535
- "Nº Corrigido": [""] * len(df_falhas),
 
 
 
536
  "Sugestões": df_falhas["sugestoes"].fillna("").astype(str).tolist(),
537
  })
538
 
 
227
  falhas = []
228
  ajustados = []
229
 
230
+ def _geom_interpolavel(geom) -> bool:
231
+ if geom is None or not hasattr(geom, "interpolate"):
232
+ return False
233
+ try:
234
+ comprimento = float(geom.length)
235
+ except Exception:
236
+ return False
237
+ return np.isfinite(comprimento) and comprimento > 0
238
+
239
+ def _primeira_posicao_com_geom(segmentos_df: pd.DataFrame, posicoes: np.ndarray) -> int | None:
240
+ for pos in posicoes:
241
+ try:
242
+ geom = segmentos_df.iloc[int(pos)].geometry
243
+ except Exception:
244
+ continue
245
+ if _geom_interpolavel(geom):
246
+ return int(pos)
247
+ return None
248
+
249
  for idx, cdlog, numero_raw in df[["_idx", "__geo_cdlog", "__geo_num"]].itertuples(index=False, name=None):
250
  numero = int(numero_raw)
251
  segmentos = segmentos_por_cdlog.get(cdlog)
 
289
  cond = valid_mask & (ini_vals <= numero) & (fim_vals >= numero)
290
 
291
  if cond.any():
292
+ posicoes_cond = np.flatnonzero(cond)
293
+ pos = _primeira_posicao_com_geom(segmentos, posicoes_cond)
294
+ if pos is None:
295
+ lats.append(None)
296
+ lons.append(None)
297
+ falhas.append({
298
+ "_idx": idx,
299
+ "cdlog": cdlog,
300
+ "numero_atual": numero,
301
+ "motivo": "Segmento sem geometria válida",
302
+ "sugestoes": "",
303
+ "numero_corrigido": "",
304
+ })
305
+ continue
306
  linha = segmentos.iloc[pos]
307
  geom = linha.geometry
308
  ini = ini_vals[pos]
 
315
 
316
  frac = (numero - ini) / (fim - ini)
317
  frac = max(0.0, min(1.0, frac))
318
+ ponto = geom.interpolate(float(geom.length) * frac)
319
  lons.append(ponto.x)
320
  lats.append(ponto.y)
321
  continue
 
356
  if numero_para_interpolar is not None and min_pos is not None:
357
  cond2 = valid_mask & (ini_vals <= numero_para_interpolar) & (fim_vals >= numero_para_interpolar)
358
  if cond2.any():
359
+ pos2 = _primeira_posicao_com_geom(segmentos, np.flatnonzero(cond2))
360
  else:
361
+ pos2 = None
362
+
363
+ if pos2 is None and min_pos is not None:
364
+ pos2 = _primeira_posicao_com_geom(segmentos, np.array([min_pos], dtype=int))
365
+ if pos2 is None:
366
+ pos2 = _primeira_posicao_com_geom(segmentos, valid_positions)
367
+ if pos2 is None:
368
+ lats.append(None)
369
+ lons.append(None)
370
+ falhas.append({
371
+ "_idx": idx,
372
+ "cdlog": cdlog,
373
+ "numero_atual": numero,
374
+ "motivo": "Segmento sem geometria válida",
375
+ "sugestoes": sugestoes_str,
376
+ "numero_corrigido": "",
377
+ })
378
+ continue
379
 
380
  linha = segmentos.iloc[pos2]
381
  geom = linha.geometry
 
388
  else:
389
  frac = (numero_para_interpolar - ini_v) / (fim_v - ini_v)
390
  frac = max(0.0, min(1.0, frac))
391
+ ponto = geom.interpolate(float(geom.length) * frac)
392
  lons.append(ponto.x)
393
  lats.append(ponto.y)
394
  continue
 
441
 
442
  df = df.drop(columns=["__geo_cdlog", "__geo_num"], errors="ignore")
443
 
444
+ colunas_falhas = [
445
+ "_idx",
446
+ "cdlog",
447
+ "numero_atual",
448
+ "motivo",
449
+ "sugestoes",
450
+ "cdlog_corrigido",
451
+ "numero_corrigido",
452
+ ]
453
+ df_falhas = pd.DataFrame(falhas, columns=colunas_falhas) if falhas else pd.DataFrame(columns=colunas_falhas)
454
+ if "cdlog_corrigido" not in df_falhas.columns:
455
+ df_falhas["cdlog_corrigido"] = ""
456
+ if "numero_corrigido" not in df_falhas.columns:
457
+ df_falhas["numero_corrigido"] = ""
458
+ df_falhas["cdlog_corrigido"] = df_falhas["cdlog_corrigido"].fillna("").astype(str)
459
+ df_falhas["numero_corrigido"] = df_falhas["numero_corrigido"].fillna("").astype(str)
460
 
461
  return df, df_falhas, ajustados
462
 
 
466
 
467
  Args:
468
  df_original: DataFrame acumulado da rodada anterior (com coluna _idx e col_num já corrigido)
469
+ df_falhas: DataFrame editável com colunas 'cdlog_corrigido' e/ou 'numero_corrigido'
470
  col_cdlog: Nome da coluna CDLOG
471
  col_num: Nome da coluna número predial
472
  auto_200: Repassado para geocodificar()
 
482
  df["_idx"] = range(len(df))
483
 
484
  # Aplica correções de forma vetorizada por _idx.
485
+ manuais_set: set[int] = set()
486
  if isinstance(df_falhas, pd.DataFrame) and not df_falhas.empty and "_idx" in df_falhas.columns:
487
+ if "cdlog_corrigido" in df_falhas.columns:
488
+ correcoes_cdlog = df_falhas.loc[:, ["_idx", "cdlog_corrigido"]].copy()
489
+ correcoes_cdlog["__novo_cdlog"] = correcoes_cdlog["cdlog_corrigido"].fillna("").astype(str).str.strip()
490
+ correcoes_cdlog = correcoes_cdlog[
491
+ correcoes_cdlog["_idx"].notna() & correcoes_cdlog["__novo_cdlog"].ne("")
492
+ ].copy()
493
+ if not correcoes_cdlog.empty:
494
+ serie_cdlog = obter_serie_coluna(df, col_cdlog)
495
+ if pd.api.types.is_numeric_dtype(serie_cdlog):
496
+ cdlog_num = pd.to_numeric(correcoes_cdlog["__novo_cdlog"], errors="coerce")
497
+ correcoes_cdlog = correcoes_cdlog.loc[cdlog_num.notna()].copy()
498
+ correcoes_cdlog["__novo_cdlog"] = cdlog_num.loc[cdlog_num.notna()].astype(int).to_numpy()
499
+
500
+ if not correcoes_cdlog.empty:
501
+ mapa_cdlog = (
502
+ correcoes_cdlog
503
+ .drop_duplicates(subset=["_idx"], keep="last")
504
+ .set_index("_idx")["__novo_cdlog"]
505
+ )
506
+ novos_cdlog = df["_idx"].map(mapa_cdlog)
507
+ mask_cdlog = novos_cdlog.notna()
508
+ if mask_cdlog.any():
509
+ df.loc[mask_cdlog, col_cdlog] = novos_cdlog[mask_cdlog].to_numpy()
510
+ manuais_set.update(int(v) for v in mapa_cdlog.index.tolist())
511
+
512
+ if "numero_corrigido" in df_falhas.columns:
513
+ correcoes_num = df_falhas.loc[:, ["_idx", "numero_corrigido"]].copy()
514
+ correcoes_num["__novo_num"] = pd.to_numeric(correcoes_num["numero_corrigido"], errors="coerce")
515
+ correcoes_num = correcoes_num.dropna(subset=["_idx", "__novo_num"])
516
+ if not correcoes_num.empty:
517
+ correcoes_num["__novo_num"] = correcoes_num["__novo_num"].astype(int)
518
+ mapa_num = (
519
+ correcoes_num
520
+ .drop_duplicates(subset=["_idx"], keep="last")
521
+ .set_index("_idx")["__novo_num"]
522
+ )
523
+ novos_num = df["_idx"].map(mapa_num)
524
+ mask_num = novos_num.notna()
525
+ if mask_num.any():
526
+ df.loc[mask_num, col_num] = novos_num[mask_num].astype(int).to_numpy()
527
+ manuais_set.update(int(v) for v in mapa_num.index.tolist())
528
 
529
  df_resultado, df_falhas_novas, ajustados = geocodificar(df, col_cdlog, col_num, auto_200)
530
+ manuais = sorted(manuais_set)
531
  return df_resultado, df_falhas_novas, ajustados, manuais
532
 
533
 
 
574
  if n_falhas > 0:
575
  linhas.append(
576
  f'<span style="color:#c0392b">✘ {n_falhas} com falha — '
577
+ f'preencha "Código Corrigido" e/ou "Nº Corrigido" na tabela abaixo e aplique as correções</span>'
578
  )
579
  else:
580
  linhas.append('<span style="color:#1a7a1a">Nenhuma falha restante.</span>')
 
591
 
592
  Retorna:
593
  html_str: Tabela HTML legível (read-only) com as falhas
594
+ df_correcoes: DataFrame com colunas para edição pelo usuário
595
  """
596
  if df_falhas is None or df_falhas.empty:
597
+ return "", pd.DataFrame(columns=["Nº Linha", "Motivo", "Código Atual", "Código Corrigido", "Nº Corrigido", "Sugestões"])
598
 
599
  linhas = [
600
  '<div style="overflow-x:auto;margin-top:8px">',
 
619
  )
620
  linhas.append('</tbody></table></div>')
621
 
622
+ if "cdlog_corrigido" not in df_falhas.columns:
623
+ df_falhas["cdlog_corrigido"] = ""
624
+ if "numero_corrigido" not in df_falhas.columns:
625
+ df_falhas["numero_corrigido"] = ""
626
+
627
  df_correcoes = pd.DataFrame({
628
  "Nº Linha": df_falhas["_idx"].tolist(),
629
+ "Motivo": df_falhas["motivo"].fillna("").astype(str).tolist(),
630
+ "Código Atual": df_falhas["cdlog"].fillna("").astype(str).tolist(),
631
+ "Código Corrigido": df_falhas["cdlog_corrigido"].fillna("").astype(str).tolist(),
632
+ "Nº Corrigido": df_falhas["numero_corrigido"].fillna("").astype(str).tolist(),
633
  "Sugestões": df_falhas["sugestoes"].fillna("").astype(str).tolist(),
634
  })
635
 
backend/app/core/visualizacao/app.py CHANGED
@@ -739,8 +739,8 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
739
  )
740
 
741
  # Camadas base
742
- folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True).add_to(m)
743
- folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True).add_to(m)
744
 
745
  # Se tamanho_col fornecido mas cor_col não, usa mesma variável para cor
746
  if tamanho_col and tamanho_col != "Visualização Padrão" and not cor_col:
 
739
  )
740
 
741
  # Camadas base
742
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(m)
743
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(m)
744
 
745
  # Se tamanho_col fornecido mas cor_col não, usa mesma variável para cor
746
  if tamanho_col and tamanho_col != "Visualização Padrão" and not cor_col:
backend/app/services/elaboracao_service.py CHANGED
@@ -684,7 +684,7 @@ def load_dai_for_elaboracao(session: SessionState, caminho_arquivo: str) -> dict
684
  raise HTTPException(status_code=400, detail=msg)
685
 
686
  session.elaborador = elaborador
687
- session.outliers_anteriores = _clean_int_list(outliers_excluidos)
688
  periodo_normalizado = _normalizar_periodo_dados_mercado(periodo_dados_mercado)
689
 
690
  base = _set_dataframe_base(session, df, clear_models=True)
@@ -693,6 +693,7 @@ def load_dai_for_elaboracao(session: SessionState, caminho_arquivo: str) -> dict
693
  session.periodo_dados_mercado_fim = periodo_normalizado["data_final"]
694
  session.transformacao_y = str(transformacao_y or "(x)")
695
  session.transformacoes_x = {str(k): str(v) for k, v in (transformacoes_x or {}).items()}
 
696
 
697
  selection_payload = apply_selection(
698
  session,
@@ -701,7 +702,7 @@ def load_dai_for_elaboracao(session: SessionState, caminho_arquivo: str) -> dict
701
  dicotomicas=[str(c) for c in (dicotomicas or [])],
702
  codigo_alocado=[str(c) for c in (codigo_alocado or [])],
703
  percentuais=[str(c) for c in (percentuais or [])],
704
- outliers_anteriores=session.outliers_anteriores,
705
  grau_min_coef=0,
706
  grau_min_f=0,
707
  )
@@ -1112,37 +1113,88 @@ def _montar_tabela_outliers_excluidos(session: SessionState) -> dict[str, Any] |
1112
  return dataframe_to_payload(tabela_df, decimals=4)
1113
 
1114
 
1115
- def gerar_grafico_dispersao_modelo(session: SessionState, tipo: str) -> dict[str, Any]:
 
 
 
 
 
 
1116
  if not session.resultado_modelo:
1117
  raise HTTPException(status_code=400, detail="Ajuste um modelo primeiro")
1118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1119
  try:
1120
- if "Nao Transformadas" in tipo or "Não Transformadas" in tipo:
1121
- tabela = session.resultado_modelo.get("tabela_obs_calc")
1122
- residuos = tabela["Resíduo Pad."].values if tabela is not None and "Resíduo Pad." in tabela.columns else None
1123
- indices = list(session.resultado_modelo.get("indices_usados", []) or [])
1124
- if session.df_filtrado is not None and session.colunas_x:
1125
- if indices:
1126
- x_base = session.df_filtrado.reindex(indices)
 
 
 
 
 
1127
  else:
1128
- x_base = session.df_filtrado
1129
- x_base = x_base.loc[:, [c for c in session.colunas_x if c in x_base.columns]]
1130
- else:
1131
- x_base = session.resultado_modelo["X_transformado"]
1132
  fig = charts.criar_graficos_dispersao_residuos(x_base, residuos)
1133
- elif "Residuo" in tipo or "Resíduo" in tipo:
1134
- tabela = session.resultado_modelo.get("tabela_obs_calc")
1135
- residuos = tabela["Resíduo Pad."].values if tabela is not None and "Resíduo Pad." in tabela.columns else None
1136
- fig = charts.criar_graficos_dispersao_residuos(session.resultado_modelo["X_transformado"], residuos)
 
 
 
 
 
 
 
 
 
 
 
1137
  else:
1138
- fig = charts.criar_graficos_dispersao(
1139
- session.resultado_modelo["X_transformado"],
1140
- session.resultado_modelo["y_transformado"],
1141
- )
 
 
1142
  except Exception:
1143
  fig = None
1144
 
1145
- return {"grafico": figure_to_payload(fig)}
 
 
 
 
 
 
 
1146
 
1147
 
1148
  def apply_outlier_filters(session: SessionState, filtros: list[dict[str, Any]]) -> dict[str, Any]:
@@ -1720,17 +1772,53 @@ def exportar_equacao(session: SessionState, mode: str) -> tuple[str, str]:
1720
  return caminho, nome
1721
 
1722
 
1723
- def atualizar_mapa(session: SessionState, var_mapa: str | None) -> dict[str, Any]:
1724
  df = session.df_filtrado if session.df_filtrado is not None else session.df_original
1725
  if df is None:
1726
  raise HTTPException(status_code=400, detail="Carregue dados primeiro")
1727
 
1728
  tamanho_col = None if not var_mapa or var_mapa == "Visualizacao Padrao" or var_mapa == "Visualização Padrão" else var_mapa
 
 
 
1729
  session.mapa_habilitado = True
1730
- mapa_html = charts.criar_mapa(df, tamanho_col=tamanho_col)
1731
  return {"mapa_html": mapa_html}
1732
 
1733
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1734
  def mapear_coordenadas_manualmente(session: SessionState, col_lat: str, col_lon: str) -> dict[str, Any]:
1735
  df = session.df_original
1736
  if df is None:
@@ -1901,21 +1989,36 @@ def aplicar_correcoes_geocodificacao(
1901
  raise HTTPException(status_code=400, detail="Contexto de geocodificacao ausente")
1902
 
1903
  df_falhas = session.geo_falhas_df.copy()
1904
- mapa_correcao = {
1905
  int(item.get("linha")): str(item.get("numero_corrigido", "")).strip()
1906
  for item in correcoes
1907
  if item.get("linha") is not None
1908
  }
 
 
 
 
 
1909
 
1910
  if "numero_corrigido" not in df_falhas.columns:
1911
  df_falhas["numero_corrigido"] = ""
1912
- if mapa_correcao:
 
 
 
1913
  linhas_ref = pd.to_numeric(df_falhas["_idx"], errors="coerce")
1914
- valores_corrigidos = linhas_ref.map(mapa_correcao).fillna("")
1915
  df_falhas["numero_corrigido"] = valores_corrigidos.astype(str)
1916
  else:
1917
  df_falhas["numero_corrigido"] = ""
1918
 
 
 
 
 
 
 
 
1919
  try:
1920
  df_resultado, df_falhas_novas, ajustados, manuais = geocodificacao.aplicar_correcoes_e_regeodificar(
1921
  session.df_original,
 
684
  raise HTTPException(status_code=400, detail=msg)
685
 
686
  session.elaborador = elaborador
687
+ outliers_carregados = _clean_int_list(outliers_excluidos)
688
  periodo_normalizado = _normalizar_periodo_dados_mercado(periodo_dados_mercado)
689
 
690
  base = _set_dataframe_base(session, df, clear_models=True)
 
693
  session.periodo_dados_mercado_fim = periodo_normalizado["data_final"]
694
  session.transformacao_y = str(transformacao_y or "(x)")
695
  session.transformacoes_x = {str(k): str(v) for k, v in (transformacoes_x or {}).items()}
696
+ session.outliers_anteriores = outliers_carregados
697
 
698
  selection_payload = apply_selection(
699
  session,
 
702
  dicotomicas=[str(c) for c in (dicotomicas or [])],
703
  codigo_alocado=[str(c) for c in (codigo_alocado or [])],
704
  percentuais=[str(c) for c in (percentuais or [])],
705
+ outliers_anteriores=outliers_carregados,
706
  grau_min_coef=0,
707
  grau_min_f=0,
708
  )
 
1113
  return dataframe_to_payload(tabela_df, decimals=4)
1114
 
1115
 
1116
+ def gerar_grafico_dispersao_modelo(
1117
+ session: SessionState,
1118
+ eixo_x: str = "transformado",
1119
+ eixo_y_tipo: str = "y_transformado",
1120
+ eixo_y_residuo: str | None = None,
1121
+ eixo_y_coluna: str | None = None,
1122
+ ) -> dict[str, Any]:
1123
  if not session.resultado_modelo:
1124
  raise HTTPException(status_code=400, detail="Ajuste um modelo primeiro")
1125
 
1126
+ resultado = session.resultado_modelo or {}
1127
+ x_transformado = resultado.get("X_transformado")
1128
+ y_transformado = resultado.get("y_transformado")
1129
+ tabela_obs_calc = resultado.get("tabela_obs_calc")
1130
+ df_base = session.df_filtrado if session.df_filtrado is not None else session.df_original
1131
+
1132
+ if x_transformado is None or getattr(x_transformado, "empty", True):
1133
+ return {"grafico": None}
1134
+
1135
+ eixo_x_norm = str(eixo_x or "transformado").strip().lower()
1136
+ eixo_y_norm = str(eixo_y_tipo or "y_transformado").strip().lower()
1137
+ eixo_residuo_norm = str(eixo_y_residuo or "residuo_pad").strip().lower()
1138
+
1139
+ indices_modelo = list(x_transformado.index)
1140
+ colunas_x_validas = [col for col in (session.colunas_x or []) if col in x_transformado.columns]
1141
+
1142
+ x_base = x_transformado
1143
+ if eixo_x_norm in {"nao_transformado", "não_transformado"} and df_base is not None:
1144
+ cols_origem = [col for col in (session.colunas_x or []) if col in df_base.columns]
1145
+ if cols_origem:
1146
+ x_base = df_base.reindex(indices_modelo)
1147
+ x_base = x_base.loc[:, cols_origem]
1148
+
1149
  try:
1150
+ if eixo_y_norm == "residuo":
1151
+ residuos_map = {
1152
+ "residuo_pad": "Resíduo Pad.",
1153
+ "residuo_stud": "Resíduo Stud.",
1154
+ "cook": "Cook",
1155
+ }
1156
+ col_residuo = residuos_map.get(eixo_residuo_norm, "Resíduo Pad.")
1157
+ residuos = None
1158
+ if tabela_obs_calc is not None and col_residuo in tabela_obs_calc.columns:
1159
+ if "Índice" in tabela_obs_calc.columns:
1160
+ serie_res = tabela_obs_calc.set_index("Índice")[col_residuo]
1161
+ residuos = serie_res.reindex(x_base.index).to_numpy()
1162
  else:
1163
+ residuos = tabela_obs_calc[col_residuo].to_numpy()
 
 
 
1164
  fig = charts.criar_graficos_dispersao_residuos(x_base, residuos)
1165
+ elif eixo_y_norm in {"y_nao_transformado", "y_nao_transf", "y_original"}:
1166
+ y_base = None
1167
+ if df_base is not None and session.coluna_y and session.coluna_y in df_base.columns:
1168
+ y_base = df_base.reindex(x_base.index)[session.coluna_y].copy()
1169
+ y_base.name = f"{session.coluna_y} (não transformado)"
1170
+ elif y_transformado is not None:
1171
+ y_base = y_transformado
1172
+ fig = charts.criar_graficos_dispersao(x_base, y_base)
1173
+ elif eixo_y_norm == "coluna_original":
1174
+ coluna = str(eixo_y_coluna or "").strip()
1175
+ y_base = None
1176
+ if coluna and df_base is not None and coluna in df_base.columns:
1177
+ y_base = df_base.reindex(x_base.index)[coluna].copy()
1178
+ y_base.name = coluna
1179
+ fig = charts.criar_graficos_dispersao(x_base, y_base)
1180
  else:
1181
+ y_base = y_transformado
1182
+ if isinstance(y_base, pd.Series):
1183
+ y_base = y_base.copy()
1184
+ nome_y = session.coluna_y or str(y_base.name or "Y")
1185
+ y_base.name = f"{nome_y} (transformado)"
1186
+ fig = charts.criar_graficos_dispersao(x_base, y_base)
1187
  except Exception:
1188
  fig = None
1189
 
1190
+ return {
1191
+ "grafico": figure_to_payload(fig),
1192
+ "eixo_x_aplicado": "nao_transformado" if eixo_x_norm in {"nao_transformado", "não_transformado"} else "transformado",
1193
+ "eixo_y_tipo_aplicado": eixo_y_norm,
1194
+ "eixo_y_residuo_aplicado": eixo_residuo_norm if eixo_y_norm == "residuo" else None,
1195
+ "eixo_y_coluna_aplicado": str(eixo_y_coluna or "").strip() if eixo_y_norm == "coluna_original" else None,
1196
+ "colunas_x_aplicadas": sanitize_value(list(x_base.columns)) if hasattr(x_base, "columns") else sanitize_value(colunas_x_validas),
1197
+ }
1198
 
1199
 
1200
  def apply_outlier_filters(session: SessionState, filtros: list[dict[str, Any]]) -> dict[str, Any]:
 
1772
  return caminho, nome
1773
 
1774
 
1775
+ def atualizar_mapa(session: SessionState, var_mapa: str | None, modo_mapa: str | None = None) -> dict[str, Any]:
1776
  df = session.df_filtrado if session.df_filtrado is not None else session.df_original
1777
  if df is None:
1778
  raise HTTPException(status_code=400, detail="Carregue dados primeiro")
1779
 
1780
  tamanho_col = None if not var_mapa or var_mapa == "Visualizacao Padrao" or var_mapa == "Visualização Padrão" else var_mapa
1781
+ modo = str(modo_mapa or "pontos").strip().lower()
1782
+ if tamanho_col is None:
1783
+ modo = "pontos"
1784
  session.mapa_habilitado = True
1785
+ mapa_html = charts.criar_mapa(df, tamanho_col=tamanho_col, modo=modo)
1786
  return {"mapa_html": mapa_html}
1787
 
1788
 
1789
+ def atualizar_mapa_residuos(session: SessionState, var_mapa: str | None, modo_mapa: str | None = None) -> dict[str, Any]:
1790
+ tabela_metricas = session.tabela_metricas_estado
1791
+ if tabela_metricas is None or tabela_metricas.empty:
1792
+ raise HTTPException(status_code=400, detail="Ajuste o modelo para gerar métricas de resíduos")
1793
+
1794
+ df = tabela_metricas.copy()
1795
+ var_escolhida = "Resíduo Pad."
1796
+ if var_escolhida not in df.columns:
1797
+ raise HTTPException(status_code=400, detail="Coluna 'Resíduo Pad.' não disponível para mapear")
1798
+
1799
+ modo = str(modo_mapa or "pontos").strip().lower()
1800
+ if modo not in {"pontos", "calor", "superficie"}:
1801
+ modo = "pontos"
1802
+
1803
+ mapa_html = charts.criar_mapa(
1804
+ df,
1805
+ tamanho_col=var_escolhida,
1806
+ modo=modo,
1807
+ cor_vmin=-5.0,
1808
+ cor_vmax=5.0,
1809
+ cor_caption="Resíduo Pad. (escala fixa -5 a +5)",
1810
+ cor_colors=["#0b5d1e", "#1f8f3a", "#58b96b", "#f1c40f", "#f1c40f", "#f39c12", "#d04a2f", "#8b0000"],
1811
+ cor_stops=[-5.0, -2.6, -2.0, -1.5, 1.5, 2.0, 2.6, 5.0],
1812
+ 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],
1813
+ cor_tick_labels=["-5", "-4", "-3", "-2", "-1", "0", "1", "2", "3", "4", "5"],
1814
+ )
1815
+ return {
1816
+ "mapa_html": mapa_html,
1817
+ "variavel_mapa": var_escolhida,
1818
+ "modo_mapa": modo,
1819
+ }
1820
+
1821
+
1822
  def mapear_coordenadas_manualmente(session: SessionState, col_lat: str, col_lon: str) -> dict[str, Any]:
1823
  df = session.df_original
1824
  if df is None:
 
1989
  raise HTTPException(status_code=400, detail="Contexto de geocodificacao ausente")
1990
 
1991
  df_falhas = session.geo_falhas_df.copy()
1992
+ mapa_correcao_numero = {
1993
  int(item.get("linha")): str(item.get("numero_corrigido", "")).strip()
1994
  for item in correcoes
1995
  if item.get("linha") is not None
1996
  }
1997
+ mapa_correcao_cdlog = {
1998
+ int(item.get("linha")): str(item.get("cdlog_corrigido", "")).strip()
1999
+ for item in correcoes
2000
+ if item.get("linha") is not None
2001
+ }
2002
 
2003
  if "numero_corrigido" not in df_falhas.columns:
2004
  df_falhas["numero_corrigido"] = ""
2005
+ if "cdlog_corrigido" not in df_falhas.columns:
2006
+ df_falhas["cdlog_corrigido"] = ""
2007
+
2008
+ if mapa_correcao_numero:
2009
  linhas_ref = pd.to_numeric(df_falhas["_idx"], errors="coerce")
2010
+ valores_corrigidos = linhas_ref.map(mapa_correcao_numero).fillna("")
2011
  df_falhas["numero_corrigido"] = valores_corrigidos.astype(str)
2012
  else:
2013
  df_falhas["numero_corrigido"] = ""
2014
 
2015
+ if mapa_correcao_cdlog:
2016
+ linhas_ref = pd.to_numeric(df_falhas["_idx"], errors="coerce")
2017
+ valores_corrigidos = linhas_ref.map(mapa_correcao_cdlog).fillna("")
2018
+ df_falhas["cdlog_corrigido"] = valores_corrigidos.astype(str)
2019
+ else:
2020
+ df_falhas["cdlog_corrigido"] = ""
2021
+
2022
  try:
2023
  df_resultado, df_falhas_novas, ajustados, manuais = geocodificacao.aplicar_correcoes_e_regeodificar(
2024
  session.df_original,
backend/app/services/pesquisa_service.py CHANGED
@@ -442,8 +442,10 @@ def gerar_mapa_modelos(modelos_ids: list[str], limite_pontos_por_modelo: int = 4
442
  location=[centro_lat, centro_lon],
443
  zoom_start=12,
444
  control_scale=True,
445
- tiles="CartoDB positron",
446
  )
 
 
447
 
448
  total_pontos = 0
449
  for modelo in modelos_plotados:
 
442
  location=[centro_lat, centro_lon],
443
  zoom_start=12,
444
  control_scale=True,
445
+ tiles=None,
446
  )
447
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(mapa)
448
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
449
 
450
  total_pontos = 0
451
  for modelo in modelos_plotados:
frontend/src/api.js CHANGED
@@ -155,7 +155,10 @@ export const api = {
155
  return postJson('/api/elaboracao/fit-model', payload)
156
  },
157
 
158
- updateModelDispersao: (sessionId, tipo) => postJson('/api/elaboracao/model-dispersao', { session_id: sessionId, tipo }),
 
 
 
159
  previewTransformElab: (sessionId, transformacaoY, transformacoesX) => postJson('/api/elaboracao/transform-preview', {
160
  session_id: sessionId,
161
  transformacao_y: transformacaoY,
@@ -207,7 +210,16 @@ export const api = {
207
  return response.blob()
208
  },
209
  exportBase: (sessionId, filtered = true) => getBlob(`/api/elaboracao/export-base?session_id=${encodeURIComponent(sessionId)}&filtered=${String(filtered)}`),
210
- updateElaboracaoMap: (sessionId, variavelMapa) => postJson('/api/elaboracao/map/update', { session_id: sessionId, variavel_mapa: variavelMapa }),
 
 
 
 
 
 
 
 
 
211
  previewMarketDateColumn: (sessionId, colunaData) => postJson('/api/elaboracao/market-date/preview', { session_id: sessionId, coluna_data: colunaData }),
212
  applyMarketDateColumn: (sessionId, colunaData) => postJson('/api/elaboracao/market-date/apply', { session_id: sessionId, coluna_data: colunaData }),
213
  getContext: (sessionId) => getJson(`/api/elaboracao/context?session_id=${encodeURIComponent(sessionId)}`),
 
155
  return postJson('/api/elaboracao/fit-model', payload)
156
  },
157
 
158
+ updateModelDispersao: (sessionId, payload) => postJson('/api/elaboracao/model-dispersao', {
159
+ session_id: sessionId,
160
+ ...(payload || {}),
161
+ }),
162
  previewTransformElab: (sessionId, transformacaoY, transformacoesX) => postJson('/api/elaboracao/transform-preview', {
163
  session_id: sessionId,
164
  transformacao_y: transformacaoY,
 
210
  return response.blob()
211
  },
212
  exportBase: (sessionId, filtered = true) => getBlob(`/api/elaboracao/export-base?session_id=${encodeURIComponent(sessionId)}&filtered=${String(filtered)}`),
213
+ updateElaboracaoMap: (sessionId, variavelMapa, modoMapa = 'pontos') => postJson('/api/elaboracao/map/update', {
214
+ session_id: sessionId,
215
+ variavel_mapa: variavelMapa,
216
+ modo_mapa: modoMapa,
217
+ }),
218
+ updateElaboracaoResiduosMap: (sessionId, variavelMapa, modoMapa = 'pontos') => postJson('/api/elaboracao/residuos/map/update', {
219
+ session_id: sessionId,
220
+ variavel_mapa: variavelMapa,
221
+ modo_mapa: modoMapa,
222
+ }),
223
  previewMarketDateColumn: (sessionId, colunaData) => postJson('/api/elaboracao/market-date/preview', { session_id: sessionId, coluna_data: colunaData }),
224
  applyMarketDateColumn: (sessionId, colunaData) => postJson('/api/elaboracao/market-date/apply', { session_id: sessionId, coluna_data: colunaData }),
225
  getContext: (sessionId) => getJson(`/api/elaboracao/context?session_id=${encodeURIComponent(sessionId)}`),
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -27,6 +27,11 @@ const GRAU_LABEL_CURTO = {
27
  2: 'Grau II',
28
  3: 'Grau III',
29
  }
 
 
 
 
 
30
  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.'
31
 
32
  function grauBadgeClass(value) {
@@ -552,7 +557,11 @@ function buildArquivoCarregadoInfo(resp, options = {}) {
552
  const extMatch = fileName.match(/\.([^.]+)$/)
553
  const tipoArquivo = extMatch?.[1] ? String(extMatch[1]).toUpperCase() : '-'
554
  const sheetName = String(options.sheetName || resp?.sheet_selected || '').trim()
555
- const totalLinhas = Array.isArray(resp?.dados?.rows) ? resp.dados.rows.length : null
 
 
 
 
556
  const totalColunas = Array.isArray(resp?.dados?.columns) ? resp.dados.columns.length : null
557
  const totalAbas = Array.isArray(resp?.sheets) ? resp.sheets.length : 0
558
 
@@ -628,8 +637,12 @@ export default function ElaboracaoTab({ sessionId }) {
628
 
629
  const [dados, setDados] = useState(null)
630
  const [mapaHtml, setMapaHtml] = useState('')
631
- const [mapaVariavel, setMapaVariavel] = useState('Visualização Padrão')
 
632
  const [mapaGerado, setMapaGerado] = useState(false)
 
 
 
633
 
634
  const [coordsInfo, setCoordsInfo] = useState(null)
635
  const [manualLat, setManualLat] = useState('')
@@ -678,7 +691,10 @@ export default function ElaboracaoTab({ sessionId }) {
678
  const [section6EditOpen, setSection6EditOpen] = useState(true)
679
 
680
  const [fit, setFit] = useState(null)
681
- const [tipoDispersao, setTipoDispersao] = useState('Variáveis Independentes Transformadas X Variável Dependente Transformada')
 
 
 
682
 
683
  const [filtros, setFiltros] = useState(defaultFiltros())
684
  const [outliersTexto, setOutliersTexto] = useState('')
@@ -713,7 +729,8 @@ export default function ElaboracaoTab({ sessionId }) {
713
  const [disabledHint, setDisabledHint] = useState(null)
714
  const [sectionsMountKey, setSectionsMountKey] = useState(0)
715
 
716
- const mapaChoices = useMemo(() => ['Visualização Padrão', ...colunasNumericas], [colunasNumericas])
 
717
  const colunasXDisponiveis = useMemo(
718
  () => (colunaY ? colunasNumericas.filter((coluna) => coluna !== colunaY) : []),
719
  [colunasNumericas, colunaY],
@@ -919,15 +936,37 @@ export default function ElaboracaoTab({ sessionId }) {
919
  () => Math.max(1, Math.min(3, graficosSecao9.length || 1)),
920
  [graficosSecao9.length],
921
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
922
  const graficosSecao12 = useMemo(
923
  () => buildScatterPanels(fit?.grafico_dispersao_modelo, {
924
  singleLabel: 'Dispersão do modelo',
925
  height: 360,
926
- yLabel: tipoDispersao.includes('Resíduo')
927
- ? 'Resíduo Padronizado'
928
- : `${colunaY || 'Y'} (transformada)`,
929
  }),
930
- [fit?.grafico_dispersao_modelo, tipoDispersao, colunaY],
931
  )
932
  const colunasGraficosSecao12 = useMemo(
933
  () => Math.max(1, Math.min(3, graficosSecao12.length || 1)),
@@ -1029,6 +1068,22 @@ export default function ElaboracaoTab({ sessionId }) {
1029
  }
1030
  }, [algumaXMarcada, todasXMarcadas])
1031
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1032
  useEffect(() => {
1033
  if (!sessionId || tipoFonteDados === 'dai') return undefined
1034
 
@@ -1168,11 +1223,20 @@ export default function ElaboracaoTab({ sessionId }) {
1168
  if (!table?.rows) return []
1169
  return table.rows.map((row) => {
1170
  const linha = row['Nº Linha'] ?? row['No Linha'] ?? row['linha'] ?? row['_index']
 
 
 
 
 
1171
  const sugestoes = parseSugestoes(row['Sugestões'] ?? row['Sugestoes'] ?? row['sugestoes'] ?? '')
1172
  const sugestaoProxima = sugestoes[0] || ''
1173
  return {
1174
  linha: Number(linha),
 
 
 
1175
  numero_corrigido: row['Nº Corrigido'] ?? row['No Corrigido'] ?? '',
 
1176
  sugestoes,
1177
  sugestao_proxima: sugestaoProxima,
1178
  }
@@ -1371,6 +1435,12 @@ export default function ElaboracaoTab({ sessionId }) {
1371
 
1372
  function applyFitResponse(resp, origemMeta = null) {
1373
  setFit(resp)
 
 
 
 
 
 
1374
  const transformacaoYAplicada = resp.transformacao_y || transformacaoY
1375
  const transformacoesXAplicadas = resp.transformacoes_x || transformacoesX
1376
  if (resp.transformacao_y) {
@@ -1496,6 +1566,11 @@ export default function ElaboracaoTab({ sessionId }) {
1496
  await withBusy(async () => {
1497
  setMapaGerado(false)
1498
  setMapaHtml('')
 
 
 
 
 
1499
  setGeoAuto200(true)
1500
  setSelectedSheet('')
1501
  setRequiresSheet(false)
@@ -1521,6 +1596,11 @@ export default function ElaboracaoTab({ sessionId }) {
1521
  await withBusy(async () => {
1522
  setMapaGerado(false)
1523
  setMapaHtml('')
 
 
 
 
 
1524
  setGeoAuto200(true)
1525
  setSelectedSheet('')
1526
  setRequiresSheet(false)
@@ -1575,6 +1655,11 @@ export default function ElaboracaoTab({ sessionId }) {
1575
  await withBusy(async () => {
1576
  setMapaGerado(false)
1577
  setMapaHtml('')
 
 
 
 
 
1578
  setGeoAuto200(true)
1579
  const resp = await api.confirmSheet(sessionId, selectedSheet)
1580
  setTipoFonteDados('tabular')
@@ -1800,7 +1885,7 @@ export default function ElaboracaoTab({ sessionId }) {
1800
  }
1801
 
1802
  function onLimparCorrecoesGeo() {
1803
- setGeoCorrecoes((prev) => prev.map((item) => ({ ...item, numero_corrigido: '' })))
1804
  }
1805
 
1806
  async function onApplySelection() {
@@ -1888,11 +1973,25 @@ export default function ElaboracaoTab({ sessionId }) {
1888
  })
1889
  }
1890
 
1891
- async function onTipoDispersaoChange(nextTipo) {
1892
- setTipoDispersao(nextTipo)
 
 
 
 
 
 
 
 
 
1893
  if (!sessionId) return
1894
  await withBusy(async () => {
1895
- const resp = await api.updateModelDispersao(sessionId, nextTipo)
 
 
 
 
 
1896
  setFit((prev) => ({ ...prev, grafico_dispersao_modelo: resp.grafico }))
1897
  })
1898
  }
@@ -2191,22 +2290,57 @@ export default function ElaboracaoTab({ sessionId }) {
2191
 
2192
  async function onMapVarChange(value) {
2193
  setMapaVariavel(value)
 
 
 
 
2194
  if (!sessionId || !mapaGerado) return
2195
  await withBusy(async () => {
2196
- const resp = await api.updateElaboracaoMap(sessionId, value)
 
 
 
 
 
 
 
 
 
2197
  setMapaHtml(resp.mapa_html || '')
2198
  })
2199
  }
2200
 
2201
  async function onGerarMapa() {
2202
  if (!sessionId) return
 
 
 
 
2203
  await withBusy(async () => {
2204
- const resp = await api.updateElaboracaoMap(sessionId, mapaVariavel)
2205
  setMapaHtml(resp.mapa_html || '')
2206
  setMapaGerado(true)
2207
  })
2208
  }
2209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2210
  function onDownloadTableCsv(table, fileNameBase) {
2211
  const blob = tableToCsvBlob(table)
2212
  if (!blob) {
@@ -2251,6 +2385,15 @@ export default function ElaboracaoTab({ sessionId }) {
2251
  downloadBlob(blob, 'secao3_mapa_dados_mercado.html')
2252
  }
2253
 
 
 
 
 
 
 
 
 
 
2254
  async function exportFigureAsPng(figure, fileNameBase, options = {}) {
2255
  const payload = buildFigureForExport(figure, Boolean(options.forceHideLegend))
2256
  if (!payload) {
@@ -2780,6 +2923,26 @@ export default function ElaboracaoTab({ sessionId }) {
2780
  {geoCorrecoes.map((item, idx) => (
2781
  <div className="geo-correcao-item" key={`cor-${item.linha}-${idx}`}>
2782
  <span className="geo-correcao-linha">Linha {item.linha}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2783
  <input
2784
  type="text"
2785
  value={item.numero_corrigido || ''}
@@ -2877,13 +3040,25 @@ export default function ElaboracaoTab({ sessionId }) {
2877
  ) : (
2878
  <details className="dados-mapa-details" open>
2879
  <summary>Mapa</summary>
2880
- <div className="row compact dados-mapa-controls">
2881
- <label>Variável no mapa</label>
2882
- <select value={mapaVariavel} onChange={(e) => onMapVarChange(e.target.value)}>
2883
- {mapaChoices.map((choice) => (
2884
- <option key={choice} value={choice}>{choice}</option>
2885
- ))}
2886
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
2887
  </div>
2888
  <div className="download-actions-bar">
2889
  <button
@@ -3421,14 +3596,76 @@ export default function ElaboracaoTab({ sessionId }) {
3421
 
3422
  {fit ? (
3423
  <>
3424
- <SectionBlock step="13" title="Gráficos de Dispersão (Variáveis Transformadas)" subtitle="Dispersão com variáveis transformadas.">
3425
- <div className="row">
3426
- <label>Tipo de dispersão</label>
3427
- <select value={tipoDispersao} onChange={(e) => onTipoDispersaoChange(e.target.value)}>
3428
- <option value="Variáveis Independentes Transformadas X Variável Dependente Transformada">X transformado x Y transformado</option>
3429
- <option value="Variáveis Independentes Transformadas X Resíduo Padronizado">X transformado x Resíduo</option>
3430
- <option value="Variáveis Independentes Não Transformadas X Resíduo Padronizado">X não transformado x Resíduo</option>
3431
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3432
  </div>
3433
  <div className="download-actions-bar">
3434
  {graficosSecao12.length > 1 ? <span className="download-actions-label">Fazer download:</span> : null}
@@ -3604,7 +3841,44 @@ export default function ElaboracaoTab({ sessionId }) {
3604
  </div>
3605
  </SectionBlock>
3606
 
3607
- <SectionBlock step="16" title="Analisar Outliers" subtitle="Métricas para identificação de observações influentes.">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3608
  <div className="download-actions-bar">
3609
  <button
3610
  type="button"
 
27
  2: 'Grau II',
28
  3: 'Grau III',
29
  }
30
+ const MAPA_VARIAVEL_PADRAO = 'Visualização Padrão'
31
+ const MAPA_MODO_PONTOS = 'pontos'
32
+ const MAPA_MODO_CALOR = 'calor'
33
+ const MAPA_MODO_SUPERFICIE = 'superficie'
34
+ const MAPA_RESIDUOS_VARIAVEL = 'Resíduo Pad.'
35
  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.'
36
 
37
  function grauBadgeClass(value) {
 
557
  const extMatch = fileName.match(/\.([^.]+)$/)
558
  const tipoArquivo = extMatch?.[1] ? String(extMatch[1]).toUpperCase() : '-'
559
  const sheetName = String(options.sheetName || resp?.sheet_selected || '').trim()
560
+ const totalLinhas = Number.isFinite(Number(resp?.dados?.total_rows))
561
+ ? Number(resp.dados.total_rows)
562
+ : Array.isArray(resp?.dados?.rows)
563
+ ? resp.dados.rows.length
564
+ : null
565
  const totalColunas = Array.isArray(resp?.dados?.columns) ? resp.dados.columns.length : null
566
  const totalAbas = Array.isArray(resp?.sheets) ? resp.sheets.length : 0
567
 
 
637
 
638
  const [dados, setDados] = useState(null)
639
  const [mapaHtml, setMapaHtml] = useState('')
640
+ const [mapaVariavel, setMapaVariavel] = useState(MAPA_VARIAVEL_PADRAO)
641
+ const [mapaModo, setMapaModo] = useState(MAPA_MODO_PONTOS)
642
  const [mapaGerado, setMapaGerado] = useState(false)
643
+ const [mapaResiduosHtml, setMapaResiduosHtml] = useState('')
644
+ const [mapaResiduosModo, setMapaResiduosModo] = useState(MAPA_MODO_PONTOS)
645
+ const [mapaResiduosGerado, setMapaResiduosGerado] = useState(false)
646
 
647
  const [coordsInfo, setCoordsInfo] = useState(null)
648
  const [manualLat, setManualLat] = useState('')
 
691
  const [section6EditOpen, setSection6EditOpen] = useState(true)
692
 
693
  const [fit, setFit] = useState(null)
694
+ const [dispersaoEixoX, setDispersaoEixoX] = useState('transformado')
695
+ const [dispersaoEixoYTipo, setDispersaoEixoYTipo] = useState('y_transformado')
696
+ const [dispersaoEixoYResiduo, setDispersaoEixoYResiduo] = useState('residuo_pad')
697
+ const [dispersaoEixoYColuna, setDispersaoEixoYColuna] = useState('')
698
 
699
  const [filtros, setFiltros] = useState(defaultFiltros())
700
  const [outliersTexto, setOutliersTexto] = useState('')
 
729
  const [disabledHint, setDisabledHint] = useState(null)
730
  const [sectionsMountKey, setSectionsMountKey] = useState(0)
731
 
732
+ const mapaChoices = useMemo(() => [MAPA_VARIAVEL_PADRAO, ...colunasNumericas], [colunasNumericas])
733
+ const mapaModoDisponivel = mapaVariavel !== MAPA_VARIAVEL_PADRAO
734
  const colunasXDisponiveis = useMemo(
735
  () => (colunaY ? colunasNumericas.filter((coluna) => coluna !== colunaY) : []),
736
  [colunasNumericas, colunaY],
 
936
  () => Math.max(1, Math.min(3, graficosSecao9.length || 1)),
937
  [graficosSecao9.length],
938
  )
939
+ const colunasOriginaisDispersao = useMemo(() => {
940
+ const cols = Array.isArray(dados?.columns) ? dados.columns : []
941
+ return cols
942
+ .map((item) => String(item || '').trim())
943
+ .filter((item) => item && item !== '_index')
944
+ }, [dados])
945
+ const colunaYComRotulo = useMemo(
946
+ () => (colunaY ? `${colunaY} (Y)` : 'Y'),
947
+ [colunaY],
948
+ )
949
+ const yLabelSecao13 = useMemo(() => {
950
+ if (dispersaoEixoYTipo === 'residuo') {
951
+ if (dispersaoEixoYResiduo === 'residuo_stud') return 'Resíduo Studentizado'
952
+ if (dispersaoEixoYResiduo === 'cook') return 'Distância de Cook'
953
+ return 'Resíduo Padronizado'
954
+ }
955
+ if (dispersaoEixoYTipo === 'y_nao_transformado') {
956
+ return `${colunaYComRotulo} não transformado`
957
+ }
958
+ if (dispersaoEixoYTipo === 'coluna_original') {
959
+ return dispersaoEixoYColuna || 'Outra coluna'
960
+ }
961
+ return `${colunaYComRotulo} transformado`
962
+ }, [dispersaoEixoYTipo, dispersaoEixoYResiduo, dispersaoEixoYColuna, colunaYComRotulo])
963
  const graficosSecao12 = useMemo(
964
  () => buildScatterPanels(fit?.grafico_dispersao_modelo, {
965
  singleLabel: 'Dispersão do modelo',
966
  height: 360,
967
+ yLabel: yLabelSecao13,
 
 
968
  }),
969
+ [fit?.grafico_dispersao_modelo, yLabelSecao13],
970
  )
971
  const colunasGraficosSecao12 = useMemo(
972
  () => Math.max(1, Math.min(3, graficosSecao12.length || 1)),
 
1068
  }
1069
  }, [algumaXMarcada, todasXMarcadas])
1070
 
1071
+ useEffect(() => {
1072
+ if (mapaChoices.includes(mapaVariavel)) return
1073
+ setMapaVariavel(MAPA_VARIAVEL_PADRAO)
1074
+ setMapaModo(MAPA_MODO_PONTOS)
1075
+ }, [mapaChoices, mapaVariavel])
1076
+
1077
+ useEffect(() => {
1078
+ if (colunasOriginaisDispersao.length === 0) {
1079
+ if (dispersaoEixoYColuna) setDispersaoEixoYColuna('')
1080
+ return
1081
+ }
1082
+ if (!dispersaoEixoYColuna || !colunasOriginaisDispersao.includes(dispersaoEixoYColuna)) {
1083
+ setDispersaoEixoYColuna(colunasOriginaisDispersao[0])
1084
+ }
1085
+ }, [colunasOriginaisDispersao, dispersaoEixoYColuna])
1086
+
1087
  useEffect(() => {
1088
  if (!sessionId || tipoFonteDados === 'dai') return undefined
1089
 
 
1223
  if (!table?.rows) return []
1224
  return table.rows.map((row) => {
1225
  const linha = row['Nº Linha'] ?? row['No Linha'] ?? row['linha'] ?? row['_index']
1226
+ const motivo = String(row['Motivo'] ?? row['motivo'] ?? '').trim()
1227
+ const motivoNormalizado = motivo
1228
+ .normalize('NFD')
1229
+ .replace(/[\u0300-\u036f]/g, '')
1230
+ .toLowerCase()
1231
  const sugestoes = parseSugestoes(row['Sugestões'] ?? row['Sugestoes'] ?? row['sugestoes'] ?? '')
1232
  const sugestaoProxima = sugestoes[0] || ''
1233
  return {
1234
  linha: Number(linha),
1235
+ motivo,
1236
+ cdlog_atual: String(row['Código Atual'] ?? row['Codigo Atual'] ?? row['CDLOG'] ?? row['cdlog'] ?? '').trim(),
1237
+ cdlog_corrigido: String(row['Código Corrigido'] ?? row['Codigo Corrigido'] ?? row['cdlog_corrigido'] ?? '').trim(),
1238
  numero_corrigido: row['Nº Corrigido'] ?? row['No Corrigido'] ?? '',
1239
+ exige_cdlog: motivoNormalizado.includes('cdlog'),
1240
  sugestoes,
1241
  sugestao_proxima: sugestaoProxima,
1242
  }
 
1435
 
1436
  function applyFitResponse(resp, origemMeta = null) {
1437
  setFit(resp)
1438
+ setDispersaoEixoX('transformado')
1439
+ setDispersaoEixoYTipo('y_transformado')
1440
+ setDispersaoEixoYResiduo('residuo_pad')
1441
+ setMapaResiduosModo(MAPA_MODO_PONTOS)
1442
+ setMapaResiduosHtml('')
1443
+ setMapaResiduosGerado(false)
1444
  const transformacaoYAplicada = resp.transformacao_y || transformacaoY
1445
  const transformacoesXAplicadas = resp.transformacoes_x || transformacoesX
1446
  if (resp.transformacao_y) {
 
1566
  await withBusy(async () => {
1567
  setMapaGerado(false)
1568
  setMapaHtml('')
1569
+ setMapaVariavel(MAPA_VARIAVEL_PADRAO)
1570
+ setMapaModo(MAPA_MODO_PONTOS)
1571
+ setMapaResiduosGerado(false)
1572
+ setMapaResiduosHtml('')
1573
+ setMapaResiduosModo(MAPA_MODO_PONTOS)
1574
  setGeoAuto200(true)
1575
  setSelectedSheet('')
1576
  setRequiresSheet(false)
 
1596
  await withBusy(async () => {
1597
  setMapaGerado(false)
1598
  setMapaHtml('')
1599
+ setMapaVariavel(MAPA_VARIAVEL_PADRAO)
1600
+ setMapaModo(MAPA_MODO_PONTOS)
1601
+ setMapaResiduosGerado(false)
1602
+ setMapaResiduosHtml('')
1603
+ setMapaResiduosModo(MAPA_MODO_PONTOS)
1604
  setGeoAuto200(true)
1605
  setSelectedSheet('')
1606
  setRequiresSheet(false)
 
1655
  await withBusy(async () => {
1656
  setMapaGerado(false)
1657
  setMapaHtml('')
1658
+ setMapaVariavel(MAPA_VARIAVEL_PADRAO)
1659
+ setMapaModo(MAPA_MODO_PONTOS)
1660
+ setMapaResiduosGerado(false)
1661
+ setMapaResiduosHtml('')
1662
+ setMapaResiduosModo(MAPA_MODO_PONTOS)
1663
  setGeoAuto200(true)
1664
  const resp = await api.confirmSheet(sessionId, selectedSheet)
1665
  setTipoFonteDados('tabular')
 
1885
  }
1886
 
1887
  function onLimparCorrecoesGeo() {
1888
+ setGeoCorrecoes((prev) => prev.map((item) => ({ ...item, cdlog_corrigido: '', numero_corrigido: '' })))
1889
  }
1890
 
1891
  async function onApplySelection() {
 
1973
  })
1974
  }
1975
 
1976
+ async function onDispersaoConfigChange(nextConfig) {
1977
+ const eixoX = String(nextConfig?.eixoX || dispersaoEixoX || 'transformado')
1978
+ const eixoYTipo = String(nextConfig?.eixoYTipo || dispersaoEixoYTipo || 'y_transformado')
1979
+ const eixoYResiduo = String(nextConfig?.eixoYResiduo || dispersaoEixoYResiduo || 'residuo_pad')
1980
+ const eixoYColuna = String(nextConfig?.eixoYColuna || dispersaoEixoYColuna || '')
1981
+
1982
+ setDispersaoEixoX(eixoX)
1983
+ setDispersaoEixoYTipo(eixoYTipo)
1984
+ setDispersaoEixoYResiduo(eixoYResiduo)
1985
+ setDispersaoEixoYColuna(eixoYColuna)
1986
+
1987
  if (!sessionId) return
1988
  await withBusy(async () => {
1989
+ const resp = await api.updateModelDispersao(sessionId, {
1990
+ eixo_x: eixoX,
1991
+ eixo_y_tipo: eixoYTipo,
1992
+ eixo_y_residuo: eixoYTipo === 'residuo' ? eixoYResiduo : null,
1993
+ eixo_y_coluna: eixoYTipo === 'coluna_original' ? eixoYColuna : null,
1994
+ })
1995
  setFit((prev) => ({ ...prev, grafico_dispersao_modelo: resp.grafico }))
1996
  })
1997
  }
 
2290
 
2291
  async function onMapVarChange(value) {
2292
  setMapaVariavel(value)
2293
+ const nextModo = value === MAPA_VARIAVEL_PADRAO ? MAPA_MODO_PONTOS : mapaModo
2294
+ if (nextModo !== mapaModo) {
2295
+ setMapaModo(nextModo)
2296
+ }
2297
  if (!sessionId || !mapaGerado) return
2298
  await withBusy(async () => {
2299
+ const resp = await api.updateElaboracaoMap(sessionId, value, nextModo)
2300
+ setMapaHtml(resp.mapa_html || '')
2301
+ })
2302
+ }
2303
+
2304
+ async function onMapModeChange(value) {
2305
+ setMapaModo(value)
2306
+ if (!sessionId || !mapaGerado) return
2307
+ await withBusy(async () => {
2308
+ const resp = await api.updateElaboracaoMap(sessionId, mapaVariavel, value)
2309
  setMapaHtml(resp.mapa_html || '')
2310
  })
2311
  }
2312
 
2313
  async function onGerarMapa() {
2314
  if (!sessionId) return
2315
+ const modoAtual = mapaVariavel === MAPA_VARIAVEL_PADRAO ? MAPA_MODO_PONTOS : mapaModo
2316
+ if (modoAtual !== mapaModo) {
2317
+ setMapaModo(modoAtual)
2318
+ }
2319
  await withBusy(async () => {
2320
+ const resp = await api.updateElaboracaoMap(sessionId, mapaVariavel, modoAtual)
2321
  setMapaHtml(resp.mapa_html || '')
2322
  setMapaGerado(true)
2323
  })
2324
  }
2325
 
2326
+ async function onMapaResiduosModoChange(value) {
2327
+ setMapaResiduosModo(value)
2328
+ if (!sessionId || !mapaResiduosGerado) return
2329
+ await withBusy(async () => {
2330
+ const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, value)
2331
+ setMapaResiduosHtml(resp.mapa_html || '')
2332
+ })
2333
+ }
2334
+
2335
+ async function onGerarMapaResiduos() {
2336
+ if (!sessionId) return
2337
+ await withBusy(async () => {
2338
+ const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, mapaResiduosModo)
2339
+ setMapaResiduosHtml(resp.mapa_html || '')
2340
+ setMapaResiduosGerado(true)
2341
+ })
2342
+ }
2343
+
2344
  function onDownloadTableCsv(table, fileNameBase) {
2345
  const blob = tableToCsvBlob(table)
2346
  if (!blob) {
 
2385
  downloadBlob(blob, 'secao3_mapa_dados_mercado.html')
2386
  }
2387
 
2388
+ function onDownloadMapaSecao16() {
2389
+ if (!mapaResiduosHtml) {
2390
+ setError('Mapa de resíduos padronizados indisponível para download.')
2391
+ return
2392
+ }
2393
+ const blob = new Blob([mapaResiduosHtml], { type: 'text/html;charset=utf-8;' })
2394
+ downloadBlob(blob, 'secao16_mapa_residuos.html')
2395
+ }
2396
+
2397
  async function exportFigureAsPng(figure, fileNameBase, options = {}) {
2398
  const payload = buildFigureForExport(figure, Boolean(options.forceHideLegend))
2399
  if (!payload) {
 
2923
  {geoCorrecoes.map((item, idx) => (
2924
  <div className="geo-correcao-item" key={`cor-${item.linha}-${idx}`}>
2925
  <span className="geo-correcao-linha">Linha {item.linha}</span>
2926
+ {item.motivo ? (
2927
+ <div className="geo-correcao-motivo">{item.motivo}</div>
2928
+ ) : null}
2929
+ {item.exige_cdlog ? (
2930
+ <>
2931
+ {item.cdlog_atual ? (
2932
+ <div className="geo-correcao-atual">Código atual: {item.cdlog_atual}</div>
2933
+ ) : null}
2934
+ <input
2935
+ type="text"
2936
+ value={item.cdlog_corrigido || ''}
2937
+ onChange={(e) => {
2938
+ const next = [...geoCorrecoes]
2939
+ next[idx] = { ...next[idx], cdlog_corrigido: e.target.value }
2940
+ setGeoCorrecoes(next)
2941
+ }}
2942
+ placeholder="Código corrigido (CDLOG/CTM)"
2943
+ />
2944
+ </>
2945
+ ) : null}
2946
  <input
2947
  type="text"
2948
  value={item.numero_corrigido || ''}
 
3040
  ) : (
3041
  <details className="dados-mapa-details" open>
3042
  <summary>Mapa</summary>
3043
+ <div className="dados-mapa-controls">
3044
+ <div className="dados-mapa-control-field">
3045
+ <label>Variável no mapa</label>
3046
+ <select value={mapaVariavel} onChange={(e) => onMapVarChange(e.target.value)}>
3047
+ {mapaChoices.map((choice) => (
3048
+ <option key={choice} value={choice}>{choice}</option>
3049
+ ))}
3050
+ </select>
3051
+ </div>
3052
+ {mapaModoDisponivel ? (
3053
+ <div className="dados-mapa-control-field">
3054
+ <label>Visualização</label>
3055
+ <select value={mapaModo} onChange={(e) => onMapModeChange(e.target.value)}>
3056
+ <option value={MAPA_MODO_PONTOS}>Pontos</option>
3057
+ <option value={MAPA_MODO_CALOR}>Mapa de calor</option>
3058
+ <option value={MAPA_MODO_SUPERFICIE}>Superfície contínua</option>
3059
+ </select>
3060
+ </div>
3061
+ ) : null}
3062
  </div>
3063
  <div className="download-actions-bar">
3064
  <button
 
3596
 
3597
  {fit ? (
3598
  <>
3599
+ <SectionBlock step="13" title="Visualizar Mapa dos Dados de Mercado" subtitle="Escolha livre dos eixos para análise gráfica do modelo.">
3600
+ <div className="row dispersao-config-row">
3601
+ <div className="dispersao-config-field">
3602
+ <label>Eixo X</label>
3603
+ <select
3604
+ value={dispersaoEixoX}
3605
+ onChange={(e) => {
3606
+ void onDispersaoConfigChange({ eixoX: e.target.value })
3607
+ }}
3608
+ disabled={loading}
3609
+ >
3610
+ <option value="transformado">X transformado</option>
3611
+ <option value="nao_transformado">X não transformado</option>
3612
+ </select>
3613
+ </div>
3614
+ <div className="dispersao-config-field">
3615
+ <label>Eixo Y</label>
3616
+ <select
3617
+ value={dispersaoEixoYTipo}
3618
+ onChange={(e) => {
3619
+ const nextTipo = e.target.value
3620
+ const nextColuna = nextTipo === 'coluna_original'
3621
+ ? (dispersaoEixoYColuna || colunasOriginaisDispersao[0] || '')
3622
+ : dispersaoEixoYColuna
3623
+ void onDispersaoConfigChange({ eixoYTipo: nextTipo, eixoYColuna: nextColuna })
3624
+ }}
3625
+ disabled={loading}
3626
+ >
3627
+ <option value="y_transformado">{`${colunaYComRotulo} transformado`}</option>
3628
+ <option value="y_nao_transformado">{`${colunaYComRotulo} não transformado`}</option>
3629
+ <option value="residuo">Resíduo</option>
3630
+ <option value="coluna_original">Outra coluna</option>
3631
+ </select>
3632
+ </div>
3633
+ {dispersaoEixoYTipo === 'residuo' ? (
3634
+ <div className="dispersao-config-field">
3635
+ <label>Tipo de resíduo</label>
3636
+ <select
3637
+ value={dispersaoEixoYResiduo}
3638
+ onChange={(e) => {
3639
+ void onDispersaoConfigChange({ eixoYResiduo: e.target.value })
3640
+ }}
3641
+ disabled={loading}
3642
+ >
3643
+ <option value="residuo_pad">Resíduo Padronizado</option>
3644
+ <option value="residuo_stud">Resíduo Studentizado</option>
3645
+ <option value="cook">Resíduo de Cook</option>
3646
+ </select>
3647
+ </div>
3648
+ ) : null}
3649
+ {dispersaoEixoYTipo === 'coluna_original' ? (
3650
+ <div className="dispersao-config-field dispersao-config-field-wide">
3651
+ <label>Coluna de Y (dados originais)</label>
3652
+ <select
3653
+ value={dispersaoEixoYColuna}
3654
+ onChange={(e) => {
3655
+ void onDispersaoConfigChange({ eixoYColuna: e.target.value })
3656
+ }}
3657
+ disabled={loading || colunasOriginaisDispersao.length === 0}
3658
+ >
3659
+ {colunasOriginaisDispersao.length === 0 ? (
3660
+ <option value="">Sem colunas disponíveis</option>
3661
+ ) : (
3662
+ colunasOriginaisDispersao.map((col) => (
3663
+ <option key={`eixo-y-col-${col}`} value={col}>{col}</option>
3664
+ ))
3665
+ )}
3666
+ </select>
3667
+ </div>
3668
+ ) : null}
3669
  </div>
3670
  <div className="download-actions-bar">
3671
  {graficosSecao12.length > 1 ? <span className="download-actions-label">Fazer download:</span> : null}
 
3841
  </div>
3842
  </SectionBlock>
3843
 
3844
+ <SectionBlock step="16" title="Analisar Resíduos" subtitle="Métricas para identificação de observações influentes.">
3845
+ <details className="dados-mapa-details">
3846
+ <summary>Mapa de resíduos padronizados</summary>
3847
+ {!mapaResiduosGerado ? (
3848
+ <div className="empty-box">
3849
+ <div className="row">
3850
+ <button type="button" className="btn-gerar-mapa" onClick={onGerarMapaResiduos} disabled={loading}>
3851
+ Gerar Mapa de Resíduos Padronizados
3852
+ </button>
3853
+ </div>
3854
+ <div className="section1-empty-hint">O mapa de resíduos padronizados será carregado somente após solicitação explícita.</div>
3855
+ </div>
3856
+ ) : (
3857
+ <>
3858
+ <div className="dados-mapa-controls">
3859
+ <div className="dados-mapa-control-field">
3860
+ <label>Visualização</label>
3861
+ <select value={mapaResiduosModo} onChange={(e) => onMapaResiduosModoChange(e.target.value)}>
3862
+ <option value={MAPA_MODO_PONTOS}>Pontos</option>
3863
+ <option value={MAPA_MODO_CALOR}>Mapa de calor</option>
3864
+ <option value={MAPA_MODO_SUPERFICIE}>Superfície contínua</option>
3865
+ </select>
3866
+ </div>
3867
+ </div>
3868
+ <div className="download-actions-bar">
3869
+ <button
3870
+ type="button"
3871
+ className="btn-download-subtle"
3872
+ onClick={onDownloadMapaSecao16}
3873
+ disabled={loading || downloadingAssets || !mapaResiduosHtml}
3874
+ >
3875
+ Fazer download
3876
+ </button>
3877
+ </div>
3878
+ <MapFrame html={mapaResiduosHtml} />
3879
+ </>
3880
+ )}
3881
+ </details>
3882
  <div className="download-actions-bar">
3883
  <button
3884
  type="button"
frontend/src/styles.css CHANGED
@@ -761,16 +761,25 @@ textarea {
761
 
762
  .dados-mapa-controls {
763
  display: flex;
764
- align-items: center;
765
  flex-wrap: wrap;
766
- column-gap: 14px;
767
- row-gap: 8px;
768
  margin-bottom: 18px;
769
  }
770
 
771
- .dados-mapa-controls label {
772
- margin-right: 0;
773
- min-width: 118px;
 
 
 
 
 
 
 
 
 
774
  }
775
 
776
  .dados-mapa-controls + .map-frame,
@@ -778,6 +787,33 @@ textarea {
778
  margin-top: 6px;
779
  }
780
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
781
  .visualizacao-mapa-controls {
782
  margin-bottom: 28px;
783
  }
@@ -3357,6 +3393,18 @@ button.btn-download-subtle {
3357
  color: #46617a;
3358
  }
3359
 
 
 
 
 
 
 
 
 
 
 
 
 
3360
  .geo-correcao-sugestoes {
3361
  color: #607990;
3362
  font-size: 0.78rem;
 
761
 
762
  .dados-mapa-controls {
763
  display: flex;
764
+ align-items: flex-end;
765
  flex-wrap: wrap;
766
+ column-gap: 16px;
767
+ row-gap: 12px;
768
  margin-bottom: 18px;
769
  }
770
 
771
+ .dados-mapa-control-field {
772
+ display: grid;
773
+ gap: 6px;
774
+ min-width: 220px;
775
+ }
776
+
777
+ .dados-mapa-control-field label {
778
+ margin: 0;
779
+ }
780
+
781
+ .dados-mapa-control-field select {
782
+ width: 100%;
783
  }
784
 
785
  .dados-mapa-controls + .map-frame,
 
787
  margin-top: 6px;
788
  }
789
 
790
+ .dispersao-config-row {
791
+ align-items: flex-end;
792
+ gap: 14px;
793
+ }
794
+
795
+ .dispersao-config-field {
796
+ display: grid;
797
+ gap: 6px;
798
+ min-width: 240px;
799
+ max-width: 360px;
800
+ flex: 1 1 240px;
801
+ }
802
+
803
+ .dispersao-config-field.dispersao-config-field-wide {
804
+ min-width: 320px;
805
+ max-width: 520px;
806
+ flex: 2 1 320px;
807
+ }
808
+
809
+ .dispersao-config-field label {
810
+ margin: 0;
811
+ }
812
+
813
+ .dispersao-config-field select {
814
+ width: 100%;
815
+ }
816
+
817
  .visualizacao-mapa-controls {
818
  margin-bottom: 28px;
819
  }
 
3393
  color: #46617a;
3394
  }
3395
 
3396
+ .geo-correcao-motivo {
3397
+ color: #6a7f94;
3398
+ font-size: 0.76rem;
3399
+ line-height: 1.25;
3400
+ }
3401
+
3402
+ .geo-correcao-atual {
3403
+ color: #4d6379;
3404
+ font-size: 0.76rem;
3405
+ font-weight: 700;
3406
+ }
3407
+
3408
  .geo-correcao-sugestoes {
3409
  color: #607990;
3410
  font-size: 0.78rem;