Guilherme Silberfarb Costa commited on
Commit
f7fe834
·
1 Parent(s): 5bccf4a

update a lot of things

Browse files
backend/app/api/elaboracao.py CHANGED
@@ -50,7 +50,7 @@ class ApplySelectionPayload(SessionPayload):
50
  codigo_alocado: list[str] = Field(default_factory=list)
51
  percentuais: list[str] = Field(default_factory=list)
52
  outliers_anteriores: list[int] = Field(default_factory=list)
53
- grau_min_coef: int = 1
54
  grau_min_f: int = 0
55
 
56
 
@@ -59,7 +59,7 @@ class ClassificarXPayload(SessionPayload):
59
 
60
 
61
  class SearchTransformPayload(SessionPayload):
62
- grau_min_coef: int = 1
63
  grau_min_f: int = 0
64
 
65
 
 
50
  codigo_alocado: list[str] = Field(default_factory=list)
51
  percentuais: list[str] = Field(default_factory=list)
52
  outliers_anteriores: list[int] = Field(default_factory=list)
53
+ grau_min_coef: int = 0
54
  grau_min_f: int = 0
55
 
56
 
 
59
 
60
 
61
  class SearchTransformPayload(SessionPayload):
62
+ grau_min_coef: int = 0
63
  grau_min_f: int = 0
64
 
65
 
backend/app/core/elaboracao/charts.py CHANGED
@@ -11,6 +11,7 @@ from scipy import stats
11
  import folium
12
  from folium import plugins
13
  import branca.colormap as cm
 
14
 
15
  # ============================================================
16
  # CONSTANTES DE ESTILO
@@ -602,19 +603,35 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=
602
  if lat_real is None or lon_real is None:
603
  return "<p>Coordenadas (lat/lon) não encontradas nos dados.</p>"
604
 
605
- # Filtra dados válidos
606
- df_mapa = df.dropna(subset=[lat_real, lon_real]).copy()
 
 
 
 
 
 
 
607
  if df_mapa.empty:
608
  return "<p>Sem coordenadas válidas para exibir.</p>"
609
 
 
 
 
 
 
 
 
610
  # Cria mapa
611
- centro_lat = df_mapa[lat_real].mean()
612
- centro_lon = df_mapa[lon_real].mean()
613
 
614
  m = folium.Map(
615
  location=[centro_lat, centro_lon],
616
  zoom_start=12,
617
- tiles=None
 
 
618
  )
619
 
620
  # Camadas base
@@ -650,7 +667,8 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=
650
  tamanho_func = lambda v: (raio_min + raio_max) / 2
651
 
652
  # Camada de índices (oculta por padrão, ativável pelo controle de camadas)
653
- camada_indices = folium.FeatureGroup(name="Índices", show=False)
 
654
 
655
  # Adiciona pontos
656
  for idx, row in df_mapa.iterrows():
@@ -671,13 +689,18 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=
671
 
672
  # Popup com informações
673
  popup_html = f"<b>Índice: {idx}</b><br>"
674
- for col in df_mapa.columns:
675
- if str(col).lower() not in ['lat', 'latitude', 'lon', 'longitude']:
676
- val = row[col]
677
- if isinstance(val, (int, float)):
678
- popup_html += f"{col}: {val:.2f}<br>"
679
- else:
680
- popup_html += f"{col}: {val}<br>"
 
 
 
 
 
681
 
682
  # Tooltip (hover): índice + variável selecionada no dropdown
683
  tooltip_html = (
@@ -707,16 +730,18 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=
707
  ).add_to(m)
708
 
709
  # Label com índice (na camada togglável)
710
- folium.Marker(
711
- location=[row[lat_real], row[lon_real]],
712
- icon=folium.DivIcon(
713
- 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>',
714
- icon_size=(int(raio*2), int(raio*2)),
715
- icon_anchor=(int(raio), int(raio))
716
- )
717
- ).add_to(camada_indices)
718
-
719
- camada_indices.add_to(m)
 
 
720
 
721
  # Controles
722
  folium.LayerControl().add_to(m)
@@ -728,14 +753,84 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=
728
  secondary_area_unit='hectares'
729
  ).add_to(m)
730
 
731
- # Ajusta bounds
732
- bounds = [
733
- [df_mapa[lat_real].min(), df_mapa[lon_real].min()],
734
- [df_mapa[lat_real].max(), df_mapa[lon_real].max()]
735
- ]
736
- m.fit_bounds(bounds)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
737
 
738
- return m._repr_html_()
 
739
 
740
 
741
  def criar_mapa_simples(df):
 
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
 
603
  if lat_real is None or lon_real is None:
604
  return "<p>Coordenadas (lat/lon) não encontradas nos dados.</p>"
605
 
606
+ # Filtra dados válidos (numéricos e dentro dos limites geográficos)
607
+ df_mapa = df.copy()
608
+ df_mapa[lat_real] = pd.to_numeric(df_mapa[lat_real], errors="coerce")
609
+ df_mapa[lon_real] = pd.to_numeric(df_mapa[lon_real], errors="coerce")
610
+ df_mapa = df_mapa.dropna(subset=[lat_real, lon_real])
611
+ df_mapa = df_mapa[
612
+ (df_mapa[lat_real] >= -90.0) & (df_mapa[lat_real] <= 90.0) &
613
+ (df_mapa[lon_real] >= -180.0) & (df_mapa[lon_real] <= 180.0)
614
+ ].copy()
615
  if df_mapa.empty:
616
  return "<p>Sem coordenadas válidas para exibir.</p>"
617
 
618
+ # Limita quantidade de pontos para manter o mapa responsivo em bases grandes.
619
+ limite_pontos = 2500
620
+ total_pontos = len(df_mapa)
621
+ houve_amostragem = total_pontos > limite_pontos
622
+ if houve_amostragem:
623
+ df_mapa = df_mapa.sample(n=limite_pontos, random_state=42).copy()
624
+
625
  # Cria mapa
626
+ centro_lat = float(df_mapa[lat_real].median())
627
+ centro_lon = float(df_mapa[lon_real].median())
628
 
629
  m = folium.Map(
630
  location=[centro_lat, centro_lon],
631
  zoom_start=12,
632
+ tiles=None,
633
+ prefer_canvas=True,
634
+ control_scale=True,
635
  )
636
 
637
  # Camadas base
 
667
  tamanho_func = lambda v: (raio_min + raio_max) / 2
668
 
669
  # Camada de índices (oculta por padrão, ativável pelo controle de camadas)
670
+ mostrar_indices = len(df_mapa) <= 800
671
+ camada_indices = folium.FeatureGroup(name="Índices", show=False) if mostrar_indices else None
672
 
673
  # Adiciona pontos
674
  for idx, row in df_mapa.iterrows():
 
689
 
690
  # Popup com informações
691
  popup_html = f"<b>Índice: {idx}</b><br>"
692
+ if len(df_mapa) <= 1200:
693
+ for col in df_mapa.columns:
694
+ if str(col).lower() not in ['lat', 'latitude', 'lon', 'longitude']:
695
+ val = row[col]
696
+ if isinstance(val, (int, float)):
697
+ popup_html += f"{col}: {val:.2f}<br>"
698
+ else:
699
+ popup_html += f"{col}: {val}<br>"
700
+ elif tamanho_col and tamanho_col in df_mapa.columns:
701
+ val = row[tamanho_col]
702
+ val_str = f"{val:.2f}" if isinstance(val, (int, float)) else str(val)
703
+ popup_html += f"{tamanho_col}: {val_str}<br>"
704
 
705
  # Tooltip (hover): índice + variável selecionada no dropdown
706
  tooltip_html = (
 
730
  ).add_to(m)
731
 
732
  # Label com índice (na camada togglável)
733
+ if mostrar_indices and camada_indices is not None:
734
+ folium.Marker(
735
+ location=[row[lat_real], row[lon_real]],
736
+ icon=folium.DivIcon(
737
+ 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>',
738
+ icon_size=(int(raio*2), int(raio*2)),
739
+ icon_anchor=(int(raio), int(raio))
740
+ )
741
+ ).add_to(camada_indices)
742
+
743
+ if mostrar_indices and camada_indices is not None:
744
+ camada_indices.add_to(m)
745
 
746
  # Controles
747
  folium.LayerControl().add_to(m)
 
753
  secondary_area_unit='hectares'
754
  ).add_to(m)
755
 
756
+ # Ajusta bounds robustos para evitar "salto" para longe por pontos geocodificados distantes.
757
+ df_bounds = df_mapa
758
+ if len(df_mapa) >= 8:
759
+ lat_vals = df_mapa[lat_real]
760
+ lon_vals = df_mapa[lon_real]
761
+ lat_med = float(lat_vals.median())
762
+ lon_med = float(lon_vals.median())
763
+ lat_mad = float((lat_vals - lat_med).abs().median())
764
+ lon_mad = float((lon_vals - lon_med).abs().median())
765
+
766
+ lat_span = float(lat_vals.max() - lat_vals.min())
767
+ lon_span = float(lon_vals.max() - lon_vals.min())
768
+ lat_scale = max(lat_mad, lat_span / 30.0, 1e-6)
769
+ lon_scale = max(lon_mad, lon_span / 30.0, 1e-6)
770
+
771
+ score = ((lat_vals - lat_med) / lat_scale) ** 2 + ((lon_vals - lon_med) / lon_scale) ** 2
772
+ lim = float(score.quantile(0.75))
773
+ df_core = df_mapa[score <= lim]
774
+ if len(df_core) >= max(5, int(len(df_mapa) * 0.45)):
775
+ df_bounds = df_core
776
+
777
+ if len(df_bounds) >= 50:
778
+ lat_min, lat_max = df_bounds[lat_real].quantile([0.01, 0.99]).tolist()
779
+ lon_min, lon_max = df_bounds[lon_real].quantile([0.01, 0.99]).tolist()
780
+ else:
781
+ lat_min, lat_max = float(df_bounds[lat_real].min()), float(df_bounds[lat_real].max())
782
+ lon_min, lon_max = float(df_bounds[lon_real].min()), float(df_bounds[lon_real].max())
783
+
784
+ if not np.isfinite(lat_min) or not np.isfinite(lat_max):
785
+ lat_min, lat_max = float(df_mapa[lat_real].min()), float(df_mapa[lat_real].max())
786
+ if not np.isfinite(lon_min) or not np.isfinite(lon_max):
787
+ lon_min, lon_max = float(df_mapa[lon_real].min()), float(df_mapa[lon_real].max())
788
+
789
+ # Fallback para mapa muito concentrado (mesma coordenada / quase mesma área).
790
+ if np.isclose(lat_min, lat_max) and np.isclose(lon_min, lon_max):
791
+ m.location = [float(lat_min), float(lon_min)]
792
+ else:
793
+ bounds = [[lat_min, lon_min], [lat_max, lon_max]]
794
+ m.fit_bounds(bounds, padding=(20, 20), max_zoom=17)
795
+ # Reaplica o fit após o carregamento para evitar override inesperado do viewport no cliente.
796
+ map_var = m.get_name()
797
+ center_lat = float((lat_min + lat_max) / 2.0)
798
+ center_lon = float((lon_min + lon_max) / 2.0)
799
+ recenter_js = (
800
+ "<script>"
801
+ "(function(){"
802
+ f"var __m = '{map_var}';"
803
+ f"var __b = L.latLngBounds([{float(lat_min)}, {float(lon_min)}], [{float(lat_max)}, {float(lon_max)}]);"
804
+ f"var __c = [{center_lat}, {center_lon}];"
805
+ "function __mesaRecenter(){"
806
+ "try {"
807
+ "var _map = window[__m];"
808
+ "if (!_map) return;"
809
+ "_map.invalidateSize(true);"
810
+ "_map.setView(__c, _map.getZoom(), {animate:false});"
811
+ "_map.fitBounds(__b, {padding:[20,20], maxZoom:17, animate:false});"
812
+ "} catch (e) {}"
813
+ "}"
814
+ "setTimeout(__mesaRecenter, 120);"
815
+ "setTimeout(__mesaRecenter, 650);"
816
+ "setTimeout(__mesaRecenter, 1400);"
817
+ "})();"
818
+ "</script>"
819
+ )
820
+ m.get_root().html.add_child(Element(recenter_js))
821
+
822
+ if houve_amostragem:
823
+ aviso_html = (
824
+ "<div style='position:fixed;top:10px;right:10px;z-index:9999;"
825
+ "background:#f8fbff;border:1px solid #d2deea;border-radius:8px;"
826
+ "padding:6px 10px;font-size:12px;color:#41586f;'>"
827
+ f"Exibindo {len(df_mapa)} de {total_pontos} pontos para melhor desempenho."
828
+ "</div>"
829
+ )
830
+ m.get_root().html.add_child(Element(aviso_html))
831
 
832
+ # Evita o wrapper de notebook (_repr_html_), que pode falhar dentro de iframe srcDoc.
833
+ return m.get_root().render()
834
 
835
 
836
  def criar_mapa_simples(df):
backend/app/core/elaboracao/formatadores.py CHANGED
@@ -210,13 +210,18 @@ def formatar_busca_html(resultados_busca):
210
  return html
211
 
212
 
213
- def _renderizar_secao_micro(titulo, resultados_dict):
214
  """Renderiza uma seção de micronumerosidade (dicotômicas ou códigos alocados)."""
215
  if not resultados_dict:
216
  return ""
217
 
 
 
 
 
 
218
  html = f'<div class="section-title-orange">{titulo}</div>'
219
- html += '<div class="micro-grid">'
220
 
221
  for coluna, info in resultados_dict.items():
222
  status = "✅" if info["valido"] else "⚠️"
@@ -225,12 +230,12 @@ def _renderizar_secao_micro(titulo, resultados_dict):
225
  mensagens_html = "".join(f'<div class="micro-msg">{msg}</div>' for msg in mensagens_lista)
226
 
227
  html += f'''
228
- <div class="teste-item micro-card {status_class}">
229
  <div class="micro-card-head">
230
  <span class="teste-nome micro-title">{coluna}</span>
231
  <span class="teste-valor micro-status">{status}</span>
232
  </div>
233
- <div class="micro-msg-grid">
234
  {mensagens_html}
235
  </div>
236
  </div>
@@ -282,8 +287,8 @@ def formatar_micronumerosidade_html(resultado):
282
  # =========================
283
  # Seções separadas: Dicotômicas e Códigos Alocados
284
  # =========================
285
- dic_html = _renderizar_secao_micro("Variáveis Dicotômicas", resultado.get("dicotomicas", {}))
286
- cod_html = _renderizar_secao_micro("Códigos Ajustados/Alocados", resultado.get("codigo_alocado", {}))
287
 
288
  if dic_html or cod_html:
289
  html += dic_html + cod_html
@@ -671,45 +676,22 @@ def formatar_avaliacao_html(avaliacoes_lista, indice_base=0, elem_id_excluir="ex
671
  )
672
  html += '</tr>'
673
 
674
- # Excluir (linha com lixeiras botão vermelho temporário)
675
- # JS inline em cada onclick (scripts via innerHTML não são executados)
676
- _js_trigger = (
677
- "var el=document.querySelector('#{eid} textarea')"
678
- "||document.querySelector('#{eid} input');"
679
- "if(el){{el.value=String({idx});"
680
- "el.dispatchEvent(new Event('input',{{bubbles:true}}));"
681
- "el.dispatchEvent(new Event('change',{{bubbles:true}}));}}"
682
- )
683
  html += '<tr style="background: #fff5f5;">'
684
  html += f'<td style="padding: 6px 12px; border-bottom: 1px solid #f0f0f0; font-weight: 500; color: #dc3545; font-size: 12px;">Excluir</td>'
685
  for i in range(n):
686
  idx_1 = i + 1
687
- uid = f'{elem_id_excluir}-{idx_1}'
688
- # Clicar lixeira → mostra botão vermelho por 10s
689
- onclick_trash = (
690
- f"document.getElementById('{uid}-trash').style.display='none';"
691
- f"document.getElementById('{uid}-btn').style.display='inline-block';"
692
- f"clearTimeout(window['_t_{uid}']);"
693
- f"window['_t_{uid}']=setTimeout(function(){{"
694
- f"document.getElementById('{uid}-btn').style.display='none';"
695
- f"document.getElementById('{uid}-trash').style.display='inline';"
696
- f"}},10000);"
697
- )
698
- # Clicar botão vermelho → dispara exclusão inline
699
- onclick_btn = (
700
- f"clearTimeout(window['_t_{uid}']);"
701
- + _js_trigger.format(eid=elem_id_excluir, idx=idx_1)
702
- )
703
  html += (
704
  f'<td style="text-align: right; padding: 6px 12px; border-bottom: 1px solid #f0f0f0;">'
705
- f'<span id="{uid}-trash" onclick="{onclick_trash}" '
706
- f'style="cursor: pointer; color: #dc3545; font-size: 18px;" '
707
  f'title="Excluir Avaliação {idx_1}">'
708
  f'\U0001f5d1\ufe0f</span>'
709
- f'<span id="{uid}-btn" onclick="{onclick_btn}" '
710
  f'style="display:none; cursor:pointer; background:#dc3545; color:white; '
711
- f'padding:2px 10px; border-radius:4px; font-size:12px; font-weight:600;">'
712
- f'Excluir</span>'
713
  f'</td>'
714
  )
715
  html += '</tr>'
 
210
  return html
211
 
212
 
213
+ def _renderizar_secao_micro(titulo, resultados_dict, secao_tipo="padrao"):
214
  """Renderiza uma seção de micronumerosidade (dicotômicas ou códigos alocados)."""
215
  if not resultados_dict:
216
  return ""
217
 
218
+ is_codigo = secao_tipo == "codigo_alocado"
219
+ grid_class = "micro-grid micro-grid-codigo" if is_codigo else "micro-grid"
220
+ card_extra_class = " micro-card-codigo" if is_codigo else ""
221
+ msg_grid_class = "micro-msg-grid micro-msg-grid-codigo" if is_codigo else "micro-msg-grid"
222
+
223
  html = f'<div class="section-title-orange">{titulo}</div>'
224
+ html += f'<div class="{grid_class}">'
225
 
226
  for coluna, info in resultados_dict.items():
227
  status = "✅" if info["valido"] else "⚠️"
 
230
  mensagens_html = "".join(f'<div class="micro-msg">{msg}</div>' for msg in mensagens_lista)
231
 
232
  html += f'''
233
+ <div class="teste-item micro-card {status_class}{card_extra_class}">
234
  <div class="micro-card-head">
235
  <span class="teste-nome micro-title">{coluna}</span>
236
  <span class="teste-valor micro-status">{status}</span>
237
  </div>
238
+ <div class="{msg_grid_class}">
239
  {mensagens_html}
240
  </div>
241
  </div>
 
287
  # =========================
288
  # Seções separadas: Dicotômicas e Códigos Alocados
289
  # =========================
290
+ dic_html = _renderizar_secao_micro("Variáveis Dicotômicas", resultado.get("dicotomicas", {}), secao_tipo="dicotomicas")
291
+ cod_html = _renderizar_secao_micro("Códigos Ajustados/Alocados", resultado.get("codigo_alocado", {}), secao_tipo="codigo_alocado")
292
 
293
  if dic_html or cod_html:
294
  html += dic_html + cod_html
 
676
  )
677
  html += '</tr>'
678
 
679
+ # Excluir (2 etapas): lixeira -> confirma em botao vermelho.
680
+ # O frontend React captura os cliques via event delegation.
 
 
 
 
 
 
 
681
  html += '<tr style="background: #fff5f5;">'
682
  html += f'<td style="padding: 6px 12px; border-bottom: 1px solid #f0f0f0; font-weight: 500; color: #dc3545; font-size: 12px;">Excluir</td>'
683
  for i in range(n):
684
  idx_1 = i + 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
685
  html += (
686
  f'<td style="text-align: right; padding: 6px 12px; border-bottom: 1px solid #f0f0f0;">'
687
+ f'<span data-avaliacao-delete-arm="1" data-avaliacao-delete-index="{idx_1}" '
688
+ f'style="cursor:pointer; color:#dc3545; font-size:18px;" '
689
  f'title="Excluir Avaliação {idx_1}">'
690
  f'\U0001f5d1\ufe0f</span>'
691
+ f'<button type="button" data-avaliacao-delete-confirm="{idx_1}" '
692
  f'style="display:none; cursor:pointer; background:#dc3545; color:white; '
693
+ f'border:1px solid #c62f3b; padding:2px 10px; border-radius:4px; '
694
+ f'font-size:12px; font-weight:700;">Excluir</button>'
695
  f'</td>'
696
  )
697
  html += '</tr>'
backend/app/core/elaboracao/geocodificacao.py CHANGED
@@ -315,6 +315,38 @@ def geocodificar(df, col_cdlog, col_num, auto_200=False):
315
  df["lat"] = lats
316
  df["lon"] = lons
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  df_falhas = pd.DataFrame(
319
  falhas,
320
  columns=["_idx", "cdlog", "numero_atual", "motivo", "sugestoes", "numero_corrigido"]
@@ -453,6 +485,7 @@ def preparar_display_falhas(df_falhas):
453
  df_correcoes = pd.DataFrame({
454
  "Nº Linha": df_falhas["_idx"].tolist(),
455
  "Nº Corrigido": [""] * len(df_falhas),
 
456
  })
457
 
458
  return "".join(linhas), df_correcoes
 
315
  df["lat"] = lats
316
  df["lon"] = lons
317
 
318
+ # Em alguns cenários de CRS/eixo, os pontos podem sair com lat/lon invertidos.
319
+ # Detecta isso comparando com o bounding box do próprio shapefile e corrige.
320
+ try:
321
+ min_x, min_y, max_x, max_y = gdf_eixos.total_bounds
322
+ margem = 0.02
323
+ lat_s = pd.to_numeric(df["lat"], errors="coerce")
324
+ lon_s = pd.to_numeric(df["lon"], errors="coerce")
325
+
326
+ validos = lat_s.notna() & lon_s.notna()
327
+ if validos.any():
328
+ lat_v = lat_s[validos]
329
+ lon_v = lon_s[validos]
330
+
331
+ normal_mask = (
332
+ (lat_v >= (min_y - margem)) & (lat_v <= (max_y + margem)) &
333
+ (lon_v >= (min_x - margem)) & (lon_v <= (max_x + margem))
334
+ )
335
+ invertido_mask = (
336
+ (lat_v >= (min_x - margem)) & (lat_v <= (max_x + margem)) &
337
+ (lon_v >= (min_y - margem)) & (lon_v <= (max_y + margem))
338
+ )
339
+
340
+ n_normal = int(normal_mask.sum())
341
+ n_invertido = int(invertido_mask.sum())
342
+
343
+ # Só troca quando há evidência clara de inversão.
344
+ if n_invertido >= max(3, n_normal + 1):
345
+ df["lat"], df["lon"] = lon_s, lat_s
346
+ except Exception:
347
+ # Nunca falha o fluxo principal por causa desse ajuste.
348
+ pass
349
+
350
  df_falhas = pd.DataFrame(
351
  falhas,
352
  columns=["_idx", "cdlog", "numero_atual", "motivo", "sugestoes", "numero_corrigido"]
 
485
  df_correcoes = pd.DataFrame({
486
  "Nº Linha": df_falhas["_idx"].tolist(),
487
  "Nº Corrigido": [""] * len(df_falhas),
488
+ "Sugestões": df_falhas["sugestoes"].fillna("").astype(str).tolist(),
489
  })
490
 
491
  return "".join(linhas), df_correcoes
backend/app/core/elaboracao/outliers.py CHANGED
@@ -196,7 +196,6 @@ def reiniciar_iteracao_callback(df_original, outliers_anteriores, outliers_texto
196
  try:
197
  X = df_filtrado[list(colunas_x)]
198
  y = df_filtrado[coluna_y]
199
- print(f"[reiniciar_iteracao] Criando gráfico dispersão com {len(df_filtrado)} pontos (excluídos: {outliers_combinados})")
200
  fig_dispersao = criar_graficos_dispersao(X, y)
201
  except Exception as e:
202
  print(f"Erro ao gerar gráficos de dispersão: {e}")
 
196
  try:
197
  X = df_filtrado[list(colunas_x)]
198
  y = df_filtrado[coluna_y]
 
199
  fig_dispersao = criar_graficos_dispersao(X, y)
200
  except Exception as e:
201
  print(f"Erro ao gerar gráficos de dispersão: {e}")
backend/app/core/visualizacao/app.py CHANGED
@@ -710,19 +710,28 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
710
  if lat_real is None or lon_real is None:
711
  return "<p>Coordenadas (lat/lon) não encontradas nos dados.</p>"
712
 
713
- # Filtra dados válidos
714
- df_mapa = df.dropna(subset=[lat_real, lon_real]).copy()
 
 
 
 
 
 
 
715
  if df_mapa.empty:
716
  return "<p>Sem coordenadas válidas para exibir.</p>"
717
 
718
  # Cria mapa
719
- centro_lat = df_mapa[lat_real].mean()
720
- centro_lon = df_mapa[lon_real].mean()
721
 
722
  m = folium.Map(
723
  location=[centro_lat, centro_lon],
724
  zoom_start=12,
725
- tiles=None
 
 
726
  )
727
 
728
  # Camadas base
@@ -846,14 +855,73 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
846
  secondary_area_unit='hectares'
847
  ).add_to(m)
848
 
849
- # Ajusta bounds
850
- bounds = [
851
- [df_mapa[lat_real].min(), df_mapa[lon_real].min()],
852
- [df_mapa[lat_real].max(), df_mapa[lon_real].max()]
853
- ]
854
- m.fit_bounds(bounds)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
855
 
856
- return m._repr_html_()
 
857
 
858
  # ============================================================
859
  # FUNÇÃO: CARREGAR + VALIDAR MODELO (.dai)
 
710
  if lat_real is None or lon_real is None:
711
  return "<p>Coordenadas (lat/lon) não encontradas nos dados.</p>"
712
 
713
+ # Filtra dados válidos (numéricos e dentro dos limites geográficos)
714
+ df_mapa = df.copy()
715
+ df_mapa[lat_real] = pd.to_numeric(df_mapa[lat_real], errors="coerce")
716
+ df_mapa[lon_real] = pd.to_numeric(df_mapa[lon_real], errors="coerce")
717
+ df_mapa = df_mapa.dropna(subset=[lat_real, lon_real])
718
+ df_mapa = df_mapa[
719
+ (df_mapa[lat_real] >= -90.0) & (df_mapa[lat_real] <= 90.0) &
720
+ (df_mapa[lon_real] >= -180.0) & (df_mapa[lon_real] <= 180.0)
721
+ ].copy()
722
  if df_mapa.empty:
723
  return "<p>Sem coordenadas válidas para exibir.</p>"
724
 
725
  # Cria mapa
726
+ centro_lat = float(df_mapa[lat_real].median())
727
+ centro_lon = float(df_mapa[lon_real].median())
728
 
729
  m = folium.Map(
730
  location=[centro_lat, centro_lon],
731
  zoom_start=12,
732
+ tiles=None,
733
+ prefer_canvas=True,
734
+ control_scale=True,
735
  )
736
 
737
  # Camadas base
 
855
  secondary_area_unit='hectares'
856
  ).add_to(m)
857
 
858
+ # Ajusta bounds robustos para evitar "salto" por pontos geocodificados distantes.
859
+ df_bounds = df_mapa
860
+ if len(df_mapa) >= 8:
861
+ lat_vals = df_mapa[lat_real]
862
+ lon_vals = df_mapa[lon_real]
863
+ lat_med = float(lat_vals.median())
864
+ lon_med = float(lon_vals.median())
865
+ lat_mad = float((lat_vals - lat_med).abs().median())
866
+ lon_mad = float((lon_vals - lon_med).abs().median())
867
+
868
+ lat_span = float(lat_vals.max() - lat_vals.min())
869
+ lon_span = float(lon_vals.max() - lon_vals.min())
870
+ lat_scale = max(lat_mad, lat_span / 30.0, 1e-6)
871
+ lon_scale = max(lon_mad, lon_span / 30.0, 1e-6)
872
+
873
+ score = ((lat_vals - lat_med) / lat_scale) ** 2 + ((lon_vals - lon_med) / lon_scale) ** 2
874
+ lim = float(score.quantile(0.75))
875
+ df_core = df_mapa[score <= lim]
876
+ if len(df_core) >= max(5, int(len(df_mapa) * 0.45)):
877
+ df_bounds = df_core
878
+
879
+ if len(df_bounds) >= 50:
880
+ lat_min, lat_max = df_bounds[lat_real].quantile([0.01, 0.99]).tolist()
881
+ lon_min, lon_max = df_bounds[lon_real].quantile([0.01, 0.99]).tolist()
882
+ else:
883
+ lat_min, lat_max = float(df_bounds[lat_real].min()), float(df_bounds[lat_real].max())
884
+ lon_min, lon_max = float(df_bounds[lon_real].min()), float(df_bounds[lon_real].max())
885
+
886
+ if not np.isfinite(lat_min) or not np.isfinite(lat_max):
887
+ lat_min, lat_max = float(df_mapa[lat_real].min()), float(df_mapa[lat_real].max())
888
+ if not np.isfinite(lon_min) or not np.isfinite(lon_max):
889
+ lon_min, lon_max = float(df_mapa[lon_real].min()), float(df_mapa[lon_real].max())
890
+
891
+ if np.isclose(lat_min, lat_max) and np.isclose(lon_min, lon_max):
892
+ m.location = [float(lat_min), float(lon_min)]
893
+ else:
894
+ bounds = [[lat_min, lon_min], [lat_max, lon_max]]
895
+ m.fit_bounds(bounds, padding=(20, 20), max_zoom=17)
896
+ # Reaplica o fit após o carregamento para evitar override inesperado do viewport no cliente.
897
+ map_var = m.get_name()
898
+ center_lat = float((lat_min + lat_max) / 2.0)
899
+ center_lon = float((lon_min + lon_max) / 2.0)
900
+ recenter_js = (
901
+ "<script>"
902
+ "(function(){"
903
+ f"var __m = '{map_var}';"
904
+ f"var __b = L.latLngBounds([{float(lat_min)}, {float(lon_min)}], [{float(lat_max)}, {float(lon_max)}]);"
905
+ f"var __c = [{center_lat}, {center_lon}];"
906
+ "function __mesaRecenter(){"
907
+ "try {"
908
+ "var _map = window[__m];"
909
+ "if (!_map) return;"
910
+ "_map.invalidateSize(true);"
911
+ "_map.setView(__c, _map.getZoom(), {animate:false});"
912
+ "_map.fitBounds(__b, {padding:[20,20], maxZoom:17, animate:false});"
913
+ "} catch (e) {}"
914
+ "}"
915
+ "setTimeout(__mesaRecenter, 120);"
916
+ "setTimeout(__mesaRecenter, 650);"
917
+ "setTimeout(__mesaRecenter, 1400);"
918
+ "})();"
919
+ "</script>"
920
+ )
921
+ m.get_root().html.add_child(Element(recenter_js))
922
 
923
+ # Evita o wrapper de notebook (_repr_html_), que pode falhar dentro de iframe srcDoc.
924
+ return m.get_root().render()
925
 
926
  # ============================================================
927
  # FUNÇÃO: CARREGAR + VALIDAR MODELO (.dai)
backend/app/models/session.py CHANGED
@@ -42,6 +42,7 @@ class SessionState:
42
  geo_falhas_df: pd.DataFrame | None = None
43
  geo_col_cdlog: str | None = None
44
  geo_col_num: str | None = None
 
45
 
46
  pacote_visualizacao: dict[str, Any] | None = None
47
  dados_visualizacao: pd.DataFrame | None = None
 
42
  geo_falhas_df: pd.DataFrame | None = None
43
  geo_col_cdlog: str | None = None
44
  geo_col_num: str | None = None
45
+ mapa_habilitado: bool = False
46
 
47
  pacote_visualizacao: dict[str, Any] | None = None
48
  dados_visualizacao: pd.DataFrame | None = None
backend/app/services/elaboracao_service.py CHANGED
@@ -100,6 +100,12 @@ def _selection_context(session: SessionState) -> dict[str, Any]:
100
  }
101
 
102
 
 
 
 
 
 
 
103
  def classificar_tipos_variaveis_x(session: SessionState, colunas_x: list[str] | None) -> dict[str, Any]:
104
  df = session.df_original if session.df_original is not None else session.df_filtrado
105
  if df is None:
@@ -176,6 +182,7 @@ def _set_dataframe_base(session: SessionState, df: pd.DataFrame, clear_models: b
176
  session.geo_falhas_df = None
177
  session.geo_col_cdlog = None
178
  session.geo_col_num = None
 
179
 
180
  if clear_models:
181
  session.reset_modelo()
@@ -189,7 +196,7 @@ def _set_dataframe_base(session: SessionState, df: pd.DataFrame, clear_models: b
189
 
190
  colunas_numericas = [str(c) for c in obter_colunas_numericas(df)]
191
  coluna_y_padrao = identificar_coluna_y_padrao(df)
192
- mapa_html = charts.criar_mapa(df)
193
 
194
  return {
195
  "dados": dataframe_to_payload(df, decimals=4),
@@ -275,7 +282,7 @@ def load_dai_for_elaboracao(session: SessionState, caminho_arquivo: str) -> dict
275
  codigo_alocado=[str(c) for c in (codigo_alocado or [])],
276
  percentuais=[str(c) for c in (percentuais or [])],
277
  outliers_anteriores=session.outliers_anteriores,
278
- grau_min_coef=1,
279
  grau_min_f=0,
280
  )
281
 
@@ -313,7 +320,7 @@ def apply_selection(
313
  codigo_alocado: list[str] | None = None,
314
  percentuais: list[str] | None = None,
315
  outliers_anteriores: list[int] | None = None,
316
- grau_min_coef: int = 1,
317
  grau_min_f: int = 0,
318
  ) -> dict[str, Any]:
319
  df = session.df_original
@@ -404,12 +411,12 @@ def apply_selection(
404
  },
405
  "transformacao_y": session.transformacao_y,
406
  "transform_fields": transform_fields,
407
- "mapa_html": charts.criar_mapa(df_filtrado),
408
  "contexto": _selection_context(session),
409
  }
410
 
411
 
412
- def search_transformacoes(session: SessionState, grau_min_coef: int = 1, grau_min_f: int = 0) -> dict[str, Any]:
413
  df = session.df_filtrado if session.df_filtrado is not None else session.df_original
414
  if df is None or not session.coluna_y or not session.colunas_x:
415
  raise HTTPException(status_code=400, detail="Selecione variaveis antes de buscar transformacoes")
@@ -443,16 +450,11 @@ def search_transformacoes(session: SessionState, grau_min_coef: int = 1, grau_mi
443
 
444
  html = formatar_busca_html(resultados)
445
 
446
- grau_coef_usado = min(
447
- min(resultado.get("graus_coef", {}).values(), default=0) for resultado in resultados
448
- )
449
- grau_f_usado = min(resultado.get("grau_f", 0) for resultado in resultados)
450
-
451
  return {
452
  "html": html,
453
  "resultados": sanitize_value(resultados),
454
- "grau_coef": int(grau_coef_usado),
455
- "grau_f": int(grau_f_usado),
456
  }
457
 
458
 
@@ -733,9 +735,11 @@ def build_campos_avaliacao(session: SessionState) -> list[dict[str, Any]]:
733
  campos: list[dict[str, Any]] = []
734
  for col in colunas_x:
735
  placeholder = ""
 
736
  if col in session.dicotomicas:
737
  placeholder = "0 ou 1"
738
  tipo = "dicotomica"
 
739
  elif col in session.codigo_alocado and col in est_idx.index:
740
  min_v = est_idx.loc[col, "Mínimo"]
741
  max_v = est_idx.loc[col, "Máximo"]
@@ -752,13 +756,15 @@ def build_campos_avaliacao(session: SessionState) -> list[dict[str, Any]]:
752
  else:
753
  tipo = "numerica"
754
 
755
- campos.append(
756
- {
757
- "coluna": col,
758
- "placeholder": placeholder,
759
- "tipo": tipo,
760
- }
761
- )
 
 
762
 
763
  return sanitize_value(campos)
764
 
@@ -952,6 +958,7 @@ def atualizar_mapa(session: SessionState, var_mapa: str | None) -> dict[str, Any
952
  raise HTTPException(status_code=400, detail="Carregue dados primeiro")
953
 
954
  tamanho_col = None if not var_mapa or var_mapa == "Visualizacao Padrao" or var_mapa == "Visualização Padrão" else var_mapa
 
955
  mapa_html = charts.criar_mapa(df, tamanho_col=tamanho_col)
956
  return {"mapa_html": mapa_html}
957
 
@@ -1017,7 +1024,7 @@ def mapear_coordenadas_manualmente(session: SessionState, col_lat: str, col_lon:
1017
 
1018
  return {
1019
  "status": "Coordenadas mapeadas com sucesso",
1020
- "mapa_html": charts.criar_mapa(df_filtrado),
1021
  "dados": dataframe_to_payload(df_filtrado, decimals=4),
1022
  "coords": _build_coords_payload(df_novo, True),
1023
  }
@@ -1045,7 +1052,7 @@ def geocodificar(session: SessionState, col_cdlog: str, col_num: str, auto_200:
1045
  "status_html": status_html,
1046
  "falhas_html": falhas_html,
1047
  "falhas_para_correcao": dataframe_to_payload(df_correcoes, decimals=None),
1048
- "mapa_html": charts.criar_mapa(session.df_filtrado),
1049
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
1050
  "coords": _build_coords_payload(session.df_original, True),
1051
  }
@@ -1100,7 +1107,7 @@ def aplicar_correcoes_geocodificacao(
1100
  "status_html": status_html,
1101
  "falhas_html": falhas_html,
1102
  "falhas_para_correcao": dataframe_to_payload(df_correcoes, decimals=None),
1103
- "mapa_html": charts.criar_mapa(session.df_filtrado),
1104
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
1105
  "coords": _build_coords_payload(session.df_original, True),
1106
  }
@@ -1117,7 +1124,7 @@ def limpar_historico_outliers(session: SessionState) -> dict[str, Any]:
1117
 
1118
  return {
1119
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
1120
- "mapa_html": charts.criar_mapa(session.df_filtrado),
1121
  "outliers_html": "",
1122
  "resumo_outliers": "Excluidos: 0 | A excluir: 0 | A reincluir: 0 | Total: 0",
1123
  "contexto": _selection_context(session),
 
100
  }
101
 
102
 
103
+ def _render_mapa_if_enabled(session: SessionState, df: pd.DataFrame | None, **kwargs: Any) -> str:
104
+ if not session.mapa_habilitado or df is None:
105
+ return ""
106
+ return charts.criar_mapa(df, **kwargs)
107
+
108
+
109
  def classificar_tipos_variaveis_x(session: SessionState, colunas_x: list[str] | None) -> dict[str, Any]:
110
  df = session.df_original if session.df_original is not None else session.df_filtrado
111
  if df is None:
 
182
  session.geo_falhas_df = None
183
  session.geo_col_cdlog = None
184
  session.geo_col_num = None
185
+ session.mapa_habilitado = False
186
 
187
  if clear_models:
188
  session.reset_modelo()
 
196
 
197
  colunas_numericas = [str(c) for c in obter_colunas_numericas(df)]
198
  coluna_y_padrao = identificar_coluna_y_padrao(df)
199
+ mapa_html = _render_mapa_if_enabled(session, df)
200
 
201
  return {
202
  "dados": dataframe_to_payload(df, decimals=4),
 
282
  codigo_alocado=[str(c) for c in (codigo_alocado or [])],
283
  percentuais=[str(c) for c in (percentuais or [])],
284
  outliers_anteriores=session.outliers_anteriores,
285
+ grau_min_coef=0,
286
  grau_min_f=0,
287
  )
288
 
 
320
  codigo_alocado: list[str] | None = None,
321
  percentuais: list[str] | None = None,
322
  outliers_anteriores: list[int] | None = None,
323
+ grau_min_coef: int = 0,
324
  grau_min_f: int = 0,
325
  ) -> dict[str, Any]:
326
  df = session.df_original
 
411
  },
412
  "transformacao_y": session.transformacao_y,
413
  "transform_fields": transform_fields,
414
+ "mapa_html": _render_mapa_if_enabled(session, df_filtrado),
415
  "contexto": _selection_context(session),
416
  }
417
 
418
 
419
+ def search_transformacoes(session: SessionState, grau_min_coef: int = 0, grau_min_f: int = 0) -> dict[str, Any]:
420
  df = session.df_filtrado if session.df_filtrado is not None else session.df_original
421
  if df is None or not session.coluna_y or not session.colunas_x:
422
  raise HTTPException(status_code=400, detail="Selecione variaveis antes de buscar transformacoes")
 
450
 
451
  html = formatar_busca_html(resultados)
452
 
 
 
 
 
 
453
  return {
454
  "html": html,
455
  "resultados": sanitize_value(resultados),
456
+ "grau_coef": int(grau_min_coef),
457
+ "grau_f": int(grau_min_f),
458
  }
459
 
460
 
 
735
  campos: list[dict[str, Any]] = []
736
  for col in colunas_x:
737
  placeholder = ""
738
+ opcoes = None
739
  if col in session.dicotomicas:
740
  placeholder = "0 ou 1"
741
  tipo = "dicotomica"
742
+ opcoes = [0, 1]
743
  elif col in session.codigo_alocado and col in est_idx.index:
744
  min_v = est_idx.loc[col, "Mínimo"]
745
  max_v = est_idx.loc[col, "Máximo"]
 
756
  else:
757
  tipo = "numerica"
758
 
759
+ payload = {
760
+ "coluna": col,
761
+ "placeholder": placeholder,
762
+ "tipo": tipo,
763
+ }
764
+ if tipo == "dicotomica":
765
+ payload["opcoes"] = opcoes or [0, 1]
766
+
767
+ campos.append(payload)
768
 
769
  return sanitize_value(campos)
770
 
 
958
  raise HTTPException(status_code=400, detail="Carregue dados primeiro")
959
 
960
  tamanho_col = None if not var_mapa or var_mapa == "Visualizacao Padrao" or var_mapa == "Visualização Padrão" else var_mapa
961
+ session.mapa_habilitado = True
962
  mapa_html = charts.criar_mapa(df, tamanho_col=tamanho_col)
963
  return {"mapa_html": mapa_html}
964
 
 
1024
 
1025
  return {
1026
  "status": "Coordenadas mapeadas com sucesso",
1027
+ "mapa_html": _render_mapa_if_enabled(session, df_filtrado),
1028
  "dados": dataframe_to_payload(df_filtrado, decimals=4),
1029
  "coords": _build_coords_payload(df_novo, True),
1030
  }
 
1052
  "status_html": status_html,
1053
  "falhas_html": falhas_html,
1054
  "falhas_para_correcao": dataframe_to_payload(df_correcoes, decimals=None),
1055
+ "mapa_html": _render_mapa_if_enabled(session, session.df_filtrado),
1056
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
1057
  "coords": _build_coords_payload(session.df_original, True),
1058
  }
 
1107
  "status_html": status_html,
1108
  "falhas_html": falhas_html,
1109
  "falhas_para_correcao": dataframe_to_payload(df_correcoes, decimals=None),
1110
+ "mapa_html": _render_mapa_if_enabled(session, session.df_filtrado),
1111
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
1112
  "coords": _build_coords_payload(session.df_original, True),
1113
  }
 
1124
 
1125
  return {
1126
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
1127
+ "mapa_html": _render_mapa_if_enabled(session, session.df_filtrado),
1128
  "outliers_html": "",
1129
  "resumo_outliers": "Excluidos: 0 | A excluir: 0 | A reincluir: 0 | Total: 0",
1130
  "contexto": _selection_context(session),
backend/app/services/serializers.py CHANGED
@@ -7,6 +7,20 @@ from typing import Any
7
  import numpy as np
8
  import pandas as pd
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  def sanitize_value(value: Any) -> Any:
12
  if isinstance(value, np.ndarray):
@@ -49,14 +63,19 @@ def sanitize_value(value: Any) -> Any:
49
  return value
50
 
51
 
52
- def dataframe_to_payload(df: pd.DataFrame | None, decimals: int | None = None, max_rows: int = 5000) -> dict[str, Any] | None:
53
  if df is None:
54
  return None
55
 
56
  df_work = df.copy()
57
  if decimals is not None:
58
- numeric_cols = df_work.select_dtypes(include=[np.number]).columns
59
- df_work[numeric_cols] = df_work[numeric_cols].round(decimals)
 
 
 
 
 
60
 
61
  total_rows = len(df_work)
62
  truncated = total_rows > max_rows
 
7
  import numpy as np
8
  import pandas as pd
9
 
10
+ COORD_COLUMN_NAMES = {
11
+ "lat",
12
+ "latitude",
13
+ "siat_latitude",
14
+ "lon",
15
+ "long",
16
+ "longitude",
17
+ "siat_longitude",
18
+ }
19
+
20
+
21
+ def _is_coordinate_column(col_name: Any) -> bool:
22
+ return str(col_name).strip().lower() in COORD_COLUMN_NAMES
23
+
24
 
25
  def sanitize_value(value: Any) -> Any:
26
  if isinstance(value, np.ndarray):
 
63
  return value
64
 
65
 
66
+ def dataframe_to_payload(df: pd.DataFrame | None, decimals: int | None = None, max_rows: int = 2000) -> dict[str, Any] | None:
67
  if df is None:
68
  return None
69
 
70
  df_work = df.copy()
71
  if decimals is not None:
72
+ numeric_cols = [
73
+ col
74
+ for col in df_work.select_dtypes(include=[np.number]).columns
75
+ if not _is_coordinate_column(col)
76
+ ]
77
+ if numeric_cols:
78
+ df_work.loc[:, numeric_cols] = df_work.loc[:, numeric_cols].round(decimals)
79
 
80
  total_rows = len(df_work)
81
  truncated = total_rows > max_rows
backend/app/services/visualizacao_service.py CHANGED
@@ -90,9 +90,7 @@ def exibir_modelo(session: SessionState) -> dict[str, Any]:
90
 
91
  dados = pacote["dados"]["df"].reset_index()
92
  for col in dados.columns:
93
- if str(col).lower() in ["lat", "lon"]:
94
- dados[col] = dados[col].round(6)
95
- elif pd.api.types.is_numeric_dtype(dados[col]):
96
  dados[col] = dados[col].round(2)
97
 
98
  estat = _tabela_estatisticas(pacote).round(2)
@@ -198,9 +196,11 @@ def campos_avaliacao(session: SessionState) -> list[dict[str, Any]]:
198
  campos: list[dict[str, Any]] = []
199
  for col in colunas_x:
200
  placeholder = ""
 
201
  if col in dicotomicas:
202
  placeholder = "0 ou 1"
203
  tipo = "dicotomica"
 
204
  elif col in codigo_alocado and col in est_idx.index:
205
  min_v = est_idx.loc[col, "Mínimo"]
206
  max_v = est_idx.loc[col, "Máximo"]
@@ -217,7 +217,11 @@ def campos_avaliacao(session: SessionState) -> list[dict[str, Any]]:
217
  else:
218
  tipo = "numerica"
219
 
220
- campos.append({"coluna": col, "placeholder": placeholder, "tipo": tipo})
 
 
 
 
221
 
222
  return sanitize_value(campos)
223
 
 
90
 
91
  dados = pacote["dados"]["df"].reset_index()
92
  for col in dados.columns:
93
+ if pd.api.types.is_numeric_dtype(dados[col]) and str(col).lower() not in ["lat", "latitude", "lon", "longitude", "long", "siat_latitude", "siat_longitude"]:
 
 
94
  dados[col] = dados[col].round(2)
95
 
96
  estat = _tabela_estatisticas(pacote).round(2)
 
196
  campos: list[dict[str, Any]] = []
197
  for col in colunas_x:
198
  placeholder = ""
199
+ opcoes = None
200
  if col in dicotomicas:
201
  placeholder = "0 ou 1"
202
  tipo = "dicotomica"
203
+ opcoes = [0, 1]
204
  elif col in codigo_alocado and col in est_idx.index:
205
  min_v = est_idx.loc[col, "Mínimo"]
206
  max_v = est_idx.loc[col, "Máximo"]
 
217
  else:
218
  tipo = "numerica"
219
 
220
+ payload = {"coluna": col, "placeholder": placeholder, "tipo": tipo}
221
+ if tipo == "dicotomica":
222
+ payload["opcoes"] = opcoes or [0, 1]
223
+
224
+ campos.append(payload)
225
 
226
  return sanitize_value(campos)
227
 
backend/run_backend.sh CHANGED
@@ -1,4 +1,7 @@
1
  #!/usr/bin/env bash
2
  set -euo pipefail
3
 
4
- uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
 
 
 
 
1
  #!/usr/bin/env bash
2
  set -euo pipefail
3
 
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ cd "${SCRIPT_DIR}"
6
+
7
+ uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}" --reload --reload-dir "${SCRIPT_DIR}"
frontend/src/App.jsx CHANGED
@@ -72,8 +72,12 @@ export default function App() {
72
  </section>
73
  ) : null}
74
 
75
- {activeTab === 'Elaboração/Edição' ? <ElaboracaoTab sessionId={sessionId} /> : null}
76
- {activeTab === 'Visualização/Avaliação' ? <VisualizacaoTab sessionId={sessionId} /> : null}
 
 
 
 
77
  </div>
78
  )
79
  }
 
72
  </section>
73
  ) : null}
74
 
75
+ <div className="tab-pane" hidden={activeTab !== 'Elaboração/Edição'}>
76
+ <ElaboracaoTab sessionId={sessionId} />
77
+ </div>
78
+ <div className="tab-pane" hidden={activeTab !== 'Visualização/Avaliação'}>
79
+ <VisualizacaoTab sessionId={sessionId} />
80
+ </div>
81
  </div>
82
  )
83
  }
frontend/src/components/DataTable.jsx CHANGED
@@ -1,10 +1,16 @@
1
  import React from 'react'
2
 
3
- export default function DataTable({ table, maxHeight = 320 }) {
4
  if (!table || !table.columns || !table.rows) {
5
  return <div className="empty-box">Sem dados.</div>
6
  }
7
 
 
 
 
 
 
 
8
  return (
9
  <div className="table-wrapper" style={{ maxHeight }}>
10
  <table>
@@ -16,7 +22,7 @@ export default function DataTable({ table, maxHeight = 320 }) {
16
  </tr>
17
  </thead>
18
  <tbody>
19
- {table.rows.map((row, i) => (
20
  <tr key={i}>
21
  {table.columns.map((col) => (
22
  <td key={`${i}-${col}`}>{String(row[col] ?? '')}</td>
@@ -25,9 +31,16 @@ export default function DataTable({ table, maxHeight = 320 }) {
25
  ))}
26
  </tbody>
27
  </table>
 
 
 
 
 
28
  {table.truncated ? (
29
  <div className="table-hint">Mostrando {table.returned_rows} de {table.total_rows} linhas.</div>
30
  ) : null}
31
  </div>
32
  )
33
  }
 
 
 
1
  import React from 'react'
2
 
3
+ function DataTable({ table, maxHeight = 320 }) {
4
  if (!table || !table.columns || !table.rows) {
5
  return <div className="empty-box">Sem dados.</div>
6
  }
7
 
8
+ const MAX_RENDER_ROWS = 1200
9
+ const rowsToRender = table.rows.length > MAX_RENDER_ROWS
10
+ ? table.rows.slice(0, MAX_RENDER_ROWS)
11
+ : table.rows
12
+ const renderTruncated = rowsToRender.length < table.rows.length
13
+
14
  return (
15
  <div className="table-wrapper" style={{ maxHeight }}>
16
  <table>
 
22
  </tr>
23
  </thead>
24
  <tbody>
25
+ {rowsToRender.map((row, i) => (
26
  <tr key={i}>
27
  {table.columns.map((col) => (
28
  <td key={`${i}-${col}`}>{String(row[col] ?? '')}</td>
 
31
  ))}
32
  </tbody>
33
  </table>
34
+ {renderTruncated ? (
35
+ <div className="table-hint">
36
+ Renderizando {rowsToRender.length} de {table.rows.length} linhas recebidas para manter desempenho.
37
+ </div>
38
+ ) : null}
39
  {table.truncated ? (
40
  <div className="table-hint">Mostrando {table.returned_rows} de {table.total_rows} linhas.</div>
41
  ) : null}
42
  </div>
43
  )
44
  }
45
+
46
+ export default React.memo(DataTable)
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -1,6 +1,7 @@
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
  import DataTable from './DataTable'
 
4
  import MapFrame from './MapFrame'
5
  import PlotFigure from './PlotFigure'
6
  import SectionBlock from './SectionBlock'
@@ -59,6 +60,37 @@ function formatTransformacaoBadge(transformacao) {
59
  return valor
60
  }
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  export default function ElaboracaoTab({ sessionId }) {
63
  const [loading, setLoading] = useState(false)
64
  const [error, setError] = useState('')
@@ -73,13 +105,14 @@ export default function ElaboracaoTab({ sessionId }) {
73
  const [dados, setDados] = useState(null)
74
  const [mapaHtml, setMapaHtml] = useState('')
75
  const [mapaVariavel, setMapaVariavel] = useState('Visualização Padrão')
 
76
 
77
  const [coordsInfo, setCoordsInfo] = useState(null)
78
  const [manualLat, setManualLat] = useState('')
79
  const [manualLon, setManualLon] = useState('')
80
  const [geoCdlog, setGeoCdlog] = useState('')
81
  const [geoNum, setGeoNum] = useState('')
82
- const [geoAuto200, setGeoAuto200] = useState(false)
83
  const [geoStatusHtml, setGeoStatusHtml] = useState('')
84
  const [geoFalhasHtml, setGeoFalhasHtml] = useState('')
85
  const [geoCorrecoes, setGeoCorrecoes] = useState([])
@@ -98,7 +131,7 @@ export default function ElaboracaoTab({ sessionId }) {
98
  const [iteracao, setIteracao] = useState(1)
99
 
100
  const [selection, setSelection] = useState(null)
101
- const [grauCoef, setGrauCoef] = useState(1)
102
  const [grauF, setGrauF] = useState(0)
103
 
104
  const [transformacaoY, setTransformacaoY] = useState('(x)')
@@ -114,11 +147,11 @@ export default function ElaboracaoTab({ sessionId }) {
114
  const [outliersHtml, setOutliersHtml] = useState('')
115
 
116
  const [camposAvaliacao, setCamposAvaliacao] = useState([])
117
- const [valoresAvaliacao, setValoresAvaliacao] = useState({})
 
118
  const [resultadoAvaliacaoHtml, setResultadoAvaliacaoHtml] = useState('')
119
  const [baseChoices, setBaseChoices] = useState([])
120
  const [baseValue, setBaseValue] = useState('')
121
- const [deleteAvalIndex, setDeleteAvalIndex] = useState('')
122
 
123
  const [nomeArquivoExport, setNomeArquivoExport] = useState('modelo_mesa')
124
  const [avaliadores, setAvaliadores] = useState([])
@@ -126,6 +159,7 @@ export default function ElaboracaoTab({ sessionId }) {
126
  const [tipoFonteDados, setTipoFonteDados] = useState('')
127
  const marcarTodasXRef = useRef(null)
128
  const classificarXReqRef = useRef(0)
 
129
 
130
  const mapaChoices = useMemo(() => ['Visualização Padrão', ...colunasNumericas], [colunasNumericas])
131
  const colunasXDisponiveis = useMemo(
@@ -163,6 +197,7 @@ export default function ElaboracaoTab({ sessionId }) {
163
  geoProcessError
164
  ),
165
  )
 
166
 
167
  useEffect(() => {
168
  if (coordsInfo && !coordsInfo.tem_coords) {
@@ -249,9 +284,13 @@ export default function ElaboracaoTab({ sessionId }) {
249
  if (!table?.rows) return []
250
  return table.rows.map((row) => {
251
  const linha = row['Nº Linha'] ?? row['No Linha'] ?? row['linha'] ?? row['_index']
 
 
252
  return {
253
  linha: Number(linha),
254
  numero_corrigido: row['Nº Corrigido'] ?? row['No Corrigido'] ?? '',
 
 
255
  }
256
  })
257
  }
@@ -284,6 +323,8 @@ export default function ElaboracaoTab({ sessionId }) {
284
  setSelection(null)
285
  setFit(null)
286
  setCamposAvaliacao([])
 
 
287
  setResultadoAvaliacaoHtml('')
288
  setOutliersHtml('')
289
  }
@@ -292,7 +333,7 @@ export default function ElaboracaoTab({ sessionId }) {
292
  setCoordsInfo(resp.coords)
293
  setManualLat(resp.coords.colunas_disponiveis?.[0] || '')
294
  setManualLon(resp.coords.colunas_disponiveis?.[1] || '')
295
- setGeoCdlog(resp.coords.cdlog_auto || '')
296
  setGeoNum(resp.coords.num_auto || '')
297
  }
298
 
@@ -318,7 +359,7 @@ export default function ElaboracaoTab({ sessionId }) {
318
  setTransformacoesX(map)
319
 
320
  if (resp.busca) {
321
- setGrauCoef(resp.busca.grau_coef ?? 1)
322
  setGrauF(resp.busca.grau_f ?? 0)
323
  }
324
 
@@ -342,7 +383,8 @@ export default function ElaboracaoTab({ sessionId }) {
342
  ;(resp.avaliacao_campos || []).forEach((campo) => {
343
  init[campo.coluna] = ''
344
  })
345
- setValoresAvaliacao(init)
 
346
  setResultadoAvaliacaoHtml('')
347
  setBaseChoices([])
348
  setBaseValue('')
@@ -351,6 +393,9 @@ export default function ElaboracaoTab({ sessionId }) {
351
  async function onUploadClick() {
352
  if (!uploadedFile || !sessionId) return
353
  await withBusy(async () => {
 
 
 
354
  const nomeArquivo = String(uploadedFile?.name || '').toLowerCase()
355
  const uploadEhDai = nomeArquivo.endsWith('.dai')
356
  setTipoFonteDados(uploadEhDai ? 'dai' : 'tabular')
@@ -381,6 +426,8 @@ export default function ElaboracaoTab({ sessionId }) {
381
  setTransformacaoY('(x)')
382
  setTransformacoesX({})
383
  setCamposAvaliacao([])
 
 
384
  setResultadoAvaliacaoHtml('')
385
  setStatus(resp.status)
386
  }
@@ -390,6 +437,9 @@ export default function ElaboracaoTab({ sessionId }) {
390
  async function onConfirmSheet() {
391
  if (!selectedSheet || !sessionId) return
392
  await withBusy(async () => {
 
 
 
393
  const resp = await api.confirmSheet(sessionId, selectedSheet)
394
  setTipoFonteDados('tabular')
395
  setManualMapError('')
@@ -470,6 +520,18 @@ export default function ElaboracaoTab({ sessionId }) {
470
  }
471
  }
472
 
 
 
 
 
 
 
 
 
 
 
 
 
473
  async function onApplySelection() {
474
  if (!sessionId || !colunaY || colunasX.length === 0) return
475
  await withBusy(async () => {
@@ -581,6 +643,8 @@ export default function ElaboracaoTab({ sessionId }) {
581
  setSelection(null)
582
  setFit(null)
583
  setCamposAvaliacao([])
 
 
584
  setResultadoAvaliacaoHtml('')
585
  setOutliersHtml(resp.outliers_html || '')
586
  })
@@ -589,7 +653,7 @@ export default function ElaboracaoTab({ sessionId }) {
589
  async function onCalculateAvaliacao() {
590
  if (!sessionId) return
591
  await withBusy(async () => {
592
- const resp = await api.evaluationCalculateElab(sessionId, valoresAvaliacao, baseValue || null)
593
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
594
  setBaseChoices(resp.base_choices || [])
595
  setBaseValue(resp.base_value || '')
@@ -603,20 +667,63 @@ export default function ElaboracaoTab({ sessionId }) {
603
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
604
  setBaseChoices(resp.base_choices || [])
605
  setBaseValue(resp.base_value || '')
 
 
 
 
 
 
606
  })
607
  }
608
 
609
- async function onDeleteAvaliacao() {
610
  if (!sessionId) return
611
  await withBusy(async () => {
612
- const resp = await api.evaluationDeleteElab(sessionId, deleteAvalIndex, baseValue || null)
613
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
614
  setBaseChoices(resp.base_choices || [])
615
  setBaseValue(resp.base_value || '')
616
- setDeleteAvalIndex('')
617
  })
618
  }
619
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  async function onBaseChange(value) {
621
  setBaseValue(value)
622
  if (!sessionId) return
@@ -653,13 +760,22 @@ export default function ElaboracaoTab({ sessionId }) {
653
 
654
  async function onMapVarChange(value) {
655
  setMapaVariavel(value)
656
- if (!sessionId) return
657
  await withBusy(async () => {
658
  const resp = await api.updateElaboracaoMap(sessionId, value)
659
  setMapaHtml(resp.mapa_html || '')
660
  })
661
  }
662
 
 
 
 
 
 
 
 
 
 
663
  function toggleSelection(setter, value) {
664
  setter((prev) => {
665
  if (prev.includes(value)) return prev.filter((item) => item !== value)
@@ -720,17 +836,17 @@ export default function ElaboracaoTab({ sessionId }) {
720
  <div className="subpanel section1-group">
721
  <h4>Informações do modelo</h4>
722
  {elaborador?.nome_completo ? (
723
- <div className="modelo-cabecalho-grid">
724
- <div className="elaborador-badge">
725
- <div className="elaborador-badge-title">Modelo elaborado por:</div>
726
- <div className="elaborador-badge-name">{elaborador.nome_completo}</div>
727
- {elaboradorMeta.length > 0 ? (
728
- <div className="elaborador-badge-meta">{elaboradorMeta.join(' | ')}</div>
729
- ) : null}
730
- </div>
 
731
 
732
- {colunaY || variaveisIndependentesBadge.length > 0 ? (
733
- <div className="modelo-variaveis-box">
734
  <div className="elaborador-badge-title">Variáveis do modelo carregado</div>
735
  {colunaY ? (
736
  <div className="variavel-badge-line">
@@ -740,7 +856,9 @@ export default function ElaboracaoTab({ sessionId }) {
740
  {transformacaoYBadge ? <span className="variavel-chip-transform">{` ${transformacaoYBadge}`}</span> : null}
741
  </span>
742
  </div>
743
- ) : null}
 
 
744
  {variaveisIndependentesBadge.length > 0 ? (
745
  <div className="variavel-badge-line">
746
  <span className="variavel-badge-label">Independentes:</span>
@@ -753,316 +871,384 @@ export default function ElaboracaoTab({ sessionId }) {
753
  ))}
754
  </div>
755
  </div>
756
- ) : null}
 
 
757
  </div>
758
- ) : null}
759
  </div>
760
  ) : (
761
  <div className="section1-empty-hint">Carregue um modelo .dai para visualizar os badges do elaborador.</div>
762
  )}
763
  </div>
 
 
764
 
765
- <div className="subpanel section1-group">
766
- <h4>Resolver coordenadas</h4>
767
- {showCoordsPanel && coordsMode !== 'skipped' ? (
768
- <>
769
- {coordsInfo?.aviso_html ? <div dangerouslySetInnerHTML={{ __html: coordsInfo.aviso_html }} /> : null}
770
-
771
- {coordsMode === 'menu' ? (
772
- <div className="coords-choice-row">
773
- <button
774
- onClick={() => {
775
- setManualMapError('')
776
- setGeoProcessError('')
777
- setCoordsMode('mapear')
778
- }}
779
- disabled={loading}
780
- >
781
- Mapear colunas existentes para lat/lon
782
- </button>
783
- <span className="coords-choice-separator">ou</span>
784
- <button
785
- onClick={() => {
786
- setManualMapError('')
787
- setGeoProcessError('')
788
- setCoordsMode('geocodificar')
789
- }}
790
- disabled={loading}
791
- >
792
- Geocodificar automaticamente
793
- </button>
794
- <span className="coords-choice-separator">ou</span>
795
- <button
796
- onClick={() => {
797
- setManualMapError('')
798
- setGeoProcessError('')
799
- setCoordsMode('confirmar-sem-coords')
800
- }}
801
- disabled={loading}
802
- >
803
- Prosseguir sem mapear coordenadas
804
- </button>
805
- </div>
806
- ) : null}
807
-
808
- {coordsMode === 'confirmar-sem-coords' ? (
809
- <div className="subpanel warning">
810
- <p>
811
- Funcionalidades dependentes de geolocalização podem apresentar resultados incompletos sem coordenadas.
812
- Deseja prosseguir mesmo assim?
813
- </p>
814
- <div className="row">
815
- <button
816
- onClick={() => {
817
- setCoordsMode('skipped')
818
- setStatus('Seção 1 liberada sem coordenadas completas.')
819
- }}
820
- disabled={loading}
821
- >
822
- Confirmar e prosseguir
823
- </button>
824
- <button
825
- onClick={() => {
826
- setManualMapError('')
827
- setGeoProcessError('')
828
- setCoordsMode('menu')
829
- }}
830
- disabled={loading}
831
- >
832
- Voltar
833
- </button>
834
  </div>
835
- </div>
836
- ) : null}
837
 
838
- {coordsMode === 'mapear' ? (
839
- <div className="subpanel">
840
- <div className="row">
841
- <button
842
- onClick={() => {
843
- setManualMapError('')
844
- setCoordsMode('menu')
845
- }}
846
- disabled={loading}
847
- >
848
- Voltar
849
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
850
  </div>
851
- <h4>Mapeamento manual</h4>
852
- <div className="row">
853
- <select value={manualLat} onChange={(e) => setManualLat(e.target.value)}>
854
- <option value="">Coluna Latitude</option>
855
- {(coordsInfo?.colunas_disponiveis || []).map((col) => (
856
- <option key={`lat-${col}`} value={col}>{col}</option>
857
- ))}
858
- </select>
859
- <select value={manualLon} onChange={(e) => setManualLon(e.target.value)}>
860
- <option value="">Coluna Longitude</option>
861
- {(coordsInfo?.colunas_disponiveis || []).map((col) => (
862
- <option key={`lon-${col}`} value={col}>{col}</option>
863
- ))}
864
- </select>
865
- <button onClick={onMapCoords} disabled={loading || !manualLat || !manualLon}>Confirmar mapeamento</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
866
  </div>
867
- {manualMapError ? <div className="error-line inline-error">{manualMapError}</div> : null}
868
- </div>
869
- ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
870
 
871
- {coordsMode === 'geocodificar' ? (
872
- <div className="subpanel">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873
  <div className="row">
874
- <button
875
- onClick={() => {
876
- setGeoStatusHtml('')
877
- setGeoFalhasHtml('')
878
- setGeoCorrecoes([])
879
- setGeoProcessError('')
880
- setCoordsMode('menu')
881
- }}
882
- disabled={loading}
883
- >
884
- Voltar
885
  </button>
886
  </div>
887
- <h4>Geocodificação por eixo</h4>
888
- <div className="row">
889
- <select value={geoCdlog} onChange={(e) => setGeoCdlog(e.target.value)}>
890
- <option value="">Coluna CDLOG/CTM</option>
891
- {(coordsInfo?.colunas_disponiveis || []).map((col) => (
892
- <option key={`cdlog-${col}`} value={col}>{col}</option>
893
- ))}
894
- </select>
895
- <select value={geoNum} onChange={(e) => setGeoNum(e.target.value)}>
896
- <option value="">Coluna Número</option>
897
- {(coordsInfo?.colunas_disponiveis || []).map((col) => (
898
- <option key={`num-${col}`} value={col}>{col}</option>
899
  ))}
900
  </select>
901
- <label>
902
- <input type="checkbox" checked={geoAuto200} onChange={(e) => setGeoAuto200(e.target.checked)} />
903
- Auto ajustar {'<='} 200
904
- </label>
905
- <button onClick={onGeocodificar} disabled={loading || !geoCdlog || !geoNum}>Geocodificar</button>
906
  </div>
907
- {geoProcessError ? <div className="error-line inline-error">{geoProcessError}</div> : null}
908
- {geoStatusHtml ? <div dangerouslySetInnerHTML={{ __html: geoStatusHtml }} /> : null}
909
- {geoFalhasHtml ? <div dangerouslySetInnerHTML={{ __html: geoFalhasHtml }} /> : null}
910
- {geoCorrecoes.length > 0 ? (
911
- <div>
912
- <h5>Correções manuais</h5>
913
- <div className="geo-correcoes">
914
- {geoCorrecoes.map((item, idx) => (
915
- <div className="row" key={`cor-${item.linha}-${idx}`}>
916
- <span>Linha {item.linha}</span>
917
- <input
918
- type="text"
919
- value={item.numero_corrigido || ''}
920
- onChange={(e) => {
921
- const next = [...geoCorrecoes]
922
- next[idx] = { ...next[idx], numero_corrigido: e.target.value }
923
- setGeoCorrecoes(next)
924
- }}
925
- placeholder="Número corrigido"
926
- />
927
- </div>
928
- ))}
929
- </div>
930
- <button onClick={onAplicarCorrecoesGeo} disabled={loading}>Aplicar correções</button>
931
- </div>
932
- ) : null}
933
- </div>
934
- ) : null}
935
- </>
936
- ) : (
937
- <div className="section1-empty-hint">Coordenadas já disponíveis ou etapa concluída.</div>
938
- )}
939
- </div>
940
- </div>
941
- </SectionBlock>
942
 
943
- <SectionBlock
944
- step="2"
945
- title="Visualizar Dados"
946
- subtitle="Mapa interativo e tabela prévia da base carregada."
947
- aside={(
948
- <div className="row compact">
949
- <label>Variável no mapa</label>
950
- <select value={mapaVariavel} onChange={(e) => onMapVarChange(e.target.value)}>
951
- {mapaChoices.map((choice) => (
952
- <option key={choice} value={choice}>{choice}</option>
953
- ))}
954
- </select>
955
- </div>
956
- )}
957
- >
958
- <div className="pane">
959
- <MapFrame html={mapaHtml} />
960
- </div>
961
- <div className="stack-block">
962
- <h4>Dados de mercado</h4>
963
- <DataTable table={dados} maxHeight={540} />
964
- </div>
965
- </SectionBlock>
966
 
967
- <SectionBlock step="3" title="Selecionar Variável Dependente" subtitle="Defina a variável dependente (Y).">
968
- <div className="row">
969
- <label>Variável Dependente (Y)</label>
970
- <select value={colunaY} onChange={(e) => setColunaY(e.target.value)}>
971
- <option value="">Selecione</option>
972
- {colunasNumericas.map((col) => (
973
- <option key={col} value={col}>{col}</option>
974
- ))}
975
- </select>
976
- </div>
977
- </SectionBlock>
978
 
979
- <SectionBlock step="4" title="Selecionar Variáveis Independentes" subtitle="Escolha regressoras e grupos de tipologia.">
980
- <div className="compact-option-group">
981
- <h4>Variáveis Independentes (X)</h4>
982
- <div className="checkbox-inline-wrap checkbox-inline-wrap-tools">
983
- <label className="compact-checkbox compact-checkbox-toggle-all">
984
- <input
985
- ref={marcarTodasXRef}
986
- type="checkbox"
987
- checked={todasXMarcadas}
988
- onChange={onToggleTodasX}
989
- disabled={colunasXDisponiveis.length === 0}
990
- />
991
- {todasXMarcadas ? 'Desmarcar todas' : 'Marcar todas'}
992
- </label>
993
- <span className="compact-selection-count">
994
- {colunasX.length}/{colunasXDisponiveis.length} selecionadas
995
- </span>
996
- </div>
997
- <div className="checkbox-inline-wrap">
998
- {colunasXDisponiveis.map((col) => (
999
- <label key={`x-${col}`} className="compact-checkbox">
1000
- <input
1001
- type="checkbox"
1002
- checked={colunasX.includes(col)}
1003
- onChange={() => onToggleColunaX(col)}
1004
- />
1005
- {col}
1006
- </label>
1007
- ))}
1008
- </div>
1009
- </div>
1010
 
1011
- <div className="compact-option-group">
1012
- <h4>Variáveis Dicotômicas (0/1)</h4>
1013
- <div className="checkbox-inline-wrap">
1014
- {colunasX.map((col) => (
1015
- <label key={`d-${col}`} className="compact-checkbox">
1016
- <input type="checkbox" checked={dicotomicas.includes(col)} onChange={() => toggleSelection(setDicotomicas, col)} />
1017
- {col}
1018
- </label>
1019
- ))}
1020
- </div>
1021
- </div>
1022
 
1023
- <div className="compact-option-group">
1024
- <h4>Variáveis de Código Alocado/Ajustado</h4>
1025
- <div className="checkbox-inline-wrap">
1026
- {colunasX.map((col) => (
1027
- <label key={`c-${col}`} className="compact-checkbox">
1028
- <input type="checkbox" checked={codigoAlocado.includes(col)} onChange={() => toggleSelection(setCodigoAlocado, col)} />
1029
- {col}
1030
- </label>
1031
- ))}
1032
- </div>
1033
- </div>
1034
 
1035
- <div className="compact-option-group">
1036
- <h4>Variáveis Percentuais (0 a 1)</h4>
1037
- <div className="checkbox-inline-wrap">
1038
- {colunasX.map((col) => (
1039
- <label key={`p-${col}`} className="compact-checkbox">
1040
- <input type="checkbox" checked={percentuais.includes(col)} onChange={() => toggleSelection(setPercentuais, col)} />
1041
- {col}
1042
- </label>
1043
- ))}
1044
- </div>
1045
- </div>
1046
 
1047
- <div className="row">
1048
- <button onClick={onApplySelection} disabled={loading || !colunaY || colunasX.length === 0}>Aplicar seleção</button>
1049
- </div>
1050
- </SectionBlock>
 
 
1051
 
1052
  {selection ? (
1053
  <>
1054
- <SectionBlock step="5" title="Estatísticas das Variáveis Selecionadas" subtitle="Resumo estatístico para Y e regressoras.">
1055
  <DataTable table={selection.estatisticas} />
1056
  </SectionBlock>
1057
 
1058
- <SectionBlock step="6" title="Teste de Micronumerosidade" subtitle="Validação de amostra mínima para variáveis selecionadas.">
1059
  <div dangerouslySetInnerHTML={{ __html: selection.micronumerosidade_html || '' }} />
1060
  {selection.aviso_multicolinearidade?.visible ? (
1061
  <div dangerouslySetInnerHTML={{ __html: selection.aviso_multicolinearidade.html }} />
1062
  ) : null}
1063
  </SectionBlock>
1064
 
1065
- <SectionBlock step="7" title="Gráficos de Dispersão das Variáveis Independentes" subtitle="Leitura visual entre X e Y no conjunto filtrado.">
1066
  <PlotFigure
1067
  figure={selection.grafico_dispersao}
1068
  title="Dispersão (dados filtrados)"
@@ -1071,7 +1257,7 @@ export default function ElaboracaoTab({ sessionId }) {
1071
  />
1072
  </SectionBlock>
1073
 
1074
- <SectionBlock step="8" title="Transformações Sugeridas" subtitle="Busca automática de combinações por R² e enquadramento.">
1075
  <div className="row">
1076
  <label>Grau mínimo dos coeficientes</label>
1077
  <select value={grauCoef} onChange={(e) => setGrauCoef(Number(e.target.value))}>
@@ -1124,7 +1310,7 @@ export default function ElaboracaoTab({ sessionId }) {
1124
  )}
1125
  </SectionBlock>
1126
 
1127
- <SectionBlock step="9" title="Aplicação das Transformações" subtitle="Configuração manual para ajuste do modelo.">
1128
  <div className="row">
1129
  <label>Transformação de Y</label>
1130
  <select value={transformacaoY} onChange={(e) => setTransformacaoY(e.target.value)}>
@@ -1158,7 +1344,7 @@ export default function ElaboracaoTab({ sessionId }) {
1158
 
1159
  {fit ? (
1160
  <>
1161
- <SectionBlock step="10" title="Gráficos de Dispersão (Variáveis Transformadas)" subtitle="Dispersão com variáveis já transformadas.">
1162
  <div className="row">
1163
  <label>Tipo de dispersão</label>
1164
  <select value={tipoDispersao} onChange={(e) => onTipoDispersaoChange(e.target.value)}>
@@ -1169,7 +1355,7 @@ export default function ElaboracaoTab({ sessionId }) {
1169
  <PlotFigure figure={fit.grafico_dispersao_modelo} title="Dispersão do modelo" forceHideLegend className="plot-stretch" />
1170
  </SectionBlock>
1171
 
1172
- <SectionBlock step="11" title="Diagnóstico de Modelo" subtitle="Resumo diagnóstico e tabelas principais do ajuste.">
1173
  <div dangerouslySetInnerHTML={{ __html: fit.diagnosticos_html || '' }} />
1174
  <div className="two-col diagnostic-tables">
1175
  <div className="pane">
@@ -1183,7 +1369,7 @@ export default function ElaboracaoTab({ sessionId }) {
1183
  </div>
1184
  </SectionBlock>
1185
 
1186
- <SectionBlock step="12" title="Gráficos de Diagnóstico do Modelo" subtitle="Obs x calc, resíduos, histograma, Cook e correlação.">
1187
  <div className="plot-grid-2-fixed">
1188
  <PlotFigure figure={fit.grafico_obs_calc} title="Obs x Calc" />
1189
  <PlotFigure figure={fit.grafico_residuos} title="Resíduos" />
@@ -1195,92 +1381,114 @@ export default function ElaboracaoTab({ sessionId }) {
1195
  </div>
1196
  </SectionBlock>
1197
 
1198
- <SectionBlock step="13" title="Analisar Outliers" subtitle="Métricas para identificação de observações influentes.">
1199
  <DataTable table={fit.tabela_metricas} maxHeight={320} />
1200
  </SectionBlock>
1201
 
1202
- <SectionBlock step="14" title="Exclusão ou Reinclusão de Outliers" subtitle="Filtre índices, revise e atualize o modelo.">
1203
  {outliersAnteriores.length > 0 && outliersHtml ? (
1204
  <div className="outliers-html-box" dangerouslySetInnerHTML={{ __html: outliersHtml }} />
1205
  ) : null}
1206
- <div className="outlier-subheader">Filtrar outliers</div>
1207
- <div className="outlier-dica">Outliers = linhas que satisfazem qualquer filtro (lógica OR / união).</div>
1208
- <div className="filtros-stack">
1209
- {filtros.map((filtro, idx) => (
1210
- <div key={`filtro-${idx}`} className="filtro-row-react">
1211
- <select
1212
- value={filtro.variavel}
1213
- onChange={(e) => {
1214
- const next = [...filtros]
1215
- next[idx] = { ...next[idx], variavel: e.target.value }
1216
- setFiltros(next)
1217
- }}
1218
- >
1219
- {(fit.variaveis_filtro || []).map((varItem) => (
1220
- <option key={`vf-${idx}-${varItem}`} value={varItem}>{varItem}</option>
1221
- ))}
1222
- </select>
1223
- <select
1224
- value={filtro.operador}
1225
- onChange={(e) => {
1226
- const next = [...filtros]
1227
- next[idx] = { ...next[idx], operador: e.target.value }
1228
- setFiltros(next)
1229
- }}
1230
- >
1231
- {OPERADORES.map((op) => (
1232
- <option key={`op-${idx}-${op}`} value={op}>{op}</option>
1233
- ))}
1234
- </select>
1235
- <input
1236
- type="number"
1237
- value={filtro.valor}
1238
- onChange={(e) => {
1239
- const next = [...filtros]
1240
- next[idx] = { ...next[idx], valor: Number(e.target.value) }
1241
- setFiltros(next)
1242
- }}
1243
- />
1244
- </div>
1245
- ))}
1246
- </div>
1247
- <div className="outlier-actions-row">
1248
- <button onClick={onApplyOutlierFilters} disabled={loading}>Aplicar filtros</button>
1249
- </div>
1250
 
1251
- <div className="outlier-divider"><span className="arrow">→</span></div>
1252
- <div className="outlier-subheader">Excluir ou reincluir índices</div>
1253
- <div className="outlier-inputs-grid">
1254
- <div className="outlier-input-card">
1255
- <label>A excluir</label>
1256
- <input type="text" value={outliersTexto} onChange={(e) => setOutliersTexto(e.target.value)} placeholder="ex: 5, 12, 30" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1257
  </div>
1258
- <div className="outlier-input-card">
1259
- <label>A reincluir</label>
1260
- <input type="text" value={reincluirTexto} onChange={(e) => setReincluirTexto(e.target.value)} placeholder="ex: 5" />
1261
  </div>
1262
  </div>
1263
 
1264
- <div className="outlier-actions-row">
1265
- <button onClick={onRestartIteration} disabled={loading} className="btn-reiniciar-iteracao">
1266
- Atualizar Modelo (Excluir/Reincluir Outliers)
1267
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1268
  </div>
1269
  <div className="resumo-outliers-box">Iteração: {iteracao} | {resumoOutliers}</div>
1270
  <div className="resumo-outliers-box">Outliers anteriores: {joinSelection(outliersAnteriores) || '-'}</div>
1271
  </SectionBlock>
1272
 
1273
- <SectionBlock step="15" title="Avaliação de Imóvel" subtitle="Cálculo individual e comparação entre avaliações.">
1274
- <div className="avaliacao-grid">
1275
  {camposAvaliacao.map((campo) => (
1276
  <div key={`aval-${campo.coluna}`} className="avaliacao-card">
1277
  <label>{campo.coluna}</label>
1278
- <input
1279
- type="number"
1280
- value={valoresAvaliacao[campo.coluna] ?? ''}
1281
- placeholder={campo.placeholder || ''}
1282
- onChange={(e) => setValoresAvaliacao((prev) => ({ ...prev, [campo.coluna]: e.target.value }))}
1283
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1284
  </div>
1285
  ))}
1286
  </div>
@@ -1297,14 +1505,15 @@ export default function ElaboracaoTab({ sessionId }) {
1297
  <option key={`base-${choice}`} value={choice}>{choice}</option>
1298
  ))}
1299
  </select>
1300
- <label>Excluir avaliação #</label>
1301
- <input type="text" value={deleteAvalIndex} onChange={(e) => setDeleteAvalIndex(e.target.value)} />
1302
- <button onClick={onDeleteAvaliacao} disabled={loading}>Excluir</button>
1303
  </div>
1304
- <div className="avaliacao-resultado-box" dangerouslySetInnerHTML={{ __html: resultadoAvaliacaoHtml }} />
 
 
 
 
1305
  </SectionBlock>
1306
 
1307
- <SectionBlock step="16" title="Exportar Modelo" subtitle="Geração do pacote .dai e download da base tratada.">
1308
  <div className="row">
1309
  <label>Nome do arquivo (.dai)</label>
1310
  <input type="text" value={nomeArquivoExport} onChange={(e) => setNomeArquivoExport(e.target.value)} />
@@ -1324,7 +1533,7 @@ export default function ElaboracaoTab({ sessionId }) {
1324
  </>
1325
  ) : null}
1326
 
1327
- {loading ? <div className="status-line">Processando...</div> : null}
1328
  {error ? <div className="error-line">{error}</div> : null}
1329
  </div>
1330
  )
 
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
  import DataTable from './DataTable'
4
+ import LoadingOverlay from './LoadingOverlay'
5
  import MapFrame from './MapFrame'
6
  import PlotFigure from './PlotFigure'
7
  import SectionBlock from './SectionBlock'
 
60
  return valor
61
  }
62
 
63
+ function formatGeoColLabel(coluna, kind = 'default') {
64
+ const valor = String(coluna || '')
65
+ if (kind === 'cdlog' && valor.toUpperCase() === 'CTM') return 'CTM'
66
+ return valor
67
+ }
68
+
69
+ function escolherColunaCdlogPadrao(coords) {
70
+ const colunas = Array.isArray(coords?.colunas_disponiveis) ? coords.colunas_disponiveis : []
71
+ const auto = String(coords?.cdlog_auto || '').trim()
72
+
73
+ if (auto) {
74
+ const match = colunas.find((col) => String(col).toUpperCase() === auto.toUpperCase())
75
+ if (match) return match
76
+ }
77
+
78
+ const preferCtm = colunas.find((col) => String(col).toUpperCase() === 'CTM')
79
+ if (preferCtm) return preferCtm
80
+
81
+ const preferCdlog = colunas.find((col) => String(col).toUpperCase() === 'CDLOG')
82
+ if (preferCdlog) return preferCdlog
83
+
84
+ return auto || ''
85
+ }
86
+
87
+ function parseSugestoes(raw) {
88
+ return String(raw || '')
89
+ .split(',')
90
+ .map((item) => item.trim())
91
+ .filter(Boolean)
92
+ }
93
+
94
  export default function ElaboracaoTab({ sessionId }) {
95
  const [loading, setLoading] = useState(false)
96
  const [error, setError] = useState('')
 
105
  const [dados, setDados] = useState(null)
106
  const [mapaHtml, setMapaHtml] = useState('')
107
  const [mapaVariavel, setMapaVariavel] = useState('Visualização Padrão')
108
+ const [mapaGerado, setMapaGerado] = useState(false)
109
 
110
  const [coordsInfo, setCoordsInfo] = useState(null)
111
  const [manualLat, setManualLat] = useState('')
112
  const [manualLon, setManualLon] = useState('')
113
  const [geoCdlog, setGeoCdlog] = useState('')
114
  const [geoNum, setGeoNum] = useState('')
115
+ const [geoAuto200, setGeoAuto200] = useState(true)
116
  const [geoStatusHtml, setGeoStatusHtml] = useState('')
117
  const [geoFalhasHtml, setGeoFalhasHtml] = useState('')
118
  const [geoCorrecoes, setGeoCorrecoes] = useState([])
 
131
  const [iteracao, setIteracao] = useState(1)
132
 
133
  const [selection, setSelection] = useState(null)
134
+ const [grauCoef, setGrauCoef] = useState(0)
135
  const [grauF, setGrauF] = useState(0)
136
 
137
  const [transformacaoY, setTransformacaoY] = useState('(x)')
 
147
  const [outliersHtml, setOutliersHtml] = useState('')
148
 
149
  const [camposAvaliacao, setCamposAvaliacao] = useState([])
150
+ const valoresAvaliacaoRef = useRef({})
151
+ const [avaliacaoFormVersion, setAvaliacaoFormVersion] = useState(0)
152
  const [resultadoAvaliacaoHtml, setResultadoAvaliacaoHtml] = useState('')
153
  const [baseChoices, setBaseChoices] = useState([])
154
  const [baseValue, setBaseValue] = useState('')
 
155
 
156
  const [nomeArquivoExport, setNomeArquivoExport] = useState('modelo_mesa')
157
  const [avaliadores, setAvaliadores] = useState([])
 
159
  const [tipoFonteDados, setTipoFonteDados] = useState('')
160
  const marcarTodasXRef = useRef(null)
161
  const classificarXReqRef = useRef(0)
162
+ const deleteConfirmTimersRef = useRef({})
163
 
164
  const mapaChoices = useMemo(() => ['Visualização Padrão', ...colunasNumericas], [colunasNumericas])
165
  const colunasXDisponiveis = useMemo(
 
197
  geoProcessError
198
  ),
199
  )
200
+ const baseCarregada = Boolean(dados)
201
 
202
  useEffect(() => {
203
  if (coordsInfo && !coordsInfo.tem_coords) {
 
284
  if (!table?.rows) return []
285
  return table.rows.map((row) => {
286
  const linha = row['Nº Linha'] ?? row['No Linha'] ?? row['linha'] ?? row['_index']
287
+ const sugestoes = parseSugestoes(row['Sugestões'] ?? row['Sugestoes'] ?? row['sugestoes'] ?? '')
288
+ const sugestaoProxima = sugestoes[0] || ''
289
  return {
290
  linha: Number(linha),
291
  numero_corrigido: row['Nº Corrigido'] ?? row['No Corrigido'] ?? '',
292
+ sugestoes,
293
+ sugestao_proxima: sugestaoProxima,
294
  }
295
  })
296
  }
 
323
  setSelection(null)
324
  setFit(null)
325
  setCamposAvaliacao([])
326
+ valoresAvaliacaoRef.current = {}
327
+ setAvaliacaoFormVersion((prev) => prev + 1)
328
  setResultadoAvaliacaoHtml('')
329
  setOutliersHtml('')
330
  }
 
333
  setCoordsInfo(resp.coords)
334
  setManualLat(resp.coords.colunas_disponiveis?.[0] || '')
335
  setManualLon(resp.coords.colunas_disponiveis?.[1] || '')
336
+ setGeoCdlog(escolherColunaCdlogPadrao(resp.coords))
337
  setGeoNum(resp.coords.num_auto || '')
338
  }
339
 
 
359
  setTransformacoesX(map)
360
 
361
  if (resp.busca) {
362
+ setGrauCoef(resp.busca.grau_coef ?? 0)
363
  setGrauF(resp.busca.grau_f ?? 0)
364
  }
365
 
 
383
  ;(resp.avaliacao_campos || []).forEach((campo) => {
384
  init[campo.coluna] = ''
385
  })
386
+ valoresAvaliacaoRef.current = init
387
+ setAvaliacaoFormVersion((prev) => prev + 1)
388
  setResultadoAvaliacaoHtml('')
389
  setBaseChoices([])
390
  setBaseValue('')
 
393
  async function onUploadClick() {
394
  if (!uploadedFile || !sessionId) return
395
  await withBusy(async () => {
396
+ setMapaGerado(false)
397
+ setMapaHtml('')
398
+ setGeoAuto200(true)
399
  const nomeArquivo = String(uploadedFile?.name || '').toLowerCase()
400
  const uploadEhDai = nomeArquivo.endsWith('.dai')
401
  setTipoFonteDados(uploadEhDai ? 'dai' : 'tabular')
 
426
  setTransformacaoY('(x)')
427
  setTransformacoesX({})
428
  setCamposAvaliacao([])
429
+ valoresAvaliacaoRef.current = {}
430
+ setAvaliacaoFormVersion((prev) => prev + 1)
431
  setResultadoAvaliacaoHtml('')
432
  setStatus(resp.status)
433
  }
 
437
  async function onConfirmSheet() {
438
  if (!selectedSheet || !sessionId) return
439
  await withBusy(async () => {
440
+ setMapaGerado(false)
441
+ setMapaHtml('')
442
+ setGeoAuto200(true)
443
  const resp = await api.confirmSheet(sessionId, selectedSheet)
444
  setTipoFonteDados('tabular')
445
  setManualMapError('')
 
520
  }
521
  }
522
 
523
+ function onPreencherCorrecoesSugestaoProxima() {
524
+ setGeoCorrecoes((prev) => prev.map((item) => (
525
+ item.sugestao_proxima
526
+ ? { ...item, numero_corrigido: item.sugestao_proxima }
527
+ : item
528
+ )))
529
+ }
530
+
531
+ function onLimparCorrecoesGeo() {
532
+ setGeoCorrecoes((prev) => prev.map((item) => ({ ...item, numero_corrigido: '' })))
533
+ }
534
+
535
  async function onApplySelection() {
536
  if (!sessionId || !colunaY || colunasX.length === 0) return
537
  await withBusy(async () => {
 
643
  setSelection(null)
644
  setFit(null)
645
  setCamposAvaliacao([])
646
+ valoresAvaliacaoRef.current = {}
647
+ setAvaliacaoFormVersion((prev) => prev + 1)
648
  setResultadoAvaliacaoHtml('')
649
  setOutliersHtml(resp.outliers_html || '')
650
  })
 
653
  async function onCalculateAvaliacao() {
654
  if (!sessionId) return
655
  await withBusy(async () => {
656
+ const resp = await api.evaluationCalculateElab(sessionId, valoresAvaliacaoRef.current, baseValue || null)
657
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
658
  setBaseChoices(resp.base_choices || [])
659
  setBaseValue(resp.base_value || '')
 
667
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
668
  setBaseChoices(resp.base_choices || [])
669
  setBaseValue(resp.base_value || '')
670
+ const limpo = {}
671
+ camposAvaliacao.forEach((campo) => {
672
+ limpo[campo.coluna] = ''
673
+ })
674
+ valoresAvaliacaoRef.current = limpo
675
+ setAvaliacaoFormVersion((prev) => prev + 1)
676
  })
677
  }
678
 
679
+ async function onDeleteAvaliacao(indice) {
680
  if (!sessionId) return
681
  await withBusy(async () => {
682
+ const resp = await api.evaluationDeleteElab(sessionId, indice ? String(indice) : null, baseValue || null)
683
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
684
  setBaseChoices(resp.base_choices || [])
685
  setBaseValue(resp.base_value || '')
 
686
  })
687
  }
688
 
689
+ function onAvaliacaoResultadoClick(event) {
690
+ const ativarExclusao = event.target.closest('[data-avaliacao-delete-arm]')
691
+ if (ativarExclusao) {
692
+ const indice = ativarExclusao.getAttribute('data-avaliacao-delete-index')
693
+ if (!indice) return
694
+
695
+ const cell = ativarExclusao.closest('td')
696
+ const botaoConfirmar = cell?.querySelector(`[data-avaliacao-delete-confirm="${indice}"]`)
697
+ if (!botaoConfirmar) return
698
+
699
+ ativarExclusao.style.display = 'none'
700
+ botaoConfirmar.style.display = 'inline-block'
701
+
702
+ const timerKey = String(indice)
703
+ if (deleteConfirmTimersRef.current[timerKey]) {
704
+ clearTimeout(deleteConfirmTimersRef.current[timerKey])
705
+ }
706
+ deleteConfirmTimersRef.current[timerKey] = window.setTimeout(() => {
707
+ botaoConfirmar.style.display = 'none'
708
+ ativarExclusao.style.display = 'inline'
709
+ delete deleteConfirmTimersRef.current[timerKey]
710
+ }, 10000)
711
+ return
712
+ }
713
+
714
+ const confirmarExclusao = event.target.closest('[data-avaliacao-delete-confirm]')
715
+ if (!confirmarExclusao) return
716
+ const indice = confirmarExclusao.getAttribute('data-avaliacao-delete-confirm')
717
+ if (!indice) return
718
+
719
+ const timerKey = String(indice)
720
+ if (deleteConfirmTimersRef.current[timerKey]) {
721
+ clearTimeout(deleteConfirmTimersRef.current[timerKey])
722
+ delete deleteConfirmTimersRef.current[timerKey]
723
+ }
724
+ onDeleteAvaliacao(indice)
725
+ }
726
+
727
  async function onBaseChange(value) {
728
  setBaseValue(value)
729
  if (!sessionId) return
 
760
 
761
  async function onMapVarChange(value) {
762
  setMapaVariavel(value)
763
+ if (!sessionId || !mapaGerado) return
764
  await withBusy(async () => {
765
  const resp = await api.updateElaboracaoMap(sessionId, value)
766
  setMapaHtml(resp.mapa_html || '')
767
  })
768
  }
769
 
770
+ async function onGerarMapa() {
771
+ if (!sessionId) return
772
+ await withBusy(async () => {
773
+ const resp = await api.updateElaboracaoMap(sessionId, mapaVariavel)
774
+ setMapaHtml(resp.mapa_html || '')
775
+ setMapaGerado(true)
776
+ })
777
+ }
778
+
779
  function toggleSelection(setter, value) {
780
  setter((prev) => {
781
  if (prev.includes(value)) return prev.filter((item) => item !== value)
 
836
  <div className="subpanel section1-group">
837
  <h4>Informações do modelo</h4>
838
  {elaborador?.nome_completo ? (
839
+ <div className="modelo-info-card">
840
+ <div className="modelo-info-split">
841
+ <div className="modelo-info-col">
842
+ <div className="elaborador-badge-title">Modelo elaborado por:</div>
843
+ <div className="elaborador-badge-name">{elaborador.nome_completo}</div>
844
+ {elaboradorMeta.length > 0 ? (
845
+ <div className="elaborador-badge-meta">{elaboradorMeta.join(' | ')}</div>
846
+ ) : null}
847
+ </div>
848
 
849
+ <div className="modelo-info-col modelo-info-col-vars">
 
850
  <div className="elaborador-badge-title">Variáveis do modelo carregado</div>
851
  {colunaY ? (
852
  <div className="variavel-badge-line">
 
856
  {transformacaoYBadge ? <span className="variavel-chip-transform">{` ${transformacaoYBadge}`}</span> : null}
857
  </span>
858
  </div>
859
+ ) : (
860
+ <div className="section1-empty-hint">Variável dependente não encontrada no modelo carregado.</div>
861
+ )}
862
  {variaveisIndependentesBadge.length > 0 ? (
863
  <div className="variavel-badge-line">
864
  <span className="variavel-badge-label">Independentes:</span>
 
871
  ))}
872
  </div>
873
  </div>
874
+ ) : (
875
+ <div className="section1-empty-hint">Sem variáveis independentes no modelo carregado.</div>
876
+ )}
877
  </div>
878
+ </div>
879
  </div>
880
  ) : (
881
  <div className="section1-empty-hint">Carregue um modelo .dai para visualizar os badges do elaborador.</div>
882
  )}
883
  </div>
884
+ </div>
885
+ </SectionBlock>
886
 
887
+ {baseCarregada ? (
888
+ <>
889
+ <SectionBlock step="2" title="Resolver Coordenadas" subtitle="Mapeie lat/lon ou execute geocodificação automática.">
890
+ <div className="coords-section-groups">
891
+ {showCoordsPanel && coordsMode !== 'skipped' ? (
892
+ <>
893
+ {coordsInfo?.aviso_html ? (
894
+ <div className="subpanel coords-section-group coords-section-alert" dangerouslySetInnerHTML={{ __html: coordsInfo.aviso_html }} />
895
+ ) : null}
896
+
897
+ {coordsMode === 'menu' ? (
898
+ <div className="subpanel coords-section-group">
899
+ <div className="coords-choice-row">
900
+ <button
901
+ onClick={() => {
902
+ setManualMapError('')
903
+ setGeoProcessError('')
904
+ setCoordsMode('mapear')
905
+ }}
906
+ disabled={loading}
907
+ >
908
+ Mapear colunas existentes para lat/lon
909
+ </button>
910
+ <span className="coords-choice-separator">ou</span>
911
+ <button
912
+ onClick={() => {
913
+ setManualMapError('')
914
+ setGeoProcessError('')
915
+ setCoordsMode('geocodificar')
916
+ }}
917
+ disabled={loading}
918
+ >
919
+ Geocodificar automaticamente
920
+ </button>
921
+ <span className="coords-choice-separator">ou</span>
922
+ <button
923
+ onClick={() => {
924
+ setManualMapError('')
925
+ setGeoProcessError('')
926
+ setCoordsMode('confirmar-sem-coords')
927
+ }}
928
+ disabled={loading}
929
+ >
930
+ Prosseguir sem mapear coordenadas
931
+ </button>
932
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
933
  </div>
934
+ ) : null}
 
935
 
936
+ {coordsMode === 'confirmar-sem-coords' ? (
937
+ <div className="subpanel warning coords-section-group">
938
+ <p>
939
+ Funcionalidades dependentes de geolocalização podem apresentar resultados incompletos sem coordenadas.
940
+ Deseja prosseguir mesmo assim?
941
+ </p>
942
+ <div className="row">
943
+ <button
944
+ onClick={() => {
945
+ setCoordsMode('skipped')
946
+ setStatus('Seção 2 liberada sem coordenadas completas.')
947
+ }}
948
+ disabled={loading}
949
+ >
950
+ Confirmar e prosseguir
951
+ </button>
952
+ <button
953
+ onClick={() => {
954
+ setManualMapError('')
955
+ setGeoProcessError('')
956
+ setCoordsMode('menu')
957
+ }}
958
+ disabled={loading}
959
+ >
960
+ Voltar
961
+ </button>
962
+ </div>
963
  </div>
964
+ ) : null}
965
+
966
+ {coordsMode === 'mapear' ? (
967
+ <div className="subpanel coords-section-group">
968
+ <div className="row">
969
+ <button
970
+ onClick={() => {
971
+ setManualMapError('')
972
+ setCoordsMode('menu')
973
+ }}
974
+ disabled={loading}
975
+ >
976
+ Voltar
977
+ </button>
978
+ </div>
979
+ <h4>Mapeamento manual</h4>
980
+ <div className="row">
981
+ <select value={manualLat} onChange={(e) => setManualLat(e.target.value)}>
982
+ <option value="">Coluna Latitude</option>
983
+ {(coordsInfo?.colunas_disponiveis || []).map((col) => (
984
+ <option key={`lat-${col}`} value={col}>{col}</option>
985
+ ))}
986
+ </select>
987
+ <select value={manualLon} onChange={(e) => setManualLon(e.target.value)}>
988
+ <option value="">Coluna Longitude</option>
989
+ {(coordsInfo?.colunas_disponiveis || []).map((col) => (
990
+ <option key={`lon-${col}`} value={col}>{col}</option>
991
+ ))}
992
+ </select>
993
+ <button onClick={onMapCoords} disabled={loading || !manualLat || !manualLon}>Confirmar mapeamento</button>
994
+ </div>
995
+ {manualMapError ? <div className="error-line inline-error">{manualMapError}</div> : null}
996
  </div>
997
+ ) : null}
998
+
999
+ {coordsMode === 'geocodificar' ? (
1000
+ <>
1001
+ <div className="subpanel coords-section-group">
1002
+ <div className="row">
1003
+ <button
1004
+ onClick={() => {
1005
+ setGeoStatusHtml('')
1006
+ setGeoFalhasHtml('')
1007
+ setGeoCorrecoes([])
1008
+ setGeoProcessError('')
1009
+ setCoordsMode('menu')
1010
+ }}
1011
+ disabled={loading}
1012
+ >
1013
+ Voltar
1014
+ </button>
1015
+ </div>
1016
+ <h4>Geocodificação por eixo</h4>
1017
+ <div className="row">
1018
+ <select value={geoCdlog} onChange={(e) => setGeoCdlog(e.target.value)}>
1019
+ <option value="">Coluna CDLOG/CTM</option>
1020
+ {(coordsInfo?.colunas_disponiveis || []).map((col) => (
1021
+ <option key={`cdlog-${col}`} value={col}>{formatGeoColLabel(col, 'cdlog')}</option>
1022
+ ))}
1023
+ </select>
1024
+ <select value={geoNum} onChange={(e) => setGeoNum(e.target.value)}>
1025
+ <option value="">Coluna Número</option>
1026
+ {(coordsInfo?.colunas_disponiveis || []).map((col) => (
1027
+ <option key={`num-${col}`} value={col}>{col}</option>
1028
+ ))}
1029
+ </select>
1030
+ <label className="geo-auto-toggle">
1031
+ <input type="checkbox" checked={geoAuto200} onChange={(e) => setGeoAuto200(e.target.checked)} />
1032
+ <span>Auto ajustar {'<='} 200</span>
1033
+ </label>
1034
+ <button onClick={onGeocodificar} disabled={loading || !geoCdlog || !geoNum}>Geocodificar</button>
1035
+ </div>
1036
+ {geoProcessError ? <div className="error-line inline-error">{geoProcessError}</div> : null}
1037
+ </div>
1038
 
1039
+ {geoStatusHtml ? (
1040
+ <div className="subpanel coords-section-group coords-result-group">
1041
+ <h5>Resultado da geocodificação</h5>
1042
+ <div dangerouslySetInnerHTML={{ __html: geoStatusHtml }} />
1043
+ </div>
1044
+ ) : null}
1045
+
1046
+ {geoFalhasHtml ? (
1047
+ <div className="subpanel coords-section-group coords-falhas-group">
1048
+ <h5>Planilha de falhas</h5>
1049
+ <div dangerouslySetInnerHTML={{ __html: geoFalhasHtml }} />
1050
+ </div>
1051
+ ) : null}
1052
+
1053
+ {geoCorrecoes.length > 0 ? (
1054
+ <div className="subpanel coords-section-group coords-correcoes-group">
1055
+ <h5>Correções manuais</h5>
1056
+ <div className="row compact geo-correcoes-actions">
1057
+ <button type="button" onClick={onPreencherCorrecoesSugestaoProxima} disabled={loading}>
1058
+ Preencher todas com opção mais próxima
1059
+ </button>
1060
+ <button type="button" onClick={onLimparCorrecoesGeo} disabled={loading}>
1061
+ Limpar preenchimentos
1062
+ </button>
1063
+ </div>
1064
+ <div className="geo-correcoes">
1065
+ {geoCorrecoes.map((item, idx) => (
1066
+ <div className="geo-correcao-item" key={`cor-${item.linha}-${idx}`}>
1067
+ <span className="geo-correcao-linha">Linha {item.linha}</span>
1068
+ <input
1069
+ type="text"
1070
+ value={item.numero_corrigido || ''}
1071
+ onChange={(e) => {
1072
+ const next = [...geoCorrecoes]
1073
+ next[idx] = { ...next[idx], numero_corrigido: e.target.value }
1074
+ setGeoCorrecoes(next)
1075
+ }}
1076
+ placeholder="Número corrigido"
1077
+ />
1078
+ {item.sugestoes?.length > 0 ? (
1079
+ <div className="geo-correcao-sugestoes">
1080
+ Sugestões: {item.sugestoes.join(', ')}
1081
+ </div>
1082
+ ) : null}
1083
+ </div>
1084
+ ))}
1085
+ </div>
1086
+ <div className="row geo-correcoes-apply-row">
1087
+ <button type="button" onClick={onAplicarCorrecoesGeo} disabled={loading}>
1088
+ Aplicar correções
1089
+ </button>
1090
+ </div>
1091
+ </div>
1092
+ ) : null}
1093
+ </>
1094
+ ) : null}
1095
+ </>
1096
+ ) : (
1097
+ <div className="section1-empty-hint">Coordenadas já disponíveis ou etapa concluída.</div>
1098
+ )}
1099
+ </div>
1100
+ </SectionBlock>
1101
+
1102
+ <SectionBlock
1103
+ step="3"
1104
+ title="Visualizar Dados"
1105
+ subtitle="Mapa interativo e tabela prévia da base carregada."
1106
+ >
1107
+ <div className="dados-visualizacao-groups">
1108
+ <div className="subpanel dados-visualizacao-group">
1109
+ {!mapaGerado ? (
1110
+ <div className="empty-box">
1111
  <div className="row">
1112
+ <button type="button" className="btn-gerar-mapa" onClick={onGerarMapa} disabled={loading}>
1113
+ Gerar Mapa
 
 
 
 
 
 
 
 
 
1114
  </button>
1115
  </div>
1116
+ <div className="section1-empty-hint">O mapa será carregado somente após solicitação explícita.</div>
1117
+ </div>
1118
+ ) : (
1119
+ <details className="dados-mapa-details" open>
1120
+ <summary>Mapa</summary>
1121
+ <div className="row compact dados-mapa-controls">
1122
+ <label>Variável no mapa</label>
1123
+ <select value={mapaVariavel} onChange={(e) => onMapVarChange(e.target.value)}>
1124
+ {mapaChoices.map((choice) => (
1125
+ <option key={choice} value={choice}>{choice}</option>
 
 
1126
  ))}
1127
  </select>
 
 
 
 
 
1128
  </div>
1129
+ <MapFrame html={mapaHtml} />
1130
+ </details>
1131
+ )}
1132
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1133
 
1134
+ <div className="subpanel dados-visualizacao-group">
1135
+ <h4>Dados de mercado</h4>
1136
+ <div className="dados-outliers-resumo">
1137
+ <div className="resumo-outliers-box">
1138
+ {outliersAnteriores.length > 0
1139
+ ? `Há outliers excluídos: sim (${outliersAnteriores.length})`
1140
+ : 'Há outliers excluídos: não'}
1141
+ </div>
1142
+ {outliersAnteriores.length > 0 ? (
1143
+ <div className="resumo-outliers-box">Índices excluídos: {joinSelection(outliersAnteriores)}</div>
1144
+ ) : null}
1145
+ </div>
1146
+ <DataTable table={dados} maxHeight={540} />
1147
+ </div>
1148
+ </div>
1149
+ </SectionBlock>
 
 
 
 
 
 
 
1150
 
1151
+ <SectionBlock step="4" title="Selecionar Variável Dependente" subtitle="Defina a variável dependente (Y).">
1152
+ <div className="row">
1153
+ <label>Variável Dependente (Y)</label>
1154
+ <select value={colunaY} onChange={(e) => setColunaY(e.target.value)}>
1155
+ <option value="">Selecione</option>
1156
+ {colunasNumericas.map((col) => (
1157
+ <option key={col} value={col}>{col}</option>
1158
+ ))}
1159
+ </select>
1160
+ </div>
1161
+ </SectionBlock>
1162
 
1163
+ <SectionBlock step="5" title="Selecionar Variáveis Independentes" subtitle="Escolha regressoras e grupos de tipologia.">
1164
+ <div className="compact-option-group">
1165
+ <h4>Variáveis Independentes (X)</h4>
1166
+ <div className="checkbox-inline-wrap checkbox-inline-wrap-tools">
1167
+ <label className="compact-checkbox compact-checkbox-toggle-all">
1168
+ <input
1169
+ ref={marcarTodasXRef}
1170
+ type="checkbox"
1171
+ checked={todasXMarcadas}
1172
+ onChange={onToggleTodasX}
1173
+ disabled={colunasXDisponiveis.length === 0}
1174
+ />
1175
+ {todasXMarcadas ? 'Desmarcar todas' : 'Marcar todas'}
1176
+ </label>
1177
+ <span className="compact-selection-count">
1178
+ {colunasX.length}/{colunasXDisponiveis.length} selecionadas
1179
+ </span>
1180
+ </div>
1181
+ <div className="checkbox-inline-wrap">
1182
+ {colunasXDisponiveis.map((col) => (
1183
+ <label key={`x-${col}`} className="compact-checkbox">
1184
+ <input
1185
+ type="checkbox"
1186
+ checked={colunasX.includes(col)}
1187
+ onChange={() => onToggleColunaX(col)}
1188
+ />
1189
+ {col}
1190
+ </label>
1191
+ ))}
1192
+ </div>
1193
+ </div>
1194
 
1195
+ <div className="compact-option-group">
1196
+ <h4>Variáveis Dicotômicas (0/1)</h4>
1197
+ <div className="checkbox-inline-wrap">
1198
+ {colunasX.map((col) => (
1199
+ <label key={`d-${col}`} className="compact-checkbox">
1200
+ <input type="checkbox" checked={dicotomicas.includes(col)} onChange={() => toggleSelection(setDicotomicas, col)} />
1201
+ {col}
1202
+ </label>
1203
+ ))}
1204
+ </div>
1205
+ </div>
1206
 
1207
+ <div className="compact-option-group">
1208
+ <h4>Variáveis de Código Alocado/Ajustado</h4>
1209
+ <div className="checkbox-inline-wrap">
1210
+ {colunasX.map((col) => (
1211
+ <label key={`c-${col}`} className="compact-checkbox">
1212
+ <input type="checkbox" checked={codigoAlocado.includes(col)} onChange={() => toggleSelection(setCodigoAlocado, col)} />
1213
+ {col}
1214
+ </label>
1215
+ ))}
1216
+ </div>
1217
+ </div>
1218
 
1219
+ <div className="compact-option-group">
1220
+ <h4>Variáveis Percentuais (0 a 1)</h4>
1221
+ <div className="checkbox-inline-wrap">
1222
+ {colunasX.map((col) => (
1223
+ <label key={`p-${col}`} className="compact-checkbox">
1224
+ <input type="checkbox" checked={percentuais.includes(col)} onChange={() => toggleSelection(setPercentuais, col)} />
1225
+ {col}
1226
+ </label>
1227
+ ))}
1228
+ </div>
1229
+ </div>
1230
 
1231
+ <div className="row">
1232
+ <button onClick={onApplySelection} disabled={loading || !colunaY || colunasX.length === 0}>Aplicar seleção</button>
1233
+ </div>
1234
+ </SectionBlock>
1235
+ </>
1236
+ ) : null}
1237
 
1238
  {selection ? (
1239
  <>
1240
+ <SectionBlock step="6" title="Estatísticas das Variáveis Selecionadas" subtitle="Resumo estatístico para Y e regressoras.">
1241
  <DataTable table={selection.estatisticas} />
1242
  </SectionBlock>
1243
 
1244
+ <SectionBlock step="7" title="Teste de Micronumerosidade" subtitle="Validação de amostra mínima para variáveis selecionadas.">
1245
  <div dangerouslySetInnerHTML={{ __html: selection.micronumerosidade_html || '' }} />
1246
  {selection.aviso_multicolinearidade?.visible ? (
1247
  <div dangerouslySetInnerHTML={{ __html: selection.aviso_multicolinearidade.html }} />
1248
  ) : null}
1249
  </SectionBlock>
1250
 
1251
+ <SectionBlock step="8" title="Gráficos de Dispersão das Variáveis Independentes" subtitle="Leitura visual entre X e Y no conjunto filtrado.">
1252
  <PlotFigure
1253
  figure={selection.grafico_dispersao}
1254
  title="Dispersão (dados filtrados)"
 
1257
  />
1258
  </SectionBlock>
1259
 
1260
+ <SectionBlock step="9" title="Transformações Sugeridas" subtitle="Busca automática de combinações por R² e enquadramento.">
1261
  <div className="row">
1262
  <label>Grau mínimo dos coeficientes</label>
1263
  <select value={grauCoef} onChange={(e) => setGrauCoef(Number(e.target.value))}>
 
1310
  )}
1311
  </SectionBlock>
1312
 
1313
+ <SectionBlock step="10" title="Aplicação das Transformações" subtitle="Configuração manual para ajuste do modelo.">
1314
  <div className="row">
1315
  <label>Transformação de Y</label>
1316
  <select value={transformacaoY} onChange={(e) => setTransformacaoY(e.target.value)}>
 
1344
 
1345
  {fit ? (
1346
  <>
1347
+ <SectionBlock step="11" title="Gráficos de Dispersão (Variáveis Transformadas)" subtitle="Dispersão com variáveis já transformadas.">
1348
  <div className="row">
1349
  <label>Tipo de dispersão</label>
1350
  <select value={tipoDispersao} onChange={(e) => onTipoDispersaoChange(e.target.value)}>
 
1355
  <PlotFigure figure={fit.grafico_dispersao_modelo} title="Dispersão do modelo" forceHideLegend className="plot-stretch" />
1356
  </SectionBlock>
1357
 
1358
+ <SectionBlock step="12" title="Diagnóstico de Modelo" subtitle="Resumo diagnóstico e tabelas principais do ajuste.">
1359
  <div dangerouslySetInnerHTML={{ __html: fit.diagnosticos_html || '' }} />
1360
  <div className="two-col diagnostic-tables">
1361
  <div className="pane">
 
1369
  </div>
1370
  </SectionBlock>
1371
 
1372
+ <SectionBlock step="13" title="Gráficos de Diagnóstico do Modelo" subtitle="Obs x calc, resíduos, histograma, Cook e correlação.">
1373
  <div className="plot-grid-2-fixed">
1374
  <PlotFigure figure={fit.grafico_obs_calc} title="Obs x Calc" />
1375
  <PlotFigure figure={fit.grafico_residuos} title="Resíduos" />
 
1381
  </div>
1382
  </SectionBlock>
1383
 
1384
+ <SectionBlock step="14" title="Analisar Outliers" subtitle="Métricas para identificação de observações influentes.">
1385
  <DataTable table={fit.tabela_metricas} maxHeight={320} />
1386
  </SectionBlock>
1387
 
1388
+ <SectionBlock step="15" title="Exclusão ou Reinclusão de Outliers" subtitle="Filtre índices, revise e atualize o modelo.">
1389
  {outliersAnteriores.length > 0 && outliersHtml ? (
1390
  <div className="outliers-html-box" dangerouslySetInnerHTML={{ __html: outliersHtml }} />
1391
  ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1392
 
1393
+ <div className="outlier-group-card">
1394
+ <div className="outlier-subheader">Filtrar outliers</div>
1395
+ <div className="outlier-dica">Outliers = linhas que satisfazem qualquer filtro (lógica OR / união).</div>
1396
+ <div className="filtros-stack">
1397
+ {filtros.map((filtro, idx) => (
1398
+ <div key={`filtro-${idx}`} className="filtro-row-react">
1399
+ <select
1400
+ value={filtro.variavel}
1401
+ onChange={(e) => {
1402
+ const next = [...filtros]
1403
+ next[idx] = { ...next[idx], variavel: e.target.value }
1404
+ setFiltros(next)
1405
+ }}
1406
+ >
1407
+ {(fit.variaveis_filtro || []).map((varItem) => (
1408
+ <option key={`vf-${idx}-${varItem}`} value={varItem}>{varItem}</option>
1409
+ ))}
1410
+ </select>
1411
+ <select
1412
+ value={filtro.operador}
1413
+ onChange={(e) => {
1414
+ const next = [...filtros]
1415
+ next[idx] = { ...next[idx], operador: e.target.value }
1416
+ setFiltros(next)
1417
+ }}
1418
+ >
1419
+ {OPERADORES.map((op) => (
1420
+ <option key={`op-${idx}-${op}`} value={op}>{op}</option>
1421
+ ))}
1422
+ </select>
1423
+ <input
1424
+ type="number"
1425
+ value={filtro.valor}
1426
+ onChange={(e) => {
1427
+ const next = [...filtros]
1428
+ next[idx] = { ...next[idx], valor: Number(e.target.value) }
1429
+ setFiltros(next)
1430
+ }}
1431
+ />
1432
+ </div>
1433
+ ))}
1434
  </div>
1435
+ <div className="outlier-actions-row">
1436
+ <button onClick={onApplyOutlierFilters} disabled={loading}>Aplicar filtros</button>
 
1437
  </div>
1438
  </div>
1439
 
1440
+ <div className="outlier-divider"><span className="arrow">Próxima etapa</span></div>
1441
+ <div className="outlier-group-card outlier-group-card-secondary">
1442
+ <div className="outlier-subheader">Excluir ou reincluir índices</div>
1443
+ <div className="outlier-inputs-grid">
1444
+ <div className="outlier-input-card">
1445
+ <label>A excluir</label>
1446
+ <input type="text" value={outliersTexto} onChange={(e) => setOutliersTexto(e.target.value)} placeholder="ex: 5, 12, 30" />
1447
+ </div>
1448
+ <div className="outlier-input-card">
1449
+ <label>A reincluir</label>
1450
+ <input type="text" value={reincluirTexto} onChange={(e) => setReincluirTexto(e.target.value)} placeholder="ex: 5" />
1451
+ </div>
1452
+ </div>
1453
+ <div className="outlier-actions-row">
1454
+ <button onClick={onRestartIteration} disabled={loading} className="btn-reiniciar-iteracao">
1455
+ Atualizar Modelo (Excluir/Reincluir Outliers)
1456
+ </button>
1457
+ </div>
1458
  </div>
1459
  <div className="resumo-outliers-box">Iteração: {iteracao} | {resumoOutliers}</div>
1460
  <div className="resumo-outliers-box">Outliers anteriores: {joinSelection(outliersAnteriores) || '-'}</div>
1461
  </SectionBlock>
1462
 
1463
+ <SectionBlock step="16" title="Avaliação de Imóvel" subtitle="Cálculo individual e comparação entre avaliações.">
1464
+ <div className="avaliacao-grid" key={`avaliacao-grid-elab-${avaliacaoFormVersion}`}>
1465
  {camposAvaliacao.map((campo) => (
1466
  <div key={`aval-${campo.coluna}`} className="avaliacao-card">
1467
  <label>{campo.coluna}</label>
1468
+ {campo.tipo === 'dicotomica' ? (
1469
+ <select
1470
+ defaultValue={String(valoresAvaliacaoRef.current[campo.coluna] ?? '')}
1471
+ onChange={(e) => {
1472
+ valoresAvaliacaoRef.current[campo.coluna] = e.target.value
1473
+ }}
1474
+ >
1475
+ <option value="">Selecione</option>
1476
+ {(campo.opcoes || [0, 1]).map((opcao) => (
1477
+ <option key={`op-${campo.coluna}-${opcao}`} value={String(opcao)}>
1478
+ {opcao}
1479
+ </option>
1480
+ ))}
1481
+ </select>
1482
+ ) : (
1483
+ <input
1484
+ type="number"
1485
+ defaultValue={valoresAvaliacaoRef.current[campo.coluna] ?? ''}
1486
+ placeholder={campo.placeholder || ''}
1487
+ onChange={(e) => {
1488
+ valoresAvaliacaoRef.current[campo.coluna] = e.target.value
1489
+ }}
1490
+ />
1491
+ )}
1492
  </div>
1493
  ))}
1494
  </div>
 
1505
  <option key={`base-${choice}`} value={choice}>{choice}</option>
1506
  ))}
1507
  </select>
 
 
 
1508
  </div>
1509
+ <div
1510
+ className="avaliacao-resultado-box"
1511
+ onClick={onAvaliacaoResultadoClick}
1512
+ dangerouslySetInnerHTML={{ __html: resultadoAvaliacaoHtml }}
1513
+ />
1514
  </SectionBlock>
1515
 
1516
+ <SectionBlock step="17" title="Exportar Modelo" subtitle="Geração do pacote .dai e download da base tratada.">
1517
  <div className="row">
1518
  <label>Nome do arquivo (.dai)</label>
1519
  <input type="text" value={nomeArquivoExport} onChange={(e) => setNomeArquivoExport(e.target.value)} />
 
1533
  </>
1534
  ) : null}
1535
 
1536
+ <LoadingOverlay show={loading} label="Processando dados..." />
1537
  {error ? <div className="error-line">{error}</div> : null}
1538
  </div>
1539
  )
frontend/src/components/LoadingOverlay.jsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react'
2
+
3
+ export default function LoadingOverlay({ show, label = 'Processando...' }) {
4
+ const [elapsedSeconds, setElapsedSeconds] = useState(0)
5
+
6
+ useEffect(() => {
7
+ if (!show) {
8
+ setElapsedSeconds(0)
9
+ return undefined
10
+ }
11
+
12
+ const startedAt = Date.now()
13
+ const timer = window.setInterval(() => {
14
+ setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1000))
15
+ }, 1000)
16
+
17
+ return () => {
18
+ window.clearInterval(timer)
19
+ }
20
+ }, [show])
21
+
22
+ function formatElapsed(totalSeconds) {
23
+ const minutes = Math.floor(totalSeconds / 60)
24
+ const seconds = totalSeconds % 60
25
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
26
+ }
27
+
28
+ if (!show) return null
29
+
30
+ return (
31
+ <div className="loading-overlay" role="status" aria-live="polite" aria-label={label}>
32
+ <div className="loading-overlay-card">
33
+ <div className="loading-spinner" aria-hidden="true" />
34
+ <div className="loading-overlay-label">{label}</div>
35
+ <div className="loading-overlay-elapsed">Tempo: {formatElapsed(elapsedSeconds)}</div>
36
+ </div>
37
+ </div>
38
+ )
39
+ }
frontend/src/components/PlotFigure.jsx CHANGED
@@ -2,7 +2,7 @@ import React from 'react'
2
  import Plot from 'react-plotly.js'
3
  import Plotly from 'plotly.js-dist-min'
4
 
5
- export default function PlotFigure({ figure, title, forceHideLegend = false, className = '' }) {
6
  if (!figure) {
7
  return <div className="empty-box">Grafico indisponivel.</div>
8
  }
@@ -46,3 +46,5 @@ export default function PlotFigure({ figure, title, forceHideLegend = false, cla
46
  </div>
47
  )
48
  }
 
 
 
2
  import Plot from 'react-plotly.js'
3
  import Plotly from 'plotly.js-dist-min'
4
 
5
+ function PlotFigure({ figure, title, forceHideLegend = false, className = '' }) {
6
  if (!figure) {
7
  return <div className="empty-box">Grafico indisponivel.</div>
8
  }
 
46
  </div>
47
  )
48
  }
49
+
50
+ export default React.memo(PlotFigure)
frontend/src/components/VisualizacaoTab.jsx CHANGED
@@ -1,6 +1,7 @@
1
- import React, { useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
  import DataTable from './DataTable'
 
4
  import MapFrame from './MapFrame'
5
  import PlotFigure from './PlotFigure'
6
  import SectionBlock from './SectionBlock'
@@ -44,20 +45,14 @@ export default function VisualizacaoTab({ sessionId }) {
44
  const [mapaVar, setMapaVar] = useState('Visualização Padrão')
45
 
46
  const [camposAvaliacao, setCamposAvaliacao] = useState([])
47
- const [valoresAvaliacao, setValoresAvaliacao] = useState({})
 
48
  const [resultadoAvaliacaoHtml, setResultadoAvaliacaoHtml] = useState('')
49
  const [baseChoices, setBaseChoices] = useState([])
50
  const [baseValue, setBaseValue] = useState('')
51
- const [deleteAvalIndex, setDeleteAvalIndex] = useState('')
52
 
53
  const [activeInnerTab, setActiveInnerTab] = useState('mapa')
54
-
55
- const statusFluxo = [
56
- { label: 'Modelo carregado', done: Boolean(status) },
57
- { label: 'Dados exibidos', done: Boolean(dados) },
58
- { label: 'Gráficos prontos', done: Boolean(plotObsCalc) },
59
- { label: 'Avaliação ativa', done: camposAvaliacao.length > 0 },
60
- ]
61
 
62
  function resetConteudoVisualizacao() {
63
  setDados(null)
@@ -79,11 +74,11 @@ export default function VisualizacaoTab({ sessionId }) {
79
  setMapaVar('Visualização Padrão')
80
 
81
  setCamposAvaliacao([])
82
- setValoresAvaliacao({})
 
83
  setResultadoAvaliacaoHtml('')
84
  setBaseChoices([])
85
  setBaseValue('')
86
- setDeleteAvalIndex('')
87
 
88
  setActiveInnerTab('mapa')
89
  }
@@ -112,7 +107,8 @@ export default function VisualizacaoTab({ sessionId }) {
112
  ;(resp.campos_avaliacao || []).forEach((campo) => {
113
  values[campo.coluna] = ''
114
  })
115
- setValoresAvaliacao(values)
 
116
  setResultadoAvaliacaoHtml('')
117
  setBaseChoices([])
118
  setBaseValue('')
@@ -155,7 +151,7 @@ export default function VisualizacaoTab({ sessionId }) {
155
  async function onCalcularAvaliacao() {
156
  if (!sessionId) return
157
  await withBusy(async () => {
158
- const resp = await api.evaluationCalculateViz(sessionId, valoresAvaliacao, baseValue || null)
159
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
160
  setBaseChoices(resp.base_choices || [])
161
  setBaseValue(resp.base_value || '')
@@ -169,20 +165,63 @@ export default function VisualizacaoTab({ sessionId }) {
169
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
170
  setBaseChoices(resp.base_choices || [])
171
  setBaseValue(resp.base_value || '')
 
 
 
 
 
 
172
  })
173
  }
174
 
175
- async function onDeleteAvaliacao() {
176
  if (!sessionId) return
177
  await withBusy(async () => {
178
- const resp = await api.evaluationDeleteViz(sessionId, deleteAvalIndex, baseValue || null)
179
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
180
  setBaseChoices(resp.base_choices || [])
181
  setBaseValue(resp.base_value || '')
182
- setDeleteAvalIndex('')
183
  })
184
  }
185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  async function onBaseChange(value) {
187
  setBaseValue(value)
188
  if (!sessionId) return
@@ -202,14 +241,6 @@ export default function VisualizacaoTab({ sessionId }) {
202
 
203
  return (
204
  <div className="tab-content">
205
- <div className="status-strip">
206
- {statusFluxo.map((item) => (
207
- <span key={item.label} className={item.done ? 'status-pill done' : 'status-pill'}>
208
- {item.label}
209
- </span>
210
- ))}
211
- </div>
212
-
213
  <SectionBlock step="1" title="Carregar Modelo .dai" subtitle="Carregue o arquivo e o conteúdo será exibido automaticamente.">
214
  <div className="row">
215
  <input type="file" onChange={(e) => setUploadedFile(e.target.files?.[0] ?? null)} />
@@ -297,16 +328,34 @@ export default function VisualizacaoTab({ sessionId }) {
297
 
298
  {activeInnerTab === 'avaliacao' ? (
299
  <>
300
- <div className="avaliacao-grid">
301
  {camposAvaliacao.map((campo) => (
302
  <div key={`campo-${campo.coluna}`} className="avaliacao-card">
303
  <label>{campo.coluna}</label>
304
- <input
305
- type="number"
306
- value={valoresAvaliacao[campo.coluna] ?? ''}
307
- placeholder={campo.placeholder || ''}
308
- onChange={(e) => setValoresAvaliacao((prev) => ({ ...prev, [campo.coluna]: e.target.value }))}
309
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  </div>
311
  ))}
312
  </div>
@@ -325,13 +374,13 @@ export default function VisualizacaoTab({ sessionId }) {
325
  <option key={`base-${choice}`} value={choice}>{choice}</option>
326
  ))}
327
  </select>
328
-
329
- <label>Excluir avaliação #</label>
330
- <input type="text" value={deleteAvalIndex} onChange={(e) => setDeleteAvalIndex(e.target.value)} />
331
- <button onClick={onDeleteAvaliacao} disabled={loading}>Excluir</button>
332
  </div>
333
 
334
- <div className="avaliacao-resultado-box" dangerouslySetInnerHTML={{ __html: resultadoAvaliacaoHtml }} />
 
 
 
 
335
  </>
336
  ) : null}
337
 
@@ -340,11 +389,9 @@ export default function VisualizacaoTab({ sessionId }) {
340
  ) : null}
341
  </div>
342
  </SectionBlock>
343
- ) : (
344
- <div className="empty-box">Carregue um modelo para navegar nas abas internas da Visualização/Avaliação.</div>
345
- )}
346
 
347
- {loading ? <div className="status-line">Processando...</div> : null}
348
  {error ? <div className="error-line">{error}</div> : null}
349
  </div>
350
  )
 
1
+ import React, { useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
  import DataTable from './DataTable'
4
+ import LoadingOverlay from './LoadingOverlay'
5
  import MapFrame from './MapFrame'
6
  import PlotFigure from './PlotFigure'
7
  import SectionBlock from './SectionBlock'
 
45
  const [mapaVar, setMapaVar] = useState('Visualização Padrão')
46
 
47
  const [camposAvaliacao, setCamposAvaliacao] = useState([])
48
+ const valoresAvaliacaoRef = useRef({})
49
+ const [avaliacaoFormVersion, setAvaliacaoFormVersion] = useState(0)
50
  const [resultadoAvaliacaoHtml, setResultadoAvaliacaoHtml] = useState('')
51
  const [baseChoices, setBaseChoices] = useState([])
52
  const [baseValue, setBaseValue] = useState('')
 
53
 
54
  const [activeInnerTab, setActiveInnerTab] = useState('mapa')
55
+ const deleteConfirmTimersRef = useRef({})
 
 
 
 
 
 
56
 
57
  function resetConteudoVisualizacao() {
58
  setDados(null)
 
74
  setMapaVar('Visualização Padrão')
75
 
76
  setCamposAvaliacao([])
77
+ valoresAvaliacaoRef.current = {}
78
+ setAvaliacaoFormVersion((prev) => prev + 1)
79
  setResultadoAvaliacaoHtml('')
80
  setBaseChoices([])
81
  setBaseValue('')
 
82
 
83
  setActiveInnerTab('mapa')
84
  }
 
107
  ;(resp.campos_avaliacao || []).forEach((campo) => {
108
  values[campo.coluna] = ''
109
  })
110
+ valoresAvaliacaoRef.current = values
111
+ setAvaliacaoFormVersion((prev) => prev + 1)
112
  setResultadoAvaliacaoHtml('')
113
  setBaseChoices([])
114
  setBaseValue('')
 
151
  async function onCalcularAvaliacao() {
152
  if (!sessionId) return
153
  await withBusy(async () => {
154
+ const resp = await api.evaluationCalculateViz(sessionId, valoresAvaliacaoRef.current, baseValue || null)
155
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
156
  setBaseChoices(resp.base_choices || [])
157
  setBaseValue(resp.base_value || '')
 
165
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
166
  setBaseChoices(resp.base_choices || [])
167
  setBaseValue(resp.base_value || '')
168
+ const limpo = {}
169
+ camposAvaliacao.forEach((campo) => {
170
+ limpo[campo.coluna] = ''
171
+ })
172
+ valoresAvaliacaoRef.current = limpo
173
+ setAvaliacaoFormVersion((prev) => prev + 1)
174
  })
175
  }
176
 
177
+ async function onDeleteAvaliacao(indice) {
178
  if (!sessionId) return
179
  await withBusy(async () => {
180
+ const resp = await api.evaluationDeleteViz(sessionId, indice ? String(indice) : null, baseValue || null)
181
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
182
  setBaseChoices(resp.base_choices || [])
183
  setBaseValue(resp.base_value || '')
 
184
  })
185
  }
186
 
187
+ function onAvaliacaoResultadoClick(event) {
188
+ const ativarExclusao = event.target.closest('[data-avaliacao-delete-arm]')
189
+ if (ativarExclusao) {
190
+ const indice = ativarExclusao.getAttribute('data-avaliacao-delete-index')
191
+ if (!indice) return
192
+
193
+ const cell = ativarExclusao.closest('td')
194
+ const botaoConfirmar = cell?.querySelector(`[data-avaliacao-delete-confirm="${indice}"]`)
195
+ if (!botaoConfirmar) return
196
+
197
+ ativarExclusao.style.display = 'none'
198
+ botaoConfirmar.style.display = 'inline-block'
199
+
200
+ const timerKey = String(indice)
201
+ if (deleteConfirmTimersRef.current[timerKey]) {
202
+ clearTimeout(deleteConfirmTimersRef.current[timerKey])
203
+ }
204
+ deleteConfirmTimersRef.current[timerKey] = window.setTimeout(() => {
205
+ botaoConfirmar.style.display = 'none'
206
+ ativarExclusao.style.display = 'inline'
207
+ delete deleteConfirmTimersRef.current[timerKey]
208
+ }, 10000)
209
+ return
210
+ }
211
+
212
+ const confirmarExclusao = event.target.closest('[data-avaliacao-delete-confirm]')
213
+ if (!confirmarExclusao) return
214
+ const indice = confirmarExclusao.getAttribute('data-avaliacao-delete-confirm')
215
+ if (!indice) return
216
+
217
+ const timerKey = String(indice)
218
+ if (deleteConfirmTimersRef.current[timerKey]) {
219
+ clearTimeout(deleteConfirmTimersRef.current[timerKey])
220
+ delete deleteConfirmTimersRef.current[timerKey]
221
+ }
222
+ onDeleteAvaliacao(indice)
223
+ }
224
+
225
  async function onBaseChange(value) {
226
  setBaseValue(value)
227
  if (!sessionId) return
 
241
 
242
  return (
243
  <div className="tab-content">
 
 
 
 
 
 
 
 
244
  <SectionBlock step="1" title="Carregar Modelo .dai" subtitle="Carregue o arquivo e o conteúdo será exibido automaticamente.">
245
  <div className="row">
246
  <input type="file" onChange={(e) => setUploadedFile(e.target.files?.[0] ?? null)} />
 
328
 
329
  {activeInnerTab === 'avaliacao' ? (
330
  <>
331
+ <div className="avaliacao-grid" key={`avaliacao-grid-viz-${avaliacaoFormVersion}`}>
332
  {camposAvaliacao.map((campo) => (
333
  <div key={`campo-${campo.coluna}`} className="avaliacao-card">
334
  <label>{campo.coluna}</label>
335
+ {campo.tipo === 'dicotomica' ? (
336
+ <select
337
+ defaultValue={String(valoresAvaliacaoRef.current[campo.coluna] ?? '')}
338
+ onChange={(e) => {
339
+ valoresAvaliacaoRef.current[campo.coluna] = e.target.value
340
+ }}
341
+ >
342
+ <option value="">Selecione</option>
343
+ {(campo.opcoes || [0, 1]).map((opcao) => (
344
+ <option key={`op-viz-${campo.coluna}-${opcao}`} value={String(opcao)}>
345
+ {opcao}
346
+ </option>
347
+ ))}
348
+ </select>
349
+ ) : (
350
+ <input
351
+ type="number"
352
+ defaultValue={valoresAvaliacaoRef.current[campo.coluna] ?? ''}
353
+ placeholder={campo.placeholder || ''}
354
+ onChange={(e) => {
355
+ valoresAvaliacaoRef.current[campo.coluna] = e.target.value
356
+ }}
357
+ />
358
+ )}
359
  </div>
360
  ))}
361
  </div>
 
374
  <option key={`base-${choice}`} value={choice}>{choice}</option>
375
  ))}
376
  </select>
 
 
 
 
377
  </div>
378
 
379
+ <div
380
+ className="avaliacao-resultado-box"
381
+ onClick={onAvaliacaoResultadoClick}
382
+ dangerouslySetInnerHTML={{ __html: resultadoAvaliacaoHtml }}
383
+ />
384
  </>
385
  ) : null}
386
 
 
389
  ) : null}
390
  </div>
391
  </SectionBlock>
392
+ ) : null}
 
 
393
 
394
+ <LoadingOverlay show={loading} label="Processando dados..." />
395
  {error ? <div className="error-line">{error}</div> : null}
396
  </div>
397
  )
frontend/src/styles.css CHANGED
@@ -56,7 +56,7 @@ textarea {
56
  margin: 18px auto 40px;
57
  display: flex;
58
  flex-direction: column;
59
- gap: 14px;
60
  position: relative;
61
  }
62
 
@@ -181,8 +181,8 @@ textarea {
181
  .inner-tabs {
182
  display: flex;
183
  flex-wrap: wrap;
184
- gap: 8px;
185
- margin-bottom: 10px;
186
  }
187
 
188
  .inner-tab-pill {
@@ -210,13 +210,17 @@ textarea {
210
  border: 1px solid #dbe5ef;
211
  border-radius: 12px;
212
  background: #fff;
213
- padding: 10px;
214
  }
215
 
216
  .tab-content {
217
  display: flex;
218
  flex-direction: column;
219
- gap: 12px;
 
 
 
 
220
  }
221
 
222
  .status-strip {
@@ -246,10 +250,12 @@ textarea {
246
  }
247
 
248
  .workflow-section {
249
- border: 1px solid var(--border);
250
  border-radius: var(--radius-lg);
251
  background: var(--bg-2);
252
- box-shadow: var(--shadow-sm);
 
 
253
  animation: sectionIn 0.35s ease both;
254
  animation-delay: calc(var(--section-order, 1) * 25ms);
255
  }
@@ -258,8 +264,8 @@ textarea {
258
  display: flex;
259
  gap: 12px;
260
  align-items: center;
261
- padding: 14px 16px 12px;
262
- border-bottom: 1px solid var(--border-soft);
263
  background: linear-gradient(180deg, #fff 0%, #fbfdff 100%);
264
  }
265
 
@@ -301,7 +307,71 @@ textarea {
301
  }
302
 
303
  .section-body {
304
- padding: 14px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  }
306
 
307
  .placeholder-section .empty-box {
@@ -312,19 +382,19 @@ textarea {
312
  display: flex;
313
  align-items: center;
314
  flex-wrap: wrap;
315
- gap: 10px;
316
- margin-bottom: 10px;
317
  }
318
 
319
  .row.compact {
320
  margin: 0;
321
- gap: 6px;
322
  }
323
 
324
  .row-wrap {
325
  display: flex;
326
  flex-wrap: wrap;
327
- gap: 10px;
328
  }
329
 
330
  .avaliacao-actions-row {
@@ -402,8 +472,8 @@ button:disabled {
402
  border-left: 3px solid var(--accent);
403
  border-radius: 12px;
404
  background: #fcfdff;
405
- padding: 11px 12px;
406
- margin-bottom: 10px;
407
  }
408
 
409
  .subpanel.warning {
@@ -420,13 +490,40 @@ button:disabled {
420
 
421
  .section1-groups {
422
  display: grid;
423
- gap: 10px;
424
  }
425
 
426
  .section1-group {
427
  margin: 0;
428
  }
429
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  .section1-empty-hint {
431
  color: #5b7288;
432
  font-size: 0.86rem;
@@ -455,18 +552,37 @@ button:disabled {
455
  letter-spacing: 0.04em;
456
  }
457
 
458
- .modelo-cabecalho-grid {
 
 
 
 
 
 
 
 
459
  display: grid;
460
- grid-template-columns: minmax(300px, 1fr) minmax(380px, 1.2fr);
461
- gap: 10px;
462
- margin-top: 8px;
 
 
 
 
 
 
 
 
 
 
 
463
  }
464
 
465
  .elaborador-badge {
466
  padding: 10px 12px;
467
  border-radius: 11px;
468
  border: 1px solid #cddfed;
469
- border-left: 4px solid var(--accent);
470
  background: linear-gradient(180deg, #f7fbff 0%, #eef5fb 100%);
471
  }
472
 
@@ -499,14 +615,16 @@ button:disabled {
499
  }
500
 
501
  .variavel-badge-line {
502
- display: flex;
503
- gap: 8px;
 
504
  align-items: flex-start;
505
- margin-top: 6px;
506
  }
507
 
508
  .variavel-badge-label {
509
- min-width: 96px;
 
510
  color: #556c82;
511
  font-size: 0.83rem;
512
  font-weight: 800;
@@ -517,7 +635,8 @@ button:disabled {
517
  .variavel-chip-wrap {
518
  display: flex;
519
  flex-wrap: wrap;
520
- gap: 5px;
 
521
  }
522
 
523
  .variavel-chip {
@@ -531,6 +650,9 @@ button:disabled {
531
  color: #34485e;
532
  font-size: 0.81rem;
533
  font-weight: 700;
 
 
 
534
  }
535
 
536
  .variavel-chip-y {
@@ -675,7 +797,7 @@ button:disabled {
675
  }
676
 
677
  .compact-option-group {
678
- margin: 6px 0 8px;
679
  }
680
 
681
  .compact-option-group h4 {
@@ -688,7 +810,7 @@ button:disabled {
688
  .checkbox-inline-wrap {
689
  display: flex;
690
  flex-wrap: wrap;
691
- gap: 5px 7px;
692
  }
693
 
694
  .checkbox-inline-wrap-tools {
@@ -729,8 +851,8 @@ button:disabled {
729
  .avaliacao-grid {
730
  display: grid;
731
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
732
- gap: 8px;
733
- margin-bottom: 10px;
734
  }
735
 
736
  .transform-card,
@@ -752,8 +874,8 @@ button:disabled {
752
  .transform-suggestions-grid {
753
  display: grid;
754
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
755
- gap: 10px;
756
- margin-top: 8px;
757
  }
758
 
759
  .transform-suggestion-card {
@@ -876,13 +998,13 @@ button:disabled {
876
  .plot-grid-2 {
877
  display: grid;
878
  grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
879
- gap: 10px;
880
  }
881
 
882
  .plot-grid-2-fixed {
883
  display: grid;
884
  grid-template-columns: repeat(2, minmax(0, 1fr));
885
- gap: 10px;
886
  }
887
 
888
  .plot-card {
@@ -905,7 +1027,7 @@ button:disabled {
905
  }
906
 
907
  .plot-full-width {
908
- margin-top: 10px;
909
  }
910
 
911
  .plot-card.plot-correlation-card {
@@ -914,7 +1036,7 @@ button:disabled {
914
 
915
  .filtros-stack {
916
  display: grid;
917
- gap: 8px;
918
  }
919
 
920
  .filtro-row-react {
@@ -951,6 +1073,18 @@ button:disabled {
951
  margin-top: 0;
952
  }
953
 
 
 
 
 
 
 
 
 
 
 
 
 
954
  .outlier-dica {
955
  color: #657a90;
956
  font-size: 0.84rem;
@@ -964,16 +1098,16 @@ button:disabled {
964
  .outlier-actions-row {
965
  display: flex;
966
  flex-wrap: wrap;
967
- gap: 8px;
968
- margin: 10px 0;
969
  }
970
 
971
  .outlier-divider {
972
  display: flex;
973
  align-items: center;
974
  gap: 8px;
975
- margin: 10px 0;
976
- color: #b4c1cf;
977
  }
978
 
979
  .outlier-divider::before,
@@ -984,14 +1118,21 @@ button:disabled {
984
  }
985
 
986
  .outlier-divider .arrow {
987
- color: var(--accent);
988
  font-weight: 800;
 
 
 
 
 
 
 
989
  }
990
 
991
  .outlier-inputs-grid {
992
  display: grid;
993
  grid-template-columns: repeat(2, minmax(0, 1fr));
994
- gap: 10px;
995
  }
996
 
997
  .outlier-input-card {
@@ -1069,9 +1210,64 @@ button:disabled {
1069
 
1070
  .geo-correcoes {
1071
  display: grid;
1072
- grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1073
  gap: 6px;
1074
- margin: 8px 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1075
  }
1076
 
1077
  .status-line {
@@ -1096,6 +1292,65 @@ button:disabled {
1096
  margin-top: 8px;
1097
  }
1098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1099
  /* HTML gerado pelo backend (formatadores) */
1100
  .dai-card,
1101
  .diagnosticos-container,
@@ -1109,8 +1364,8 @@ button:disabled {
1109
 
1110
  .section-title-orange,
1111
  .section-title-orange-solid {
1112
- margin: 10px 0 8px;
1113
- padding: 7px 10px;
1114
  border-left: 4px solid var(--accent);
1115
  background: var(--accent-soft);
1116
  border-radius: 0 8px 8px 0;
@@ -1249,15 +1504,17 @@ button:disabled {
1249
  }
1250
 
1251
  .micro-grid {
1252
- display: grid;
1253
- grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
1254
- gap: 10px;
1255
- margin-top: 8px;
1256
  }
1257
 
1258
  .micro-card {
1259
  margin: 0;
1260
  padding: 10px 11px;
 
 
1261
  }
1262
 
1263
  .micro-card.micro-ok {
@@ -1271,17 +1528,20 @@ button:disabled {
1271
  .micro-card-head {
1272
  display: flex;
1273
  align-items: center;
1274
- justify-content: space-between;
1275
- gap: 8px;
1276
- margin-bottom: 8px;
1277
  }
1278
 
1279
  .micro-title {
1280
  font-weight: 800;
 
 
1281
  }
1282
 
1283
  .micro-status {
1284
  font-size: 1.05rem;
 
1285
  }
1286
 
1287
  .micro-msg-grid {
@@ -1290,10 +1550,28 @@ button:disabled {
1290
  gap: 4px 12px;
1291
  }
1292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1293
  .micro-msg {
1294
  color: #495f76;
1295
  font-size: 0.82rem;
1296
  line-height: 1.35;
 
1297
  }
1298
 
1299
  ::-webkit-scrollbar {
@@ -1321,6 +1599,12 @@ button:disabled {
1321
  }
1322
  }
1323
 
 
 
 
 
 
 
1324
  @media (max-width: 1150px) {
1325
  .tabs {
1326
  grid-template-columns: 1fr;
@@ -1338,13 +1622,22 @@ button:disabled {
1338
  grid-template-columns: 1fr;
1339
  }
1340
 
1341
- .modelo-cabecalho-grid {
1342
  grid-template-columns: 1fr;
1343
  }
1344
 
 
 
 
 
 
1345
  .filtro-row-react {
1346
  grid-template-columns: 1.2fr 110px minmax(110px, 0.8fr);
1347
  }
 
 
 
 
1348
  }
1349
 
1350
  @media (max-width: 760px) {
@@ -1387,12 +1680,21 @@ button:disabled {
1387
  min-height: 460px;
1388
  }
1389
 
1390
- .modelo-cabecalho-grid,
1391
  .outlier-inputs-grid,
1392
  .filtro-row-react {
1393
  grid-template-columns: 1fr;
1394
  }
1395
 
 
 
 
 
 
 
 
 
 
1396
  .micro-summary-grid {
1397
  grid-template-columns: 1fr;
1398
  }
@@ -1408,4 +1710,8 @@ button:disabled {
1408
  .micro-msg-grid {
1409
  grid-template-columns: 1fr;
1410
  }
 
 
 
 
1411
  }
 
56
  margin: 18px auto 40px;
57
  display: flex;
58
  flex-direction: column;
59
+ gap: 16px;
60
  position: relative;
61
  }
62
 
 
181
  .inner-tabs {
182
  display: flex;
183
  flex-wrap: wrap;
184
+ gap: 10px;
185
+ margin-bottom: 12px;
186
  }
187
 
188
  .inner-tab-pill {
 
210
  border: 1px solid #dbe5ef;
211
  border-radius: 12px;
212
  background: #fff;
213
+ padding: 14px;
214
  }
215
 
216
  .tab-content {
217
  display: flex;
218
  flex-direction: column;
219
+ gap: 24px;
220
+ }
221
+
222
+ .tab-pane[hidden] {
223
+ display: none !important;
224
  }
225
 
226
  .status-strip {
 
250
  }
251
 
252
  .workflow-section {
253
+ border: 1px solid #c9d6e3;
254
  border-radius: var(--radius-lg);
255
  background: var(--bg-2);
256
+ box-shadow:
257
+ 0 6px 18px rgba(20, 28, 36, 0.08),
258
+ inset 0 0 0 1px #edf3f9;
259
  animation: sectionIn 0.35s ease both;
260
  animation-delay: calc(var(--section-order, 1) * 25ms);
261
  }
 
264
  display: flex;
265
  gap: 12px;
266
  align-items: center;
267
+ padding: 15px 18px 13px;
268
+ border-bottom: 1px solid #dde7f1;
269
  background: linear-gradient(180deg, #fff 0%, #fbfdff 100%);
270
  }
271
 
 
307
  }
308
 
309
  .section-body {
310
+ padding: 18px;
311
+ }
312
+
313
+ .dados-visualizacao-groups {
314
+ display: grid;
315
+ gap: 16px;
316
+ }
317
+
318
+ .dados-visualizacao-group {
319
+ margin: 0;
320
+ }
321
+
322
+ .dados-outliers-resumo {
323
+ margin-bottom: 14px;
324
+ }
325
+
326
+ .dados-mapa-details {
327
+ display: block;
328
+ }
329
+
330
+ .dados-mapa-details > summary {
331
+ list-style: none;
332
+ display: flex;
333
+ align-items: center;
334
+ gap: 8px;
335
+ font-family: 'Sora', sans-serif;
336
+ font-size: 0.92rem;
337
+ color: #2d4157;
338
+ cursor: pointer;
339
+ user-select: none;
340
+ margin: -2px 0 10px;
341
+ }
342
+
343
+ .dados-mapa-details > summary::-webkit-details-marker {
344
+ display: none;
345
+ }
346
+
347
+ .dados-mapa-details > summary::before {
348
+ content: '▾';
349
+ color: #5f7489;
350
+ font-size: 0.8rem;
351
+ transition: transform 0.15s ease;
352
+ }
353
+
354
+ .dados-mapa-details:not([open]) > summary::before {
355
+ transform: rotate(-90deg);
356
+ }
357
+
358
+ .dados-mapa-controls {
359
+ display: flex;
360
+ align-items: center;
361
+ flex-wrap: wrap;
362
+ column-gap: 14px;
363
+ row-gap: 8px;
364
+ margin-bottom: 18px;
365
+ }
366
+
367
+ .dados-mapa-controls label {
368
+ margin-right: 0;
369
+ min-width: 118px;
370
+ }
371
+
372
+ .dados-mapa-controls + .map-frame,
373
+ .dados-mapa-controls + .empty-box {
374
+ margin-top: 6px;
375
  }
376
 
377
  .placeholder-section .empty-box {
 
382
  display: flex;
383
  align-items: center;
384
  flex-wrap: wrap;
385
+ gap: 12px;
386
+ margin-bottom: 12px;
387
  }
388
 
389
  .row.compact {
390
  margin: 0;
391
+ gap: 8px;
392
  }
393
 
394
  .row-wrap {
395
  display: flex;
396
  flex-wrap: wrap;
397
+ gap: 12px;
398
  }
399
 
400
  .avaliacao-actions-row {
 
472
  border-left: 3px solid var(--accent);
473
  border-radius: 12px;
474
  background: #fcfdff;
475
+ padding: 14px 15px;
476
+ margin-bottom: 12px;
477
  }
478
 
479
  .subpanel.warning {
 
490
 
491
  .section1-groups {
492
  display: grid;
493
+ gap: 18px;
494
  }
495
 
496
  .section1-group {
497
  margin: 0;
498
  }
499
 
500
+ .section1-group.subpanel {
501
+ border-left: 1px solid var(--border-soft);
502
+ background: #fff;
503
+ }
504
+
505
+ .coords-section-groups {
506
+ display: grid;
507
+ gap: 14px;
508
+ }
509
+
510
+ .coords-section-group {
511
+ margin: 0;
512
+ border-left: 1px solid var(--border-soft);
513
+ background: #fff;
514
+ }
515
+
516
+ .coords-section-alert {
517
+ border-left-color: #e5be8a;
518
+ background: #fffaf2;
519
+ }
520
+
521
+ .coords-result-group h5,
522
+ .coords-falhas-group h5,
523
+ .coords-correcoes-group h5 {
524
+ margin-bottom: 10px;
525
+ }
526
+
527
  .section1-empty-hint {
528
  color: #5b7288;
529
  font-size: 0.86rem;
 
552
  letter-spacing: 0.04em;
553
  }
554
 
555
+ .modelo-info-card {
556
+ margin-top: 10px;
557
+ border: 1px solid #d1deea;
558
+ border-radius: 12px;
559
+ background: linear-gradient(180deg, #fbfdff 0%, #f6faff 100%);
560
+ overflow: hidden;
561
+ }
562
+
563
+ .modelo-info-split {
564
  display: grid;
565
+ grid-template-columns: minmax(260px, 0.9fr) minmax(0, 1.4fr);
566
+ }
567
+
568
+ .modelo-info-col {
569
+ min-width: 0;
570
+ padding: 12px 14px;
571
+ }
572
+
573
+ .modelo-info-col + .modelo-info-col {
574
+ border-left: 1px solid #dde7f1;
575
+ }
576
+
577
+ .modelo-info-col-vars {
578
+ background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
579
  }
580
 
581
  .elaborador-badge {
582
  padding: 10px 12px;
583
  border-radius: 11px;
584
  border: 1px solid #cddfed;
585
+ border-left: 1px solid #cddfed;
586
  background: linear-gradient(180deg, #f7fbff 0%, #eef5fb 100%);
587
  }
588
 
 
615
  }
616
 
617
  .variavel-badge-line {
618
+ display: grid;
619
+ grid-template-columns: 118px minmax(0, 1fr);
620
+ gap: 10px;
621
  align-items: flex-start;
622
+ margin-top: 8px;
623
  }
624
 
625
  .variavel-badge-label {
626
+ min-width: 0;
627
+ padding-top: 3px;
628
  color: #556c82;
629
  font-size: 0.83rem;
630
  font-weight: 800;
 
635
  .variavel-chip-wrap {
636
  display: flex;
637
  flex-wrap: wrap;
638
+ gap: 6px;
639
+ min-width: 0;
640
  }
641
 
642
  .variavel-chip {
 
650
  color: #34485e;
651
  font-size: 0.81rem;
652
  font-weight: 700;
653
+ max-width: 100%;
654
+ white-space: normal;
655
+ word-break: break-word;
656
  }
657
 
658
  .variavel-chip-y {
 
797
  }
798
 
799
  .compact-option-group {
800
+ margin: 10px 0 14px;
801
  }
802
 
803
  .compact-option-group h4 {
 
810
  .checkbox-inline-wrap {
811
  display: flex;
812
  flex-wrap: wrap;
813
+ gap: 8px 10px;
814
  }
815
 
816
  .checkbox-inline-wrap-tools {
 
851
  .avaliacao-grid {
852
  display: grid;
853
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
854
+ gap: 10px;
855
+ margin-bottom: 12px;
856
  }
857
 
858
  .transform-card,
 
874
  .transform-suggestions-grid {
875
  display: grid;
876
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
877
+ gap: 12px;
878
+ margin-top: 10px;
879
  }
880
 
881
  .transform-suggestion-card {
 
998
  .plot-grid-2 {
999
  display: grid;
1000
  grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
1001
+ gap: 12px;
1002
  }
1003
 
1004
  .plot-grid-2-fixed {
1005
  display: grid;
1006
  grid-template-columns: repeat(2, minmax(0, 1fr));
1007
+ gap: 12px;
1008
  }
1009
 
1010
  .plot-card {
 
1027
  }
1028
 
1029
  .plot-full-width {
1030
+ margin-top: 12px;
1031
  }
1032
 
1033
  .plot-card.plot-correlation-card {
 
1036
 
1037
  .filtros-stack {
1038
  display: grid;
1039
+ gap: 10px;
1040
  }
1041
 
1042
  .filtro-row-react {
 
1073
  margin-top: 0;
1074
  }
1075
 
1076
+ .outlier-group-card {
1077
+ border: 1px solid #dbe6f1;
1078
+ border-radius: 12px;
1079
+ background: #fbfdff;
1080
+ padding: 12px 12px 10px;
1081
+ }
1082
+
1083
+ .outlier-group-card-secondary {
1084
+ border-color: #d3e0ec;
1085
+ background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
1086
+ }
1087
+
1088
  .outlier-dica {
1089
  color: #657a90;
1090
  font-size: 0.84rem;
 
1098
  .outlier-actions-row {
1099
  display: flex;
1100
  flex-wrap: wrap;
1101
+ gap: 12px;
1102
+ margin: 12px 0;
1103
  }
1104
 
1105
  .outlier-divider {
1106
  display: flex;
1107
  align-items: center;
1108
  gap: 8px;
1109
+ margin: 14px 0;
1110
+ color: #8ea1b5;
1111
  }
1112
 
1113
  .outlier-divider::before,
 
1118
  }
1119
 
1120
  .outlier-divider .arrow {
1121
+ color: #4f657b;
1122
  font-weight: 800;
1123
+ text-transform: uppercase;
1124
+ letter-spacing: 0.04em;
1125
+ font-size: 0.74rem;
1126
+ border: 1px solid #cfdae5;
1127
+ border-radius: 999px;
1128
+ background: #f3f7fb;
1129
+ padding: 3px 10px;
1130
  }
1131
 
1132
  .outlier-inputs-grid {
1133
  display: grid;
1134
  grid-template-columns: repeat(2, minmax(0, 1fr));
1135
+ gap: 12px;
1136
  }
1137
 
1138
  .outlier-input-card {
 
1210
 
1211
  .geo-correcoes {
1212
  display: grid;
1213
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
1214
+ gap: 10px;
1215
+ margin: 10px 0;
1216
+ }
1217
+
1218
+ .geo-correcoes-actions {
1219
+ gap: 10px;
1220
+ margin-bottom: 10px;
1221
+ }
1222
+
1223
+ .geo-correcoes-apply-row {
1224
+ margin-top: 4px;
1225
+ margin-bottom: 0;
1226
+ }
1227
+
1228
+ .geo-correcao-item {
1229
+ display: grid;
1230
  gap: 6px;
1231
+ border: 1px solid #dce7f2;
1232
+ border-radius: 10px;
1233
+ background: #fbfdff;
1234
+ padding: 8px 9px;
1235
+ }
1236
+
1237
+ .geo-correcao-linha {
1238
+ font-weight: 800;
1239
+ font-size: 0.82rem;
1240
+ color: #46617a;
1241
+ }
1242
+
1243
+ .geo-correcao-sugestoes {
1244
+ color: #607990;
1245
+ font-size: 0.78rem;
1246
+ line-height: 1.3;
1247
+ }
1248
+
1249
+ .geo-auto-toggle {
1250
+ display: inline-flex;
1251
+ align-items: center;
1252
+ gap: 7px;
1253
+ border: 1px solid #d9e5f0;
1254
+ border-radius: 9px;
1255
+ background: #f7fbff;
1256
+ padding: 7px 10px;
1257
+ min-height: 38px;
1258
+ }
1259
+
1260
+ .geo-auto-toggle input[type='checkbox'] {
1261
+ margin: 0;
1262
+ width: 16px;
1263
+ height: 16px;
1264
+ accent-color: #1f7a42;
1265
+ }
1266
+
1267
+ .geo-auto-toggle span {
1268
+ font-weight: 700;
1269
+ color: #38516a;
1270
+ font-size: 0.84rem;
1271
  }
1272
 
1273
  .status-line {
 
1292
  margin-top: 8px;
1293
  }
1294
 
1295
+ .loading-overlay {
1296
+ position: fixed;
1297
+ inset: 0;
1298
+ z-index: 2500;
1299
+ display: flex;
1300
+ align-items: center;
1301
+ justify-content: center;
1302
+ background: rgba(244, 248, 252, 0.62);
1303
+ backdrop-filter: blur(2px);
1304
+ }
1305
+
1306
+ .loading-overlay-card {
1307
+ min-width: 220px;
1308
+ padding: 16px 20px;
1309
+ border-radius: 14px;
1310
+ border: 1px solid #d6e2ee;
1311
+ background: linear-gradient(180deg, #ffffff 0%, #f6faff 100%);
1312
+ box-shadow: 0 14px 30px rgba(18, 38, 58, 0.14);
1313
+ display: flex;
1314
+ flex-direction: column;
1315
+ align-items: center;
1316
+ gap: 10px;
1317
+ }
1318
+
1319
+ .loading-spinner {
1320
+ width: 54px;
1321
+ height: 54px;
1322
+ border-radius: 50%;
1323
+ border: 4px solid #d9e6f2;
1324
+ border-top-color: var(--accent);
1325
+ border-right-color: var(--accent-strong);
1326
+ animation: spinLoader 0.85s linear infinite;
1327
+ }
1328
+
1329
+ .loading-overlay-label {
1330
+ font-family: 'Sora', sans-serif;
1331
+ font-size: 0.9rem;
1332
+ color: #3c5369;
1333
+ letter-spacing: 0.01em;
1334
+ }
1335
+
1336
+ .loading-overlay-elapsed {
1337
+ font-family: 'JetBrains Mono', monospace;
1338
+ font-size: 0.78rem;
1339
+ color: #5a7187;
1340
+ border: 1px solid #dbe6f1;
1341
+ border-radius: 999px;
1342
+ padding: 3px 9px;
1343
+ background: #f8fbff;
1344
+ }
1345
+
1346
+ .btn-gerar-mapa {
1347
+ --btn-bg-start: #269065;
1348
+ --btn-bg-end: #1d7452;
1349
+ --btn-border: #175d41;
1350
+ --btn-shadow-soft: rgba(38, 144, 101, 0.2);
1351
+ --btn-shadow-strong: rgba(38, 144, 101, 0.28);
1352
+ }
1353
+
1354
  /* HTML gerado pelo backend (formatadores) */
1355
  .dai-card,
1356
  .diagnosticos-container,
 
1364
 
1365
  .section-title-orange,
1366
  .section-title-orange-solid {
1367
+ margin: 14px 0 14px;
1368
+ padding: 8px 11px;
1369
  border-left: 4px solid var(--accent);
1370
  background: var(--accent-soft);
1371
  border-radius: 0 8px 8px 0;
 
1504
  }
1505
 
1506
  .micro-grid {
1507
+ display: flex;
1508
+ flex-wrap: wrap;
1509
+ gap: 16px;
1510
+ margin-top: 12px;
1511
  }
1512
 
1513
  .micro-card {
1514
  margin: 0;
1515
  padding: 10px 11px;
1516
+ flex: 0 1 300px;
1517
+ max-width: 360px;
1518
  }
1519
 
1520
  .micro-card.micro-ok {
 
1528
  .micro-card-head {
1529
  display: flex;
1530
  align-items: center;
1531
+ justify-content: flex-start;
1532
+ gap: 6px;
1533
+ margin-bottom: 9px;
1534
  }
1535
 
1536
  .micro-title {
1537
  font-weight: 800;
1538
+ max-width: 100%;
1539
+ word-break: break-word;
1540
  }
1541
 
1542
  .micro-status {
1543
  font-size: 1.05rem;
1544
+ line-height: 1;
1545
  }
1546
 
1547
  .micro-msg-grid {
 
1550
  gap: 4px 12px;
1551
  }
1552
 
1553
+ .micro-grid-codigo {
1554
+ display: grid;
1555
+ grid-template-columns: minmax(0, 1fr);
1556
+ gap: 12px;
1557
+ }
1558
+
1559
+ .micro-card-codigo {
1560
+ width: 100%;
1561
+ max-width: none;
1562
+ flex: none;
1563
+ }
1564
+
1565
+ .micro-msg-grid-codigo {
1566
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1567
+ gap: 6px 14px;
1568
+ }
1569
+
1570
  .micro-msg {
1571
  color: #495f76;
1572
  font-size: 0.82rem;
1573
  line-height: 1.35;
1574
+ word-break: break-word;
1575
  }
1576
 
1577
  ::-webkit-scrollbar {
 
1599
  }
1600
  }
1601
 
1602
+ @keyframes spinLoader {
1603
+ to {
1604
+ transform: rotate(360deg);
1605
+ }
1606
+ }
1607
+
1608
  @media (max-width: 1150px) {
1609
  .tabs {
1610
  grid-template-columns: 1fr;
 
1622
  grid-template-columns: 1fr;
1623
  }
1624
 
1625
+ .modelo-info-split {
1626
  grid-template-columns: 1fr;
1627
  }
1628
 
1629
+ .modelo-info-col + .modelo-info-col {
1630
+ border-left: none;
1631
+ border-top: 1px solid #dde7f1;
1632
+ }
1633
+
1634
  .filtro-row-react {
1635
  grid-template-columns: 1.2fr 110px minmax(110px, 0.8fr);
1636
  }
1637
+
1638
+ .micro-msg-grid-codigo {
1639
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1640
+ }
1641
  }
1642
 
1643
  @media (max-width: 760px) {
 
1680
  min-height: 460px;
1681
  }
1682
 
1683
+ .modelo-info-split,
1684
  .outlier-inputs-grid,
1685
  .filtro-row-react {
1686
  grid-template-columns: 1fr;
1687
  }
1688
 
1689
+ .variavel-badge-line {
1690
+ grid-template-columns: 1fr;
1691
+ gap: 5px;
1692
+ }
1693
+
1694
+ .variavel-badge-label {
1695
+ padding-top: 0;
1696
+ }
1697
+
1698
  .micro-summary-grid {
1699
  grid-template-columns: 1fr;
1700
  }
 
1710
  .micro-msg-grid {
1711
  grid-template-columns: 1fr;
1712
  }
1713
+
1714
+ .micro-msg-grid-codigo {
1715
+ grid-template-columns: 1fr;
1716
+ }
1717
  }