Guilherme Silberfarb Costa commited on
Commit
de0fbb8
·
1 Parent(s): e8db196

update a lot of things

Browse files
backend/app/core/elaboracao/core.py CHANGED
@@ -5,6 +5,7 @@ Contém: carregamento de dados, estatísticas, transformações, modelo OLS, dia
5
  """
6
 
7
  import os
 
8
  import pandas as pd
9
  # Desabilita StringDtype para compatibilidade entre versões do pandas
10
  pd.set_option('future.infer_string', False)
@@ -660,6 +661,87 @@ def detectar_percentuais(df, colunas):
660
  # VERIFICAÇÃO DE MULTICOLINEARIDADE
661
  # ============================================================
662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
663
  def verificar_multicolinearidade(df, colunas_x):
664
  """Verifica multicolinearidade na matriz de regressoras (dados brutos, sem transformação).
665
 
@@ -673,11 +755,24 @@ def verificar_multicolinearidade(df, colunas_x):
673
  'vars_alta': list — variáveis com VIF > 10
674
  'posto': int — posto efetivo da matriz aumentada
675
  'ncolunas': int — número de colunas da matriz aumentada
 
 
 
676
  """
677
  from statsmodels.stats.outliers_influence import variance_inflation_factor
678
 
679
  n_vars = len(colunas_x)
680
- vazio = {'perfeita': False, 'alta': False, 'vif': {}, 'vars_alta': [], 'posto': 0, 'ncolunas': 0}
 
 
 
 
 
 
 
 
 
 
681
 
682
  if n_vars < 2:
683
  return vazio
@@ -688,8 +783,9 @@ def verificar_multicolinearidade(df, colunas_x):
688
  return vazio
689
  X = X_df.values.astype(float)
690
 
691
- # Remove colunas com variância zero (constantes puras — capturadas como dependência com intercepto)
692
  std = X.std(axis=0)
 
693
  X = X[:, std > 0]
694
  cols_validas = [c for c, s in zip(colunas_x, std) if s > 0]
695
 
@@ -701,9 +797,36 @@ def verificar_multicolinearidade(df, colunas_x):
701
  ncolunas = X_const.shape[1]
702
 
703
  if posto < ncolunas:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
704
  return {
705
- 'perfeita': True, 'alta': True, 'vif': {}, 'vars_alta': [],
706
- 'posto': posto, 'ncolunas': ncolunas,
 
 
 
 
 
 
 
707
  }
708
 
709
  # Calcula VIF apenas quando n > k (amostra suficiente)
@@ -723,9 +846,15 @@ def verificar_multicolinearidade(df, colunas_x):
723
  vars_inf = [c for c, v in vif.items() if np.isinf(v)]
724
  vif_finito = {c: v for c, v in vif.items() if not np.isinf(v)}
725
  return {
726
- 'perfeita': True, 'alta': True,
727
- 'vif': vif_finito, 'vars_alta': vars_inf,
728
- 'posto': posto, 'ncolunas': ncolunas,
 
 
 
 
 
 
729
  }
730
 
731
  vars_alta = [c for c, v in vif.items() if v > 10]
@@ -736,6 +865,9 @@ def verificar_multicolinearidade(df, colunas_x):
736
  'vars_alta': vars_alta,
737
  'posto': posto,
738
  'ncolunas': ncolunas,
 
 
 
739
  }
740
  except Exception:
741
  return vazio
 
5
  """
6
 
7
  import os
8
+ import re
9
  import pandas as pd
10
  # Desabilita StringDtype para compatibilidade entre versões do pandas
11
  pd.set_option('future.infer_string', False)
 
661
  # VERIFICAÇÃO DE MULTICOLINEARIDADE
662
  # ============================================================
663
 
664
+ def _coluna_binaria_01(serie):
665
+ """Retorna True quando a coluna contém apenas 0/1 (com tolerância numérica)."""
666
+ try:
667
+ valores = pd.to_numeric(serie, errors='coerce').dropna().to_numpy(dtype=float)
668
+ except Exception:
669
+ return False
670
+
671
+ if valores.size == 0:
672
+ return False
673
+ return bool(np.all(np.isclose(valores, 0.0) | np.isclose(valores, 1.0)))
674
+
675
+
676
+ def _detectar_grupos_dummy_sem_base(X_df):
677
+ """Detecta grupos de dummies com soma=1 em todas as linhas (dummy trap com intercepto)."""
678
+ if X_df is None or X_df.empty:
679
+ return []
680
+
681
+ colunas = [str(c) for c in X_df.columns]
682
+ colunas_binarias = [c for c in colunas if _coluna_binaria_01(X_df[c])]
683
+ if len(colunas_binarias) < 2:
684
+ return []
685
+
686
+ grupos = []
687
+ usadas = set()
688
+
689
+ # Heurística explícita para grupos de ano (ex.: a2019, a2020, ...).
690
+ colunas_ano = [c for c in colunas_binarias if re.match(r"^(a|ano_?)\d{4}$", c.lower())]
691
+ if len(colunas_ano) >= 2:
692
+ soma_ano = pd.to_numeric(X_df[colunas_ano].sum(axis=1), errors='coerce').fillna(0.0).to_numpy()
693
+ if bool(np.all(np.isclose(soma_ano, 1.0))):
694
+ grupos.append({
695
+ "tipo": "ano_sem_base",
696
+ "colunas": sorted(colunas_ano),
697
+ "linhas": int(len(X_df)),
698
+ })
699
+ usadas.update(colunas_ano)
700
+
701
+ # Heurística genérica para blocos dicotômicos mutuamente exclusivos.
702
+ restantes = [c for c in colunas_binarias if c not in usadas]
703
+ if len(restantes) >= 2:
704
+ valores = {
705
+ c: pd.to_numeric(X_df[c], errors='coerce').fillna(0.0).to_numpy(dtype=float)
706
+ for c in restantes
707
+ }
708
+ adj = {c: set() for c in restantes}
709
+ for i, col_a in enumerate(restantes):
710
+ va = valores[col_a]
711
+ for col_b in restantes[i + 1:]:
712
+ vb = valores[col_b]
713
+ # Nunca assumem 1 simultaneamente -> potencial grupo de categorias.
714
+ if bool(np.all((va * vb) == 0)):
715
+ adj[col_a].add(col_b)
716
+ adj[col_b].add(col_a)
717
+
718
+ visitadas = set()
719
+ for origem in restantes:
720
+ if origem in visitadas:
721
+ continue
722
+ pilha = [origem]
723
+ componente = []
724
+ while pilha:
725
+ atual = pilha.pop()
726
+ if atual in visitadas:
727
+ continue
728
+ visitadas.add(atual)
729
+ componente.append(atual)
730
+ pilha.extend([viz for viz in adj[atual] if viz not in visitadas])
731
+
732
+ if len(componente) < 2:
733
+ continue
734
+
735
+ soma_comp = pd.to_numeric(X_df[componente].sum(axis=1), errors='coerce').fillna(0.0).to_numpy()
736
+ if bool(np.all(np.isclose(soma_comp, 1.0))):
737
+ grupos.append({
738
+ "tipo": "dummy_sem_base",
739
+ "colunas": sorted(componente),
740
+ "linhas": int(len(X_df)),
741
+ })
742
+
743
+ return grupos
744
+
745
  def verificar_multicolinearidade(df, colunas_x):
746
  """Verifica multicolinearidade na matriz de regressoras (dados brutos, sem transformação).
747
 
 
755
  'vars_alta': list — variáveis com VIF > 10
756
  'posto': int — posto efetivo da matriz aumentada
757
  'ncolunas': int — número de colunas da matriz aumentada
758
+ 'colunas_problematicas': list — colunas associadas ao problema
759
+ 'pares_perfeitos': list[tuple[col_a, col_b, corr]] — pares ~linearmente dependentes
760
+ 'grupos_dummy_sem_base': list[dict] — grupos dicotômicos com soma=1 (dummy trap)
761
  """
762
  from statsmodels.stats.outliers_influence import variance_inflation_factor
763
 
764
  n_vars = len(colunas_x)
765
+ vazio = {
766
+ 'perfeita': False,
767
+ 'alta': False,
768
+ 'vif': {},
769
+ 'vars_alta': [],
770
+ 'posto': 0,
771
+ 'ncolunas': 0,
772
+ 'colunas_problematicas': [],
773
+ 'pares_perfeitos': [],
774
+ 'grupos_dummy_sem_base': [],
775
+ }
776
 
777
  if n_vars < 2:
778
  return vazio
 
783
  return vazio
784
  X = X_df.values.astype(float)
785
 
786
+ # Remove colunas com variância zero (constantes puras — dependentes com intercepto)
787
  std = X.std(axis=0)
788
+ cols_constantes = [c for c, s in zip(colunas_x, std) if s == 0]
789
  X = X[:, std > 0]
790
  cols_validas = [c for c, s in zip(colunas_x, std) if s > 0]
791
 
 
797
  ncolunas = X_const.shape[1]
798
 
799
  if posto < ncolunas:
800
+ pares_perfeitos = []
801
+ if X.shape[1] >= 2:
802
+ try:
803
+ corr = np.corrcoef(X, rowvar=False)
804
+ for i in range(len(cols_validas)):
805
+ for j in range(i + 1, len(cols_validas)):
806
+ c = float(corr[i, j])
807
+ if np.isfinite(c) and abs(c) >= 0.9999:
808
+ pares_perfeitos.append((cols_validas[i], cols_validas[j], c))
809
+ except Exception:
810
+ pares_perfeitos = []
811
+
812
+ colunas_problematicas = sorted(
813
+ set(cols_constantes + [a for a, _, _ in pares_perfeitos] + [b for _, b, _ in pares_perfeitos])
814
+ )
815
+ if not colunas_problematicas:
816
+ colunas_problematicas = list(cols_validas)
817
+
818
+ grupos_dummy_sem_base = _detectar_grupos_dummy_sem_base(X_df[cols_validas])
819
+
820
  return {
821
+ 'perfeita': True,
822
+ 'alta': True,
823
+ 'vif': {},
824
+ 'vars_alta': [],
825
+ 'posto': posto,
826
+ 'ncolunas': ncolunas,
827
+ 'colunas_problematicas': colunas_problematicas,
828
+ 'pares_perfeitos': pares_perfeitos,
829
+ 'grupos_dummy_sem_base': grupos_dummy_sem_base,
830
  }
831
 
832
  # Calcula VIF apenas quando n > k (amostra suficiente)
 
846
  vars_inf = [c for c, v in vif.items() if np.isinf(v)]
847
  vif_finito = {c: v for c, v in vif.items() if not np.isinf(v)}
848
  return {
849
+ 'perfeita': True,
850
+ 'alta': True,
851
+ 'vif': vif_finito,
852
+ 'vars_alta': vars_inf,
853
+ 'posto': posto,
854
+ 'ncolunas': ncolunas,
855
+ 'colunas_problematicas': sorted(set(cols_constantes + vars_inf)),
856
+ 'pares_perfeitos': [],
857
+ 'grupos_dummy_sem_base': [],
858
  }
859
 
860
  vars_alta = [c for c, v in vif.items() if v > 10]
 
865
  'vars_alta': vars_alta,
866
  'posto': posto,
867
  'ncolunas': ncolunas,
868
+ 'colunas_problematicas': sorted(set(cols_constantes + vars_alta)),
869
+ 'pares_perfeitos': [],
870
+ 'grupos_dummy_sem_base': [],
871
  }
872
  except Exception:
873
  return vazio
backend/app/core/elaboracao/formatadores.py CHANGED
@@ -220,7 +220,7 @@ def _renderizar_secao_micro(titulo, resultados_dict, secao_tipo="padrao"):
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():
@@ -713,9 +713,55 @@ def formatar_aviso_multicolinearidade(resultado):
713
  if not resultado or (not resultado.get('perfeita') and not resultado.get('alta')):
714
  return "", False
715
 
 
 
 
716
  if resultado.get('perfeita'):
717
  posto = resultado.get('posto', '?')
718
  ncolunas = resultado.get('ncolunas', '?')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719
  html = (
720
  '<div style="margin-top:12px; padding:12px 16px; background:#fdecea; '
721
  'border-left:4px solid #c0392b; border-radius:6px; color:#7b1a1a;">'
@@ -724,6 +770,9 @@ def formatar_aviso_multicolinearidade(resultado):
724
  f'(posto {posto} &lt; {ncolunas} colunas). '
725
  'O modelo OLS <strong>não poderá ser estimado</strong> com as variáveis selecionadas. '
726
  'Verifique se há dummies em excesso, variáveis redundantes ou combinações lineares exatas.</span>'
 
 
 
727
  '</div>'
728
  )
729
  return html, True
@@ -731,16 +780,27 @@ def formatar_aviso_multicolinearidade(resultado):
731
  # Alta (VIF > 10)
732
  vif = resultado.get('vif', {})
733
  vars_alta = resultado.get('vars_alta', [])
734
- itens = ", ".join(
735
- f"<strong>{c}</strong> (VIF={vif[c]:.1f})" for c in vars_alta if c in vif
 
 
 
 
 
 
 
 
 
736
  )
 
737
  html = (
738
  '<div style="margin-top:12px; padding:12px 16px; background:#fff8e1; '
739
  'border-left:4px solid #f39c12; border-radius:6px; color:#7d5a00;">'
740
  '<strong>⚠️ Multicolinearidade Alta Detectada</strong><br>'
741
- f'<span style="font-size:0.93em;">Variáveis com VIF &gt; 10: {itens}. '
742
  'A alta colinearidade pode causar instabilidade nos coeficientes estimados. '
743
  'Considere remover variáveis redundantes antes da estimação.</span>'
 
744
  '</div>'
745
  )
746
  return html, True
 
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 micro-group-title">{titulo}</div>'
224
  html += f'<div class="{grid_class}">'
225
 
226
  for coluna, info in resultados_dict.items():
 
713
  if not resultado or (not resultado.get('perfeita') and not resultado.get('alta')):
714
  return "", False
715
 
716
+ colunas_problematicas = [str(c) for c in (resultado.get('colunas_problematicas') or [])]
717
+ colunas_problematicas_html = ", ".join(f"<strong>{c}</strong>" for c in colunas_problematicas)
718
+
719
  if resultado.get('perfeita'):
720
  posto = resultado.get('posto', '?')
721
  ncolunas = resultado.get('ncolunas', '?')
722
+ pares = resultado.get('pares_perfeitos') or []
723
+ grupos_dummy = resultado.get('grupos_dummy_sem_base') or []
724
+
725
+ detalhe_colunas = (
726
+ f'<br><span style="font-size:0.93em;">Colunas envolvidas: {colunas_problematicas_html}.</span>'
727
+ if colunas_problematicas_html else ""
728
+ )
729
+
730
+ if pares:
731
+ pares_fmt = []
732
+ for a, b, corr in pares[:8]:
733
+ try:
734
+ corr_txt = f"{float(corr):.4f}"
735
+ except Exception:
736
+ corr_txt = "?"
737
+ pares_fmt.append(f"<strong>{a}</strong> × <strong>{b}</strong> (r={corr_txt})")
738
+ detalhe_pares = (
739
+ '<br><span style="font-size:0.93em;">Pares com correlação praticamente perfeita: '
740
+ + "; ".join(pares_fmt)
741
+ + ".</span>"
742
+ )
743
+ else:
744
+ detalhe_pares = ""
745
+
746
+ if grupos_dummy:
747
+ causas = []
748
+ for grupo in grupos_dummy[:4]:
749
+ cols = [str(c) for c in (grupo.get('colunas') or [])]
750
+ cols_html = ", ".join(f"<strong>{c}</strong>" for c in cols[:10])
751
+ if len(cols) > 10:
752
+ cols_html += ", …"
753
+ if grupo.get('tipo') == 'ano_sem_base':
754
+ causas.append(f"grupo de anos sem categoria base: {cols_html}")
755
+ else:
756
+ causas.append(f"grupo dicotômico sem categoria base: {cols_html}")
757
+ detalhe_causa = (
758
+ '<br><span style="font-size:0.93em;">Causa provável detectada: '
759
+ + "; ".join(causas)
760
+ + '. Remova ao menos uma dummy de cada grupo (categoria base) antes da estimação.</span>'
761
+ )
762
+ else:
763
+ detalhe_causa = ""
764
+
765
  html = (
766
  '<div style="margin-top:12px; padding:12px 16px; background:#fdecea; '
767
  'border-left:4px solid #c0392b; border-radius:6px; color:#7b1a1a;">'
 
770
  f'(posto {posto} &lt; {ncolunas} colunas). '
771
  'O modelo OLS <strong>não poderá ser estimado</strong> com as variáveis selecionadas. '
772
  'Verifique se há dummies em excesso, variáveis redundantes ou combinações lineares exatas.</span>'
773
+ f'{detalhe_colunas}'
774
+ f'{detalhe_pares}'
775
+ f'{detalhe_causa}'
776
  '</div>'
777
  )
778
  return html, True
 
780
  # Alta (VIF > 10)
781
  vif = resultado.get('vif', {})
782
  vars_alta = resultado.get('vars_alta', [])
783
+ itens = []
784
+ for c in vars_alta:
785
+ if c in vif:
786
+ itens.append(f"<strong>{c}</strong> (VIF={vif[c]:.1f})")
787
+ else:
788
+ itens.append(f"<strong>{c}</strong>")
789
+ itens_html = ", ".join(itens) if itens else "não identificado"
790
+
791
+ detalhe_colunas = (
792
+ f'<br><span style="font-size:0.93em;">Colunas com problema: {colunas_problematicas_html}.</span>'
793
+ if colunas_problematicas_html else ""
794
  )
795
+
796
  html = (
797
  '<div style="margin-top:12px; padding:12px 16px; background:#fff8e1; '
798
  'border-left:4px solid #f39c12; border-radius:6px; color:#7d5a00;">'
799
  '<strong>⚠️ Multicolinearidade Alta Detectada</strong><br>'
800
+ f'<span style="font-size:0.93em;">Variáveis com VIF &gt; 10: {itens_html}. '
801
  'A alta colinearidade pode causar instabilidade nos coeficientes estimados. '
802
  'Considere remover variáveis redundantes antes da estimação.</span>'
803
+ f'{detalhe_colunas}'
804
  '</div>'
805
  )
806
  return html, True
backend/app/core/elaboracao/geocodificacao.py CHANGED
@@ -23,6 +23,36 @@ _SHAPEFILE = os.path.join(_BASE, "dados", "EixosLogradouros.shp")
23
  _gdf_eixos = None
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def carregar_eixos():
27
  """Carrega e cacheia o GeoDataFrame dos eixos de logradouros.
28
 
@@ -76,12 +106,12 @@ def verificar_coords(df):
76
  col_lon = colunas_lower[nome]
77
  break
78
 
79
- if col_lat is None or col_lon is None:
80
  return False, None, None
81
 
82
  # Verifica se há ao menos 1 valor não-nulo em cada coluna
83
- lat_ok = pd.to_numeric(df[col_lat], errors="coerce").notna().any()
84
- lon_ok = pd.to_numeric(df[col_lon], errors="coerce").notna().any()
85
 
86
  if lat_ok and lon_ok:
87
  return True, col_lat, col_lon
@@ -99,19 +129,17 @@ def padronizar_coords(df, col_lat, col_lon):
99
  Retorna:
100
  DataFrame com colunas 'lat' e 'lon' padronizadas.
101
  """
102
- df = df.copy()
 
103
 
104
- if col_lat != "lat":
105
- df["lat"] = pd.to_numeric(df[col_lat], errors="coerce")
106
- df = df.drop(columns=[col_lat])
107
- else:
108
- df["lat"] = pd.to_numeric(df["lat"], errors="coerce")
109
 
110
- if col_lon != "lon":
111
- df["lon"] = pd.to_numeric(df[col_lon], errors="coerce")
112
- df = df.drop(columns=[col_lon])
113
- else:
114
- df["lon"] = pd.to_numeric(df["lon"], errors="coerce")
115
 
116
  return df
117
 
@@ -183,7 +211,8 @@ def geocodificar(df, col_cdlog, col_num, auto_200=False):
183
  if "_idx" not in df.columns:
184
  df["_idx"] = range(len(df))
185
 
186
- df[col_num] = pd.to_numeric(df[col_num], errors="coerce").fillna(0).astype(int)
 
187
 
188
  lats = []
189
  lons = []
@@ -192,8 +221,8 @@ def geocodificar(df, col_cdlog, col_num, auto_200=False):
192
 
193
  for _, row in df.iterrows():
194
  idx = row["_idx"]
195
- cdlog = row[col_cdlog]
196
- numero = int(row[col_num])
197
 
198
  # --- Passo 1: buscar segmentos do CDLOG ---
199
  segmentos = gdf_eixos[gdf_eixos["CDLOG"] == cdlog]
@@ -347,6 +376,8 @@ def geocodificar(df, col_cdlog, col_num, auto_200=False):
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"]
 
23
  _gdf_eixos = None
24
 
25
 
26
+ def obter_serie_coluna(df: pd.DataFrame, coluna: str) -> pd.Series:
27
+ """Retorna uma Series 1D para uma coluna, mesmo com nomes duplicados.
28
+
29
+ Quando há colunas duplicadas com o mesmo nome, pandas retorna DataFrame em
30
+ `df[coluna]`. Nesse caso escolhemos a versão com maior quantidade de
31
+ valores convertíveis para número (heurística robusta para lat/lon/número).
32
+ """
33
+ selecionada = df.loc[:, coluna]
34
+ if isinstance(selecionada, pd.Series):
35
+ return selecionada
36
+
37
+ if isinstance(selecionada, pd.DataFrame):
38
+ if selecionada.shape[1] == 0:
39
+ return pd.Series(index=df.index, dtype="object")
40
+ if selecionada.shape[1] == 1:
41
+ return selecionada.iloc[:, 0]
42
+
43
+ melhor = selecionada.iloc[:, 0]
44
+ melhor_score = -1
45
+ for idx in range(selecionada.shape[1]):
46
+ serie = selecionada.iloc[:, idx]
47
+ score = int(pd.to_numeric(serie, errors="coerce").notna().sum())
48
+ if score > melhor_score:
49
+ melhor_score = score
50
+ melhor = serie
51
+ return melhor
52
+
53
+ return pd.Series(selecionada, index=df.index)
54
+
55
+
56
  def carregar_eixos():
57
  """Carrega e cacheia o GeoDataFrame dos eixos de logradouros.
58
 
 
106
  col_lon = colunas_lower[nome]
107
  break
108
 
109
+ if col_lat is None or col_lon is None or col_lat == col_lon:
110
  return False, None, None
111
 
112
  # Verifica se há ao menos 1 valor não-nulo em cada coluna
113
+ lat_ok = pd.to_numeric(obter_serie_coluna(df, col_lat), errors="coerce").notna().any()
114
+ lon_ok = pd.to_numeric(obter_serie_coluna(df, col_lon), errors="coerce").notna().any()
115
 
116
  if lat_ok and lon_ok:
117
  return True, col_lat, col_lon
 
129
  Retorna:
130
  DataFrame com colunas 'lat' e 'lon' padronizadas.
131
  """
132
+ lat_series = pd.to_numeric(obter_serie_coluna(df, col_lat), errors="coerce")
133
+ lon_series = pd.to_numeric(obter_serie_coluna(df, col_lon), errors="coerce")
134
 
135
+ df = df.copy()
136
+ cols_drop = [nome for nome in (col_lat, col_lon) if nome in df.columns]
137
+ if cols_drop:
138
+ # Remove todas as ocorrências dos nomes originais (inclusive duplicadas).
139
+ df = df.drop(columns=list(dict.fromkeys(cols_drop)), errors="ignore")
140
 
141
+ df["lat"] = lat_series
142
+ df["lon"] = lon_series
 
 
 
143
 
144
  return df
145
 
 
211
  if "_idx" not in df.columns:
212
  df["_idx"] = range(len(df))
213
 
214
+ df["__geo_cdlog"] = obter_serie_coluna(df, col_cdlog)
215
+ df["__geo_num"] = pd.to_numeric(obter_serie_coluna(df, col_num), errors="coerce").fillna(0).astype(int)
216
 
217
  lats = []
218
  lons = []
 
221
 
222
  for _, row in df.iterrows():
223
  idx = row["_idx"]
224
+ cdlog = row["__geo_cdlog"]
225
+ numero = int(row["__geo_num"])
226
 
227
  # --- Passo 1: buscar segmentos do CDLOG ---
228
  segmentos = gdf_eixos[gdf_eixos["CDLOG"] == cdlog]
 
376
  # Nunca falha o fluxo principal por causa desse ajuste.
377
  pass
378
 
379
+ df = df.drop(columns=["__geo_cdlog", "__geo_num"], errors="ignore")
380
+
381
  df_falhas = pd.DataFrame(
382
  falhas,
383
  columns=["_idx", "cdlog", "numero_atual", "motivo", "sugestoes", "numero_corrigido"]
backend/app/services/elaboracao_service.py CHANGED
@@ -100,6 +100,77 @@ def _selection_context(session: SessionState) -> dict[str, Any]:
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 ""
@@ -280,6 +351,8 @@ def load_dai_for_elaboracao(session: SessionState, caminho_arquivo: str) -> dict
280
  session.outliers_anteriores = _clean_int_list(outliers_excluidos)
281
 
282
  base = _set_dataframe_base(session, df, clear_models=True)
 
 
283
 
284
  selection_payload = apply_selection(
285
  session,
@@ -384,6 +457,8 @@ def apply_selection(
384
 
385
  resultado_multi = verificar_multicolinearidade(df_filtrado, colunas_x_validas)
386
  aviso_multi_html, aviso_multi_visivel = formatar_aviso_multicolinearidade(resultado_multi)
 
 
387
 
388
  n_out = len(outliers)
389
  resumo_outliers = f"Excluidos: {n_out} | A excluir: 0 | A reincluir: 0 | Total: {n_out}"
@@ -416,6 +491,11 @@ def apply_selection(
416
  "html": aviso_multi_html,
417
  "visible": aviso_multi_visivel,
418
  },
 
 
 
 
 
419
  "transformacao_y": session.transformacao_y,
420
  "transform_fields": transform_fields,
421
  "mapa_html": _render_mapa_if_enabled(session, df_filtrado),
@@ -578,6 +658,13 @@ def fit_model(
578
 
579
  return {
580
  "diagnosticos_html": diagnosticos_html,
 
 
 
 
 
 
 
581
  "tabela_coef": dataframe_to_payload(resultado["tabela_coef"], decimals=4),
582
  "tabela_obs_calc": dataframe_to_payload(resultado["tabela_obs_calc"], decimals=4),
583
  "grafico_dispersao_modelo": figure_to_payload(fig_dispersao_transf),
@@ -994,8 +1081,8 @@ def mapear_coordenadas_manualmente(session: SessionState, col_lat: str, col_lon:
994
  if n_total == 0:
995
  raise HTTPException(status_code=400, detail="DataFrame vazio")
996
 
997
- lat_num = pd.to_numeric(df[col_lat], errors="coerce")
998
- lon_num = pd.to_numeric(df[col_lon], errors="coerce")
999
  lat_vals = lat_num.dropna()
1000
  lon_vals = lon_num.dropna()
1001
 
 
100
  }
101
 
102
 
103
+ def _resumo_faltantes_variaveis(df: pd.DataFrame, colunas: list[str]) -> dict[str, Any]:
104
+ if df is None or not colunas:
105
+ return {"has_missing": False, "rows_missing": 0, "total_rows": 0, "columns": [], "rows_preview": []}
106
+
107
+ colunas_validas = [c for c in colunas if c in df.columns]
108
+ if not colunas_validas:
109
+ return {"has_missing": False, "rows_missing": 0, "total_rows": int(len(df)), "columns": [], "rows_preview": []}
110
+
111
+ sub = df[colunas_validas]
112
+ mask_nulos = sub.isna()
113
+
114
+ # Compatível com versões novas do pandas (sem DataFrame.applymap)
115
+ # e robusto com colunas duplicadas (acesso por posição).
116
+ mask_brancos = pd.DataFrame(False, index=sub.index, columns=sub.columns)
117
+ for idx_col in range(sub.shape[1]):
118
+ serie = sub.iloc[:, idx_col]
119
+ if pd.api.types.is_object_dtype(serie) or pd.api.types.is_string_dtype(serie):
120
+ mask_brancos.iloc[:, idx_col] = serie.astype(str).str.strip().eq("").to_numpy()
121
+ else:
122
+ mask_brancos.iloc[:, idx_col] = False
123
+
124
+ mask_faltantes = (mask_nulos | mask_brancos).astype(bool)
125
+
126
+ linhas_com_faltante = mask_faltantes.any(axis=1)
127
+ linhas_idx = df.index[linhas_com_faltante].tolist()
128
+
129
+ faltantes_por_nome: dict[str, bool] = {}
130
+ for idx_col, nome in enumerate(sub.columns):
131
+ nome_str = str(nome)
132
+ tem_faltante = bool(mask_faltantes.iloc[:, idx_col].any())
133
+ faltantes_por_nome[nome_str] = bool(faltantes_por_nome.get(nome_str, False) or tem_faltante)
134
+
135
+ colunas_com_faltante = [col for col in colunas_validas if faltantes_por_nome.get(col, False)]
136
+
137
+ return {
138
+ "has_missing": len(linhas_idx) > 0,
139
+ "rows_missing": len(linhas_idx),
140
+ "total_rows": int(len(df)),
141
+ "columns": colunas_com_faltante,
142
+ "rows_preview": [int(i) if isinstance(i, (int, np.integer)) else str(i) for i in linhas_idx[:25]],
143
+ }
144
+
145
+
146
+ def _formatar_aviso_faltantes(info: dict[str, Any]) -> str:
147
+ if not info.get("has_missing"):
148
+ return ""
149
+
150
+ rows_missing = int(info.get("rows_missing") or 0)
151
+ total_rows = int(info.get("total_rows") or 0)
152
+ colunas = [str(c) for c in (info.get("columns") or [])]
153
+ preview = [str(i) for i in (info.get("rows_preview") or [])]
154
+
155
+ colunas_txt = ", ".join(f"<strong>{c}</strong>" for c in colunas) if colunas else "não identificado"
156
+ preview_txt = ", ".join(preview)
157
+ detalhe_preview = (
158
+ f'<br><span style="font-size:0.93em;">Primeiros índices com problema: {preview_txt}</span>'
159
+ if preview_txt else ""
160
+ )
161
+
162
+ return (
163
+ '<div style="margin-top:12px; padding:12px 16px; background:#fff7ed; '
164
+ 'border-left:4px solid #f28c28; border-radius:6px; color:#7a4a00;">'
165
+ '<strong>⚠️ Verificação de nulos/brancos</strong><br>'
166
+ f'<span style="font-size:0.93em;">Foram encontradas <strong>{rows_missing}</strong> linha(s) com valores nulos ou em branco '
167
+ f'em Y/X selecionadas (total analisado: {total_rows}).</span>'
168
+ f'<br><span style="font-size:0.93em;">Colunas afetadas: {colunas_txt}.</span>'
169
+ f'{detalhe_preview}'
170
+ '</div>'
171
+ )
172
+
173
+
174
  def _render_mapa_if_enabled(session: SessionState, df: pd.DataFrame | None, **kwargs: Any) -> str:
175
  if not session.mapa_habilitado or df is None:
176
  return ""
 
351
  session.outliers_anteriores = _clean_int_list(outliers_excluidos)
352
 
353
  base = _set_dataframe_base(session, df, clear_models=True)
354
+ session.transformacao_y = str(transformacao_y or "(x)")
355
+ session.transformacoes_x = {str(k): str(v) for k, v in (transformacoes_x or {}).items()}
356
 
357
  selection_payload = apply_selection(
358
  session,
 
457
 
458
  resultado_multi = verificar_multicolinearidade(df_filtrado, colunas_x_validas)
459
  aviso_multi_html, aviso_multi_visivel = formatar_aviso_multicolinearidade(resultado_multi)
460
+ faltantes_info = _resumo_faltantes_variaveis(df_filtrado, [coluna_y] + colunas_x_validas)
461
+ aviso_faltantes_html = _formatar_aviso_faltantes(faltantes_info)
462
 
463
  n_out = len(outliers)
464
  resumo_outliers = f"Excluidos: {n_out} | A excluir: 0 | A reincluir: 0 | Total: {n_out}"
 
491
  "html": aviso_multi_html,
492
  "visible": aviso_multi_visivel,
493
  },
494
+ "aviso_faltantes": {
495
+ "html": aviso_faltantes_html,
496
+ "visible": bool(faltantes_info.get("has_missing")),
497
+ "info": sanitize_value(faltantes_info),
498
+ },
499
  "transformacao_y": session.transformacao_y,
500
  "transform_fields": transform_fields,
501
  "mapa_html": _render_mapa_if_enabled(session, df_filtrado),
 
658
 
659
  return {
660
  "diagnosticos_html": diagnosticos_html,
661
+ "transformacao_y": session.transformacao_y,
662
+ "transformacoes_x": sanitize_value(session.transformacoes_x),
663
+ "transformacoes_aplicadas": {
664
+ "coluna_y": session.coluna_y,
665
+ "transformacao_y": session.transformacao_y,
666
+ "transformacoes_x": sanitize_value(session.transformacoes_x),
667
+ },
668
  "tabela_coef": dataframe_to_payload(resultado["tabela_coef"], decimals=4),
669
  "tabela_obs_calc": dataframe_to_payload(resultado["tabela_obs_calc"], decimals=4),
670
  "grafico_dispersao_modelo": figure_to_payload(fig_dispersao_transf),
 
1081
  if n_total == 0:
1082
  raise HTTPException(status_code=400, detail="DataFrame vazio")
1083
 
1084
+ lat_num = pd.to_numeric(geocodificacao.obter_serie_coluna(df, col_lat), errors="coerce")
1085
+ lon_num = pd.to_numeric(geocodificacao.obter_serie_coluna(df, col_lon), errors="coerce")
1086
  lat_vals = lat_num.dropna()
1087
  lon_vals = lon_num.dropna()
1088
 
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -56,10 +56,30 @@ function formatConselhoRegistro(elaborador) {
56
 
57
  function formatTransformacaoBadge(transformacao) {
58
  const valor = String(transformacao || '').trim()
59
- if (!valor || valor === '(x)' || valor === '(y)' || valor === 'x' || valor === 'y') return ''
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'
@@ -91,16 +111,24 @@ function parseSugestoes(raw) {
91
  .filter(Boolean)
92
  }
93
 
 
 
 
 
 
 
94
  export default function ElaboracaoTab({ sessionId }) {
95
  const [loading, setLoading] = useState(false)
96
  const [error, setError] = useState('')
97
  const [status, setStatus] = useState('')
98
 
99
  const [uploadedFile, setUploadedFile] = useState(null)
 
100
  const [requiresSheet, setRequiresSheet] = useState(false)
101
  const [sheetOptions, setSheetOptions] = useState([])
102
  const [selectedSheet, setSelectedSheet] = useState('')
103
  const [elaborador, setElaborador] = useState(null)
 
104
 
105
  const [dados, setDados] = useState(null)
106
  const [mapaHtml, setMapaHtml] = useState('')
@@ -136,7 +164,10 @@ export default function ElaboracaoTab({ sessionId }) {
136
 
137
  const [transformacaoY, setTransformacaoY] = useState('(x)')
138
  const [transformacoesX, setTransformacoesX] = useState({})
 
 
139
  const [section8Open, setSection8Open] = useState(false)
 
140
  const [section11Open, setSection11Open] = useState(false)
141
 
142
  const [fit, setFit] = useState(null)
@@ -162,6 +193,7 @@ export default function ElaboracaoTab({ sessionId }) {
162
  const marcarTodasXRef = useRef(null)
163
  const classificarXReqRef = useRef(0)
164
  const deleteConfirmTimersRef = useRef({})
 
165
 
166
  const mapaChoices = useMemo(() => ['Visualização Padrão', ...colunasNumericas], [colunasNumericas])
167
  const colunasXDisponiveis = useMemo(
@@ -181,13 +213,46 @@ export default function ElaboracaoTab({ sessionId }) {
181
  if (!elaborador) return []
182
  return [elaborador.cargo, conselhoRegistro, elaborador.matricula_sem_digito ? `Matricula ${elaborador.matricula_sem_digito}` : '', elaborador.lotacao].filter(Boolean)
183
  }, [elaborador, conselhoRegistro])
184
- const transformacaoYBadge = useMemo(() => formatTransformacaoBadge(transformacaoY), [transformacaoY])
185
- const variaveisIndependentesBadge = useMemo(() => {
186
- return colunasX.map((coluna) => ({
 
 
 
 
 
187
  coluna,
188
- transformacao: formatTransformacaoBadge(transformacoesX[coluna]),
189
  }))
190
- }, [colunasX, transformacoesX])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  const showCoordsPanel = Boolean(
192
  coordsInfo && (
193
  !coordsInfo.tem_coords ||
@@ -320,6 +385,8 @@ export default function ElaboracaoTab({ sessionId }) {
320
  setPercentuais([])
321
  setTransformacaoY('(x)')
322
  setTransformacoesX({})
 
 
323
  setOutliersAnteriores([])
324
  setIteracao(1)
325
  setSelection(null)
@@ -344,13 +411,19 @@ export default function ElaboracaoTab({ sessionId }) {
344
  }
345
 
346
  if (resp.fit && !resetXSelection) {
347
- applyFitResponse(resp.fit)
 
 
 
348
  }
349
  }
350
 
351
  function applySelectionResponse(resp) {
352
  setSelection(resp)
353
  setSection8Open(false)
 
 
 
354
  if (resp.transformacao_y) {
355
  setTransformacaoY(resp.transformacao_y)
356
  }
@@ -378,9 +451,29 @@ export default function ElaboracaoTab({ sessionId }) {
378
  }
379
  }
380
 
381
- function applyFitResponse(resp) {
382
  setFit(resp)
383
  setSection11Open(false)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  setResumoOutliers(resp.resumo_outliers || resumoOutliers)
385
  setCamposAvaliacao(resp.avaliacao_campos || [])
386
  const init = {}
@@ -394,22 +487,24 @@ export default function ElaboracaoTab({ sessionId }) {
394
  setBaseValue('')
395
  }
396
 
397
- async function onUploadClick() {
398
- if (!uploadedFile || !sessionId) return
 
399
  await withBusy(async () => {
400
  setMapaGerado(false)
401
  setMapaHtml('')
402
  setGeoAuto200(true)
403
- const nomeArquivo = String(uploadedFile?.name || '').toLowerCase()
404
  const uploadEhDai = nomeArquivo.endsWith('.dai')
405
  setTipoFonteDados(uploadEhDai ? 'dai' : 'tabular')
406
- const resp = await api.uploadElaboracaoFile(sessionId, uploadedFile)
407
  setManualMapError('')
408
  setGeoProcessError('')
409
  setGeoStatusHtml('')
410
  setGeoFalhasHtml('')
411
  setGeoCorrecoes([])
412
  setElaborador(resp.elaborador || null)
 
413
  setAvaliadorSelecionado(resp.elaborador?.nome_completo || '')
414
  setRequiresSheet(Boolean(resp.requires_sheet))
415
  setSheetOptions(resp.sheets || [])
@@ -429,6 +524,8 @@ export default function ElaboracaoTab({ sessionId }) {
429
  setPercentuais([])
430
  setTransformacaoY('(x)')
431
  setTransformacoesX({})
 
 
432
  setCamposAvaliacao([])
433
  valoresAvaliacaoRef.current = {}
434
  setAvaliacaoFormVersion((prev) => prev + 1)
@@ -438,6 +535,37 @@ export default function ElaboracaoTab({ sessionId }) {
438
  })
439
  }
440
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
  async function onConfirmSheet() {
442
  if (!selectedSheet || !sessionId) return
443
  await withBusy(async () => {
@@ -452,6 +580,7 @@ export default function ElaboracaoTab({ sessionId }) {
452
  setGeoFalhasHtml('')
453
  setGeoCorrecoes([])
454
  setElaborador(resp.elaborador || null)
 
455
  setAvaliadorSelecionado(resp.elaborador?.nome_completo || '')
456
  setRequiresSheet(false)
457
  applyBaseResponse(resp, { resetXSelection: true })
@@ -590,9 +719,28 @@ export default function ElaboracaoTab({ sessionId }) {
590
  async function onAdoptSuggestion(idx) {
591
  if (!sessionId) return
592
  await withBusy(async () => {
593
- const resp = await api.adoptSuggestion(sessionId, idx)
594
- setTransformacaoY(resp.transformacao_y || '(x)')
595
- setTransformacoesX(resp.transformacoes_x || {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  })
597
  }
598
 
@@ -607,7 +755,7 @@ export default function ElaboracaoTab({ sessionId }) {
607
  codigo_alocado: codigoAlocado,
608
  percentuais,
609
  })
610
- applyFitResponse(resp)
611
  })
612
  }
613
 
@@ -659,6 +807,8 @@ export default function ElaboracaoTab({ sessionId }) {
659
  setOutliersTexto('')
660
  setReincluirTexto('')
661
  setFit(null)
 
 
662
  setResultadoAvaliacaoHtml('')
663
  setResumoOutliers(resp.resumo_outliers || resumoOutliers)
664
  if (typeof window !== 'undefined') {
@@ -678,6 +828,8 @@ export default function ElaboracaoTab({ sessionId }) {
678
  setIteracao(1)
679
  setSelection(null)
680
  setFit(null)
 
 
681
  setCamposAvaliacao([])
682
  valoresAvaliacaoRef.current = {}
683
  setAvaliacaoFormVersion((prev) => prev + 1)
@@ -852,9 +1004,33 @@ export default function ElaboracaoTab({ sessionId }) {
852
  <div className="section1-groups">
853
  <div className="subpanel section1-group">
854
  <h4>Carregar modelo</h4>
855
- <div className="row">
856
- <input type="file" onChange={(e) => setUploadedFile(e.target.files?.[0] ?? null)} />
857
- <button onClick={onUploadClick} disabled={!uploadedFile || loading || !sessionId}>Carregar arquivo</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
858
  </div>
859
 
860
  {requiresSheet ? (
@@ -871,38 +1047,42 @@ export default function ElaboracaoTab({ sessionId }) {
871
 
872
  <div className="subpanel section1-group">
873
  <h4>Informações do modelo</h4>
874
- {elaborador?.nome_completo ? (
875
  <div className="modelo-info-card">
876
  <div className="modelo-info-split">
877
  <div className="modelo-info-col">
878
  <div className="elaborador-badge-title">Modelo elaborado por:</div>
879
- <div className="elaborador-badge-name">{elaborador.nome_completo}</div>
880
- {elaboradorMeta.length > 0 ? (
 
 
 
 
881
  <div className="elaborador-badge-meta">{elaboradorMeta.join(' | ')}</div>
882
  ) : null}
883
  </div>
884
 
885
  <div className="modelo-info-col modelo-info-col-vars">
886
  <div className="elaborador-badge-title">Variáveis do modelo carregado</div>
887
- {colunaY ? (
888
  <div className="variavel-badge-line">
889
  <span className="variavel-badge-label">Dependente:</span>
890
- <span className="variavel-chip variavel-chip-y">
891
- {colunaY}
892
- {transformacaoYBadge ? <span className="variavel-chip-transform">{` ${transformacaoYBadge}`}</span> : null}
893
  </span>
894
  </div>
895
  ) : (
896
  <div className="section1-empty-hint">Variável dependente não encontrada no modelo carregado.</div>
897
  )}
898
- {variaveisIndependentesBadge.length > 0 ? (
899
  <div className="variavel-badge-line">
900
  <span className="variavel-badge-label">Independentes:</span>
901
  <div className="variavel-chip-wrap">
902
- {variaveisIndependentesBadge.map((item) => (
903
  <span key={`ind-${item.coluna}`} className="variavel-chip">
904
  {item.coluna}
905
- {item.transformacao ? <span className="variavel-chip-transform">{` ${item.transformacao}`}</span> : null}
906
  </span>
907
  ))}
908
  </div>
@@ -1280,6 +1460,12 @@ export default function ElaboracaoTab({ sessionId }) {
1280
  <div className="row">
1281
  <button onClick={onApplySelection} disabled={loading || !colunaY || colunasX.length === 0}>Aplicar seleção</button>
1282
  </div>
 
 
 
 
 
 
1283
  </SectionBlock>
1284
  </>
1285
  ) : null}
@@ -1292,9 +1478,6 @@ export default function ElaboracaoTab({ sessionId }) {
1292
 
1293
  <SectionBlock step="7" title="Teste de Micronumerosidade" subtitle="Validação de amostra mínima para variáveis selecionadas.">
1294
  <div dangerouslySetInnerHTML={{ __html: selection.micronumerosidade_html || '' }} />
1295
- {selection.aviso_multicolinearidade?.visible ? (
1296
- <div dangerouslySetInnerHTML={{ __html: selection.aviso_multicolinearidade.html }} />
1297
- ) : null}
1298
  </SectionBlock>
1299
 
1300
  <SectionBlock step="8" title="Gráficos de Dispersão das Variáveis Independentes" subtitle="Leitura visual entre X e Y no conjunto filtrado.">
@@ -1337,10 +1520,19 @@ export default function ElaboracaoTab({ sessionId }) {
1337
  <div key={`sug-${item.rank || idx + 1}`} className="transform-suggestion-card">
1338
  <div className="transform-suggestion-head">
1339
  <span className="transform-suggestion-rank">#{item.rank || idx + 1}</span>
1340
- <span className="transform-suggestion-r2">R² = {Number(item.r2 ?? 0).toFixed(4)}</span>
 
 
 
 
 
1341
  </div>
1342
- <div className="transform-suggestion-line"><strong>Y:</strong> {item.transformacao_y}</div>
1343
  <div className="transform-suggestion-list">
 
 
 
 
 
1344
  {Object.entries(item.transformacoes_x || {}).map(([coluna, transf]) => {
1345
  const grau = Number(item.graus_coef?.[coluna] ?? 0)
1346
  return (
@@ -1352,13 +1544,8 @@ export default function ElaboracaoTab({ sessionId }) {
1352
  )
1353
  })}
1354
  </div>
1355
- <div className="transform-suggestion-foot">
1356
- <span className={grauBadgeClass(Number(item.grau_f ?? 0))}>
1357
- Teste F: {GRAU_LABEL_CURTO[Number(item.grau_f ?? 0)] || 'Sem enq.'}
1358
- </span>
1359
- </div>
1360
- <button onClick={() => onAdoptSuggestion(idx)} disabled={loading}>
1361
- Adotar sugestão #{item.rank || idx + 1}
1362
  </button>
1363
  </div>
1364
  ))}
@@ -1369,33 +1556,79 @@ export default function ElaboracaoTab({ sessionId }) {
1369
  </SectionBlock>
1370
 
1371
  <SectionBlock step="10" title="Aplicação das Transformações" subtitle="Configuração manual para ajuste do modelo.">
1372
- <div className="row">
1373
- <label>Transformação de Y</label>
1374
- <select value={transformacaoY} onChange={(e) => setTransformacaoY(e.target.value)}>
1375
- {['(x)', '1/(x)', 'ln(x)', 'exp(x)', '(x)^2', 'raiz(x)', '1/raiz(x)'].map((item) => (
1376
- <option key={`y-${item}`} value={item}>{item}</option>
1377
- ))}
1378
- </select>
 
1379
  </div>
1380
 
1381
- <div className="transform-grid">
1382
- {(selection.transform_fields || []).map((field) => (
1383
- <div key={`tf-${field.coluna}`} className="transform-card">
1384
- <span>{field.coluna}</span>
1385
- <select
1386
- value={transformacoesX[field.coluna] || '(x)'}
1387
- onChange={(e) => setTransformacoesX((prev) => ({ ...prev, [field.coluna]: e.target.value }))}
1388
- disabled={field.locked}
1389
- >
1390
- {(field.choices || []).map((choice) => (
1391
- <option key={`${field.coluna}-${choice}`} value={choice}>{choice}</option>
1392
  ))}
1393
  </select>
1394
  </div>
1395
- ))}
1396
- </div>
1397
 
1398
- <button onClick={onFitModel} disabled={loading}>Aplicar transformações e ajustar modelo</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1399
  </SectionBlock>
1400
  </>
1401
  ) : null}
 
56
 
57
  function formatTransformacaoBadge(transformacao) {
58
  const valor = String(transformacao || '').trim()
59
+ if (!valor || valor === '(x)' || valor === '(y)' || valor === 'x' || valor === 'y') return '(x)'
60
  return valor
61
  }
62
 
63
+ function buildLoadedModelInfo(resp) {
64
+ const tipo = String(resp?.tipo || '').toLowerCase()
65
+ if (tipo !== 'dai') return null
66
+
67
+ const contexto = resp?.contexto || {}
68
+ const fit = resp?.fit || {}
69
+ const colunaY = String(contexto.coluna_y || '').trim()
70
+ const colunasX = Array.isArray(contexto.colunas_x) ? contexto.colunas_x.map((item) => String(item)) : []
71
+ const transformacoesX = fit && typeof fit.transformacoes_x === 'object' && fit.transformacoes_x !== null
72
+ ? fit.transformacoes_x
73
+ : {}
74
+
75
+ return {
76
+ coluna_y: colunaY,
77
+ colunas_x: colunasX,
78
+ transformacao_y: fit.transformacao_y || contexto.transformacao_y || '(x)',
79
+ transformacoes_x: transformacoesX,
80
+ }
81
+ }
82
+
83
  function formatGeoColLabel(coluna, kind = 'default') {
84
  const valor = String(coluna || '')
85
  if (kind === 'cdlog' && valor.toUpperCase() === 'CTM') return 'CTM'
 
111
  .filter(Boolean)
112
  }
113
 
114
+ function obterLabelGrau(listaGraus, valor) {
115
+ const n = Number(valor)
116
+ const item = (listaGraus || []).find((g) => Number(g.value) === n)
117
+ return item?.label || 'Sem enquadramento'
118
+ }
119
+
120
  export default function ElaboracaoTab({ sessionId }) {
121
  const [loading, setLoading] = useState(false)
122
  const [error, setError] = useState('')
123
  const [status, setStatus] = useState('')
124
 
125
  const [uploadedFile, setUploadedFile] = useState(null)
126
+ const [uploadDragOver, setUploadDragOver] = useState(false)
127
  const [requiresSheet, setRequiresSheet] = useState(false)
128
  const [sheetOptions, setSheetOptions] = useState([])
129
  const [selectedSheet, setSelectedSheet] = useState('')
130
  const [elaborador, setElaborador] = useState(null)
131
+ const [modeloCarregadoInfo, setModeloCarregadoInfo] = useState(null)
132
 
133
  const [dados, setDados] = useState(null)
134
  const [mapaHtml, setMapaHtml] = useState('')
 
164
 
165
  const [transformacaoY, setTransformacaoY] = useState('(x)')
166
  const [transformacoesX, setTransformacoesX] = useState({})
167
+ const [transformacoesAplicadas, setTransformacoesAplicadas] = useState(null)
168
+ const [origemTransformacoes, setOrigemTransformacoes] = useState(null)
169
  const [section8Open, setSection8Open] = useState(false)
170
+ const [section10ManualOpen, setSection10ManualOpen] = useState(false)
171
  const [section11Open, setSection11Open] = useState(false)
172
 
173
  const [fit, setFit] = useState(null)
 
193
  const marcarTodasXRef = useRef(null)
194
  const classificarXReqRef = useRef(0)
195
  const deleteConfirmTimersRef = useRef({})
196
+ const uploadInputRef = useRef(null)
197
 
198
  const mapaChoices = useMemo(() => ['Visualização Padrão', ...colunasNumericas], [colunasNumericas])
199
  const colunasXDisponiveis = useMemo(
 
213
  if (!elaborador) return []
214
  return [elaborador.cargo, conselhoRegistro, elaborador.matricula_sem_digito ? `Matricula ${elaborador.matricula_sem_digito}` : '', elaborador.lotacao].filter(Boolean)
215
  }, [elaborador, conselhoRegistro])
216
+ const transformacaoYModeloBadge = useMemo(
217
+ () => formatTransformacaoBadge(modeloCarregadoInfo?.transformacao_y),
218
+ [modeloCarregadoInfo],
219
+ )
220
+ const variaveisIndependentesModeloBadge = useMemo(() => {
221
+ const colunas = modeloCarregadoInfo?.colunas_x || []
222
+ const transformacoes = modeloCarregadoInfo?.transformacoes_x || {}
223
+ return colunas.map((coluna) => ({
224
  coluna,
225
+ transformacao: formatTransformacaoBadge(transformacoes[coluna]),
226
  }))
227
+ }, [modeloCarregadoInfo])
228
+ const transformacaoAplicadaYBadge = useMemo(
229
+ () => formatTransformacaoBadge(transformacoesAplicadas?.transformacao_y),
230
+ [transformacoesAplicadas],
231
+ )
232
+ const transformacoesAplicadasXBadge = useMemo(() => {
233
+ const mapaX = transformacoesAplicadas?.transformacoes_x || {}
234
+ const ordemX = colunasX.length > 0 ? colunasX : Object.keys(mapaX)
235
+ return ordemX.map((coluna) => ({
236
+ coluna,
237
+ transformacao: formatTransformacaoBadge(mapaX[coluna]),
238
+ }))
239
+ }, [transformacoesAplicadas, colunasX])
240
+ const origemTransformacoesTexto = useMemo(() => {
241
+ if (!origemTransformacoes?.origem) return ''
242
+ if (origemTransformacoes.origem === 'modelo_original') {
243
+ return 'Origem: transformação original do modelo carregado.'
244
+ }
245
+ if (origemTransformacoes.origem === 'manual') {
246
+ return 'Origem: ajuste manual do usuário.'
247
+ }
248
+ if (origemTransformacoes.origem === 'sugestao') {
249
+ const numero = Number(origemTransformacoes.sugestao_numero || 0)
250
+ const grauCoefLabel = obterLabelGrau(GRAUS_COEF, origemTransformacoes.grau_coef)
251
+ const grauFLabel = obterLabelGrau(GRAUS_F, origemTransformacoes.grau_f)
252
+ return `Origem: sugestão #${Number.isFinite(numero) && numero > 0 ? numero : '?'} (Grau mínimo coef.: ${grauCoefLabel}; Grau mínimo teste F: ${grauFLabel}).`
253
+ }
254
+ return ''
255
+ }, [origemTransformacoes])
256
  const showCoordsPanel = Boolean(
257
  coordsInfo && (
258
  !coordsInfo.tem_coords ||
 
385
  setPercentuais([])
386
  setTransformacaoY('(x)')
387
  setTransformacoesX({})
388
+ setTransformacoesAplicadas(null)
389
+ setOrigemTransformacoes(null)
390
  setOutliersAnteriores([])
391
  setIteracao(1)
392
  setSelection(null)
 
411
  }
412
 
413
  if (resp.fit && !resetXSelection) {
414
+ const meta = String(resp.tipo || '').toLowerCase() === 'dai'
415
+ ? { origem: 'modelo_original' }
416
+ : null
417
+ applyFitResponse(resp.fit, meta)
418
  }
419
  }
420
 
421
  function applySelectionResponse(resp) {
422
  setSelection(resp)
423
  setSection8Open(false)
424
+ setSection10ManualOpen(false)
425
+ setTransformacoesAplicadas(null)
426
+ setOrigemTransformacoes(null)
427
  if (resp.transformacao_y) {
428
  setTransformacaoY(resp.transformacao_y)
429
  }
 
451
  }
452
  }
453
 
454
+ function applyFitResponse(resp, origemMeta = null) {
455
  setFit(resp)
456
  setSection11Open(false)
457
+ if (resp.transformacao_y) {
458
+ setTransformacaoY(resp.transformacao_y)
459
+ }
460
+ if (resp.transformacoes_x) {
461
+ setTransformacoesX(resp.transformacoes_x)
462
+ }
463
+ if (resp.transformacoes_aplicadas) {
464
+ setTransformacoesAplicadas(resp.transformacoes_aplicadas)
465
+ } else if (resp.transformacao_y || resp.transformacoes_x) {
466
+ setTransformacoesAplicadas({
467
+ coluna_y: colunaY,
468
+ transformacao_y: resp.transformacao_y || transformacaoY,
469
+ transformacoes_x: resp.transformacoes_x || transformacoesX,
470
+ })
471
+ }
472
+ if (origemMeta && origemMeta.origem) {
473
+ setOrigemTransformacoes(origemMeta)
474
+ } else if (resp.origem_transformacoes?.origem) {
475
+ setOrigemTransformacoes(resp.origem_transformacoes)
476
+ }
477
  setResumoOutliers(resp.resumo_outliers || resumoOutliers)
478
  setCamposAvaliacao(resp.avaliacao_campos || [])
479
  const init = {}
 
487
  setBaseValue('')
488
  }
489
 
490
+ async function onUploadClick(arquivo = null) {
491
+ const arquivoUpload = arquivo || uploadedFile
492
+ if (!arquivoUpload || !sessionId) return
493
  await withBusy(async () => {
494
  setMapaGerado(false)
495
  setMapaHtml('')
496
  setGeoAuto200(true)
497
+ const nomeArquivo = String(arquivoUpload?.name || '').toLowerCase()
498
  const uploadEhDai = nomeArquivo.endsWith('.dai')
499
  setTipoFonteDados(uploadEhDai ? 'dai' : 'tabular')
500
+ const resp = await api.uploadElaboracaoFile(sessionId, arquivoUpload)
501
  setManualMapError('')
502
  setGeoProcessError('')
503
  setGeoStatusHtml('')
504
  setGeoFalhasHtml('')
505
  setGeoCorrecoes([])
506
  setElaborador(resp.elaborador || null)
507
+ setModeloCarregadoInfo(buildLoadedModelInfo(resp))
508
  setAvaliadorSelecionado(resp.elaborador?.nome_completo || '')
509
  setRequiresSheet(Boolean(resp.requires_sheet))
510
  setSheetOptions(resp.sheets || [])
 
524
  setPercentuais([])
525
  setTransformacaoY('(x)')
526
  setTransformacoesX({})
527
+ setTransformacoesAplicadas(null)
528
+ setOrigemTransformacoes(null)
529
  setCamposAvaliacao([])
530
  valoresAvaliacaoRef.current = {}
531
  setAvaliacaoFormVersion((prev) => prev + 1)
 
535
  })
536
  }
537
 
538
+ function onUploadInputChange(event) {
539
+ const input = event.target
540
+ const file = input.files?.[0] ?? null
541
+ setUploadedFile(file)
542
+ input.value = ''
543
+ if (!file || loading) return
544
+ void onUploadClick(file)
545
+ }
546
+
547
+ function onUploadDropZoneDragOver(event) {
548
+ event.preventDefault()
549
+ event.dataTransfer.dropEffect = 'copy'
550
+ setUploadDragOver(true)
551
+ }
552
+
553
+ function onUploadDropZoneDragLeave(event) {
554
+ event.preventDefault()
555
+ if (!event.currentTarget.contains(event.relatedTarget)) {
556
+ setUploadDragOver(false)
557
+ }
558
+ }
559
+
560
+ function onUploadDropZoneDrop(event) {
561
+ event.preventDefault()
562
+ setUploadDragOver(false)
563
+ const file = event.dataTransfer?.files?.[0]
564
+ if (!file || loading) return
565
+ setUploadedFile(file)
566
+ void onUploadClick(file)
567
+ }
568
+
569
  async function onConfirmSheet() {
570
  if (!selectedSheet || !sessionId) return
571
  await withBusy(async () => {
 
580
  setGeoFalhasHtml('')
581
  setGeoCorrecoes([])
582
  setElaborador(resp.elaborador || null)
583
+ setModeloCarregadoInfo(null)
584
  setAvaliadorSelecionado(resp.elaborador?.nome_completo || '')
585
  setRequiresSheet(false)
586
  applyBaseResponse(resp, { resetXSelection: true })
 
719
  async function onAdoptSuggestion(idx) {
720
  if (!sessionId) return
721
  await withBusy(async () => {
722
+ const adopted = await api.adoptSuggestion(sessionId, idx)
723
+ const transformacaoYAdotada = adopted.transformacao_y || '(x)'
724
+ const transformacoesXAdotadas = adopted.transformacoes_x || {}
725
+ const sugestao = selection?.busca?.resultados?.[idx] || null
726
+ const sugestaoNumero = Number(sugestao?.rank ?? idx + 1)
727
+ setTransformacaoY(transformacaoYAdotada)
728
+ setTransformacoesX(transformacoesXAdotadas)
729
+
730
+ const fitResp = await api.fitModel({
731
+ session_id: sessionId,
732
+ transformacao_y: transformacaoYAdotada,
733
+ transformacoes_x: transformacoesXAdotadas,
734
+ dicotomicas,
735
+ codigo_alocado: codigoAlocado,
736
+ percentuais,
737
+ })
738
+ applyFitResponse(fitResp, {
739
+ origem: 'sugestao',
740
+ sugestao_numero: Number.isFinite(sugestaoNumero) ? sugestaoNumero : null,
741
+ grau_coef: grauCoef,
742
+ grau_f: grauF,
743
+ })
744
  })
745
  }
746
 
 
755
  codigo_alocado: codigoAlocado,
756
  percentuais,
757
  })
758
+ applyFitResponse(resp, { origem: 'manual' })
759
  })
760
  }
761
 
 
807
  setOutliersTexto('')
808
  setReincluirTexto('')
809
  setFit(null)
810
+ setTransformacoesAplicadas(null)
811
+ setOrigemTransformacoes(null)
812
  setResultadoAvaliacaoHtml('')
813
  setResumoOutliers(resp.resumo_outliers || resumoOutliers)
814
  if (typeof window !== 'undefined') {
 
828
  setIteracao(1)
829
  setSelection(null)
830
  setFit(null)
831
+ setTransformacoesAplicadas(null)
832
+ setOrigemTransformacoes(null)
833
  setCamposAvaliacao([])
834
  valoresAvaliacaoRef.current = {}
835
  setAvaliacaoFormVersion((prev) => prev + 1)
 
1004
  <div className="section1-groups">
1005
  <div className="subpanel section1-group">
1006
  <h4>Carregar modelo</h4>
1007
+ <div
1008
+ className={`upload-dropzone${uploadDragOver ? ' is-dragover' : ''}`}
1009
+ onDragOver={onUploadDropZoneDragOver}
1010
+ onDragEnter={onUploadDropZoneDragOver}
1011
+ onDragLeave={onUploadDropZoneDragLeave}
1012
+ onDrop={onUploadDropZoneDrop}
1013
+ >
1014
+ <input
1015
+ ref={uploadInputRef}
1016
+ type="file"
1017
+ className="upload-hidden-input"
1018
+ onChange={onUploadInputChange}
1019
+ />
1020
+ <div className="row upload-dropzone-main">
1021
+ <button
1022
+ type="button"
1023
+ className="btn-upload-select"
1024
+ onClick={() => uploadInputRef.current?.click()}
1025
+ disabled={loading}
1026
+ >
1027
+ Selecionar arquivo
1028
+ </button>
1029
+ </div>
1030
+ <div className="upload-dropzone-hint">Ou arraste e solte aqui para carregar automaticamente.</div>
1031
+ <div className="upload-dropzone-file">
1032
+ {uploadedFile ? `Arquivo selecionado: ${uploadedFile.name}` : 'Nenhum arquivo selecionado.'}
1033
+ </div>
1034
  </div>
1035
 
1036
  {requiresSheet ? (
 
1047
 
1048
  <div className="subpanel section1-group">
1049
  <h4>Informações do modelo</h4>
1050
+ {modeloCarregadoInfo ? (
1051
  <div className="modelo-info-card">
1052
  <div className="modelo-info-split">
1053
  <div className="modelo-info-col">
1054
  <div className="elaborador-badge-title">Modelo elaborado por:</div>
1055
+ {elaborador?.nome_completo ? (
1056
+ <div className="elaborador-badge-name">{elaborador.nome_completo}</div>
1057
+ ) : (
1058
+ <div className="section1-empty-hint">Elaborador não informado no arquivo.</div>
1059
+ )}
1060
+ {elaboradorMeta.length > 0 && elaborador?.nome_completo ? (
1061
  <div className="elaborador-badge-meta">{elaboradorMeta.join(' | ')}</div>
1062
  ) : null}
1063
  </div>
1064
 
1065
  <div className="modelo-info-col modelo-info-col-vars">
1066
  <div className="elaborador-badge-title">Variáveis do modelo carregado</div>
1067
+ {modeloCarregadoInfo.coluna_y ? (
1068
  <div className="variavel-badge-line">
1069
  <span className="variavel-badge-label">Dependente:</span>
1070
+ <span className="variavel-chip variavel-chip-y variavel-chip-inline">
1071
+ {modeloCarregadoInfo.coluna_y}
1072
+ <span className="variavel-chip-transform">{` ${transformacaoYModeloBadge}`}</span>
1073
  </span>
1074
  </div>
1075
  ) : (
1076
  <div className="section1-empty-hint">Variável dependente não encontrada no modelo carregado.</div>
1077
  )}
1078
+ {variaveisIndependentesModeloBadge.length > 0 ? (
1079
  <div className="variavel-badge-line">
1080
  <span className="variavel-badge-label">Independentes:</span>
1081
  <div className="variavel-chip-wrap">
1082
+ {variaveisIndependentesModeloBadge.map((item) => (
1083
  <span key={`ind-${item.coluna}`} className="variavel-chip">
1084
  {item.coluna}
1085
+ <span className="variavel-chip-transform">{` ${item.transformacao}`}</span>
1086
  </span>
1087
  ))}
1088
  </div>
 
1460
  <div className="row">
1461
  <button onClick={onApplySelection} disabled={loading || !colunaY || colunasX.length === 0}>Aplicar seleção</button>
1462
  </div>
1463
+ {selection?.aviso_multicolinearidade?.visible ? (
1464
+ <div dangerouslySetInnerHTML={{ __html: selection.aviso_multicolinearidade.html }} />
1465
+ ) : null}
1466
+ {selection?.aviso_faltantes?.visible ? (
1467
+ <div dangerouslySetInnerHTML={{ __html: selection.aviso_faltantes.html || '' }} />
1468
+ ) : null}
1469
  </SectionBlock>
1470
  </>
1471
  ) : null}
 
1478
 
1479
  <SectionBlock step="7" title="Teste de Micronumerosidade" subtitle="Validação de amostra mínima para variáveis selecionadas.">
1480
  <div dangerouslySetInnerHTML={{ __html: selection.micronumerosidade_html || '' }} />
 
 
 
1481
  </SectionBlock>
1482
 
1483
  <SectionBlock step="8" title="Gráficos de Dispersão das Variáveis Independentes" subtitle="Leitura visual entre X e Y no conjunto filtrado.">
 
1520
  <div key={`sug-${item.rank || idx + 1}`} className="transform-suggestion-card">
1521
  <div className="transform-suggestion-head">
1522
  <span className="transform-suggestion-rank">#{item.rank || idx + 1}</span>
1523
+ <div className="transform-suggestion-metrics">
1524
+ <span className="transform-suggestion-r2">R² = {Number(item.r2 ?? 0).toFixed(4)}</span>
1525
+ <span className={grauBadgeClass(Number(item.grau_f ?? 0))}>
1526
+ Teste F: {GRAU_LABEL_CURTO[Number(item.grau_f ?? 0)] || 'Sem enq.'}
1527
+ </span>
1528
+ </div>
1529
  </div>
 
1530
  <div className="transform-suggestion-list">
1531
+ <div className="transform-suggestion-item transform-suggestion-item-y">
1532
+ <span className="transform-suggestion-col">{`${colunaY || 'Y'} (Y)`}</span>
1533
+ <span className="transform-suggestion-fn">{item.transformacao_y || '(x)'}</span>
1534
+ <span className="transform-suggestion-item-note">Dependente</span>
1535
+ </div>
1536
  {Object.entries(item.transformacoes_x || {}).map(([coluna, transf]) => {
1537
  const grau = Number(item.graus_coef?.[coluna] ?? 0)
1538
  return (
 
1544
  )
1545
  })}
1546
  </div>
1547
+ <button className="btn-adopt-model" onClick={() => onAdoptSuggestion(idx)} disabled={loading}>
1548
+ Adotar e Ajustar Modelo
 
 
 
 
 
1549
  </button>
1550
  </div>
1551
  ))}
 
1556
  </SectionBlock>
1557
 
1558
  <SectionBlock step="10" title="Aplicação das Transformações" subtitle="Configuração manual para ajuste do modelo.">
1559
+ <div className="manual-transform-toggle">
1560
+ <button
1561
+ type="button"
1562
+ className={section10ManualOpen ? 'btn-manual-toggle active' : 'btn-manual-toggle'}
1563
+ onClick={() => setSection10ManualOpen((prev) => !prev)}
1564
+ >
1565
+ {section10ManualOpen ? 'Ocultar ajustes manuais de transformação' : 'Proceceder com as transformações manualmente'}
1566
+ </button>
1567
  </div>
1568
 
1569
+ {section10ManualOpen ? (
1570
+ <>
1571
+ <div className="row">
1572
+ <label>Transformação de Y</label>
1573
+ <select value={transformacaoY} onChange={(e) => setTransformacaoY(e.target.value)}>
1574
+ {['(x)', '1/(x)', 'ln(x)', 'exp(x)', '(x)^2', 'raiz(x)', '1/raiz(x)'].map((item) => (
1575
+ <option key={`y-${item}`} value={item}>{item}</option>
 
 
 
 
1576
  ))}
1577
  </select>
1578
  </div>
 
 
1579
 
1580
+ <div className="transform-grid">
1581
+ {(selection.transform_fields || []).map((field) => (
1582
+ <div key={`tf-${field.coluna}`} className="transform-card">
1583
+ <span>{field.coluna}</span>
1584
+ <select
1585
+ value={transformacoesX[field.coluna] || '(x)'}
1586
+ onChange={(e) => setTransformacoesX((prev) => ({ ...prev, [field.coluna]: e.target.value }))}
1587
+ disabled={field.locked}
1588
+ >
1589
+ {(field.choices || []).map((choice) => (
1590
+ <option key={`${field.coluna}-${choice}`} value={choice}>{choice}</option>
1591
+ ))}
1592
+ </select>
1593
+ </div>
1594
+ ))}
1595
+ </div>
1596
+
1597
+ <button className="btn-fit-model" onClick={onFitModel} disabled={loading}>Aplicar transformações e ajustar modelo</button>
1598
+ </>
1599
+ ) : null}
1600
+ {transformacoesAplicadas?.coluna_y ? (
1601
+ <div className="transformacoes-aplicadas-wrap">
1602
+ <div className="modelo-info-card transformacoes-aplicadas-badge-card">
1603
+ <div className="modelo-info-col">
1604
+ <div className="elaborador-badge-title">Escalas / Transformações aplicadas</div>
1605
+ {origemTransformacoesTexto ? (
1606
+ <div className="transformacao-origem-info">{origemTransformacoesTexto}</div>
1607
+ ) : null}
1608
+ <div className="variavel-badge-line">
1609
+ <span className="variavel-badge-label">Dependente:</span>
1610
+ <span className="variavel-chip variavel-chip-y variavel-chip-inline">
1611
+ {transformacoesAplicadas.coluna_y}
1612
+ <span className="variavel-chip-transform">{` ${transformacaoAplicadaYBadge}`}</span>
1613
+ </span>
1614
+ </div>
1615
+ {transformacoesAplicadasXBadge.length > 0 ? (
1616
+ <div className="variavel-badge-line">
1617
+ <span className="variavel-badge-label">Independentes:</span>
1618
+ <div className="variavel-chip-wrap">
1619
+ {transformacoesAplicadasXBadge.map((item) => (
1620
+ <span key={`tx-apl-${item.coluna}`} className="variavel-chip">
1621
+ {item.coluna}
1622
+ <span className="variavel-chip-transform">{` ${item.transformacao}`}</span>
1623
+ </span>
1624
+ ))}
1625
+ </div>
1626
+ </div>
1627
+ ) : null}
1628
+ </div>
1629
+ </div>
1630
+ </div>
1631
+ ) : null}
1632
  </SectionBlock>
1633
  </>
1634
  ) : null}
frontend/src/components/VisualizacaoTab.jsx CHANGED
@@ -25,6 +25,7 @@ export default function VisualizacaoTab({ sessionId }) {
25
  const [badgeHtml, setBadgeHtml] = useState('')
26
 
27
  const [uploadedFile, setUploadedFile] = useState(null)
 
28
 
29
  const [dados, setDados] = useState(null)
30
  const [estatisticas, setEstatisticas] = useState(null)
@@ -53,6 +54,7 @@ export default function VisualizacaoTab({ sessionId }) {
53
 
54
  const [activeInnerTab, setActiveInnerTab] = useState('mapa')
55
  const deleteConfirmTimersRef = useRef({})
 
56
 
57
  function resetConteudoVisualizacao() {
58
  setDados(null)
@@ -126,11 +128,12 @@ export default function VisualizacaoTab({ sessionId }) {
126
  }
127
  }
128
 
129
- async function onUploadModel() {
130
- if (!sessionId || !uploadedFile) return
 
131
  await withBusy(async () => {
132
  resetConteudoVisualizacao()
133
- const uploadResp = await api.uploadVisualizacaoFile(sessionId, uploadedFile)
134
  setStatus(uploadResp.status || '')
135
  setBadgeHtml(uploadResp.badge_html || '')
136
 
@@ -139,6 +142,37 @@ export default function VisualizacaoTab({ sessionId }) {
139
  })
140
  }
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  async function onMapChange(value) {
143
  setMapaVar(value)
144
  if (!sessionId) return
@@ -242,9 +276,34 @@ export default function VisualizacaoTab({ sessionId }) {
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)} />
247
- <button onClick={onUploadModel} disabled={!uploadedFile || loading || !sessionId}>Carregar Modelo</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  </div>
249
  {status ? <div className="status-line">{status}</div> : null}
250
  {badgeHtml ? <div dangerouslySetInnerHTML={{ __html: badgeHtml }} /> : null}
 
25
  const [badgeHtml, setBadgeHtml] = useState('')
26
 
27
  const [uploadedFile, setUploadedFile] = useState(null)
28
+ const [uploadDragOver, setUploadDragOver] = useState(false)
29
 
30
  const [dados, setDados] = useState(null)
31
  const [estatisticas, setEstatisticas] = useState(null)
 
54
 
55
  const [activeInnerTab, setActiveInnerTab] = useState('mapa')
56
  const deleteConfirmTimersRef = useRef({})
57
+ const uploadInputRef = useRef(null)
58
 
59
  function resetConteudoVisualizacao() {
60
  setDados(null)
 
128
  }
129
  }
130
 
131
+ async function onUploadModel(arquivo = null) {
132
+ const arquivoUpload = arquivo || uploadedFile
133
+ if (!sessionId || !arquivoUpload) return
134
  await withBusy(async () => {
135
  resetConteudoVisualizacao()
136
+ const uploadResp = await api.uploadVisualizacaoFile(sessionId, arquivoUpload)
137
  setStatus(uploadResp.status || '')
138
  setBadgeHtml(uploadResp.badge_html || '')
139
 
 
142
  })
143
  }
144
 
145
+ function onUploadInputChange(event) {
146
+ const input = event.target
147
+ const file = input.files?.[0] ?? null
148
+ setUploadedFile(file)
149
+ input.value = ''
150
+ if (!file || loading) return
151
+ void onUploadModel(file)
152
+ }
153
+
154
+ function onUploadDropZoneDragOver(event) {
155
+ event.preventDefault()
156
+ event.dataTransfer.dropEffect = 'copy'
157
+ setUploadDragOver(true)
158
+ }
159
+
160
+ function onUploadDropZoneDragLeave(event) {
161
+ event.preventDefault()
162
+ if (!event.currentTarget.contains(event.relatedTarget)) {
163
+ setUploadDragOver(false)
164
+ }
165
+ }
166
+
167
+ function onUploadDropZoneDrop(event) {
168
+ event.preventDefault()
169
+ setUploadDragOver(false)
170
+ const file = event.dataTransfer?.files?.[0]
171
+ if (!file || loading) return
172
+ setUploadedFile(file)
173
+ void onUploadModel(file)
174
+ }
175
+
176
  async function onMapChange(value) {
177
  setMapaVar(value)
178
  if (!sessionId) return
 
276
  return (
277
  <div className="tab-content">
278
  <SectionBlock step="1" title="Carregar Modelo .dai" subtitle="Carregue o arquivo e o conteúdo será exibido automaticamente.">
279
+ <div
280
+ className={`upload-dropzone${uploadDragOver ? ' is-dragover' : ''}`}
281
+ onDragOver={onUploadDropZoneDragOver}
282
+ onDragEnter={onUploadDropZoneDragOver}
283
+ onDragLeave={onUploadDropZoneDragLeave}
284
+ onDrop={onUploadDropZoneDrop}
285
+ >
286
+ <input
287
+ ref={uploadInputRef}
288
+ type="file"
289
+ className="upload-hidden-input"
290
+ accept=".dai"
291
+ onChange={onUploadInputChange}
292
+ />
293
+ <div className="row upload-dropzone-main">
294
+ <button
295
+ type="button"
296
+ className="btn-upload-select"
297
+ onClick={() => uploadInputRef.current?.click()}
298
+ disabled={loading}
299
+ >
300
+ Selecionar arquivo
301
+ </button>
302
+ </div>
303
+ <div className="upload-dropzone-hint">Ou arraste e solte aqui para carregar automaticamente.</div>
304
+ <div className="upload-dropzone-file">
305
+ {uploadedFile ? `Arquivo selecionado: ${uploadedFile.name}` : 'Nenhum arquivo selecionado.'}
306
+ </div>
307
  </div>
308
  {status ? <div className="status-line">{status}</div> : null}
309
  {badgeHtml ? <div dangerouslySetInnerHTML={{ __html: badgeHtml }} /> : null}
frontend/src/styles.css CHANGED
@@ -520,6 +520,49 @@ button:disabled {
520
  background: #fff;
521
  }
522
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  .coords-section-groups {
524
  display: grid;
525
  gap: 14px;
@@ -684,6 +727,12 @@ button:disabled {
684
  color: #1f4e7b;
685
  }
686
 
 
 
 
 
 
 
687
  .variavel-chip-transform {
688
  color: #6a7f94;
689
  font-weight: 600;
@@ -823,15 +872,15 @@ button:disabled {
823
  }
824
 
825
  .compact-option-group {
826
- margin: 12px 0 14px;
827
- padding: 11px 12px;
828
  border: 1px solid #dbe6f1;
829
  border-radius: 12px;
830
  background: #fbfdff;
831
  }
832
 
833
  .compact-option-group + .compact-option-group {
834
- margin-top: 16px;
835
  }
836
 
837
  .compact-option-group h4 {
@@ -942,9 +991,17 @@ button:disabled {
942
 
943
  .transform-suggestion-head {
944
  display: flex;
945
- align-items: center;
946
  justify-content: space-between;
947
  gap: 8px;
 
 
 
 
 
 
 
 
948
  }
949
 
950
  .transform-suggestion-rank {
@@ -971,7 +1028,24 @@ button:disabled {
971
 
972
  .transform-suggestion-list {
973
  display: grid;
974
- gap: 6px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
975
  }
976
 
977
  .transform-suggestion-item {
@@ -985,6 +1059,11 @@ button:disabled {
985
  background: #f9fbfd;
986
  }
987
 
 
 
 
 
 
988
  .transform-suggestion-col {
989
  color: #2e4358;
990
  font-weight: 700;
@@ -1000,6 +1079,13 @@ button:disabled {
1000
  font-family: 'JetBrains Mono', monospace;
1001
  }
1002
 
 
 
 
 
 
 
 
1003
  .transform-suggestion-foot {
1004
  padding-top: 3px;
1005
  border-top: 1px solid #e9eef4;
@@ -1047,6 +1133,16 @@ button:disabled {
1047
  justify-content: center;
1048
  }
1049
 
 
 
 
 
 
 
 
 
 
 
1050
  .plot-grid-2 {
1051
  display: grid;
1052
  grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
@@ -1333,6 +1429,27 @@ button:disabled {
1333
  margin-bottom: 10px;
1334
  }
1335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1336
  .geo-correcoes {
1337
  display: grid;
1338
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
@@ -1635,6 +1752,11 @@ button:disabled {
1635
  margin-top: 12px;
1636
  }
1637
 
 
 
 
 
 
1638
  .micro-card {
1639
  margin: 0;
1640
  padding: 10px 11px;
@@ -1655,7 +1777,7 @@ button:disabled {
1655
  align-items: center;
1656
  justify-content: flex-start;
1657
  gap: 6px;
1658
- margin-bottom: 9px;
1659
  }
1660
 
1661
  .micro-title {
 
520
  background: #fff;
521
  }
522
 
523
+ .upload-dropzone {
524
+ border: 1px dashed #c5d5e5;
525
+ border-radius: 12px;
526
+ background: linear-gradient(180deg, #f9fcff 0%, #f4f9ff 100%);
527
+ padding: 11px 12px;
528
+ transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
529
+ }
530
+
531
+ .upload-dropzone.is-dragover {
532
+ border-color: #3b7fb8;
533
+ background: linear-gradient(180deg, #f3f9ff 0%, #ebf4ff 100%);
534
+ box-shadow: 0 0 0 1px rgba(59, 127, 184, 0.12);
535
+ }
536
+
537
+ .upload-hidden-input {
538
+ display: none;
539
+ }
540
+
541
+ .upload-dropzone-main {
542
+ margin-bottom: 6px;
543
+ }
544
+
545
+ button.btn-upload-select {
546
+ --btn-bg-start: #4a90c8;
547
+ --btn-bg-end: #3978ab;
548
+ --btn-border: #346f9f;
549
+ --btn-shadow-soft: rgba(53, 113, 157, 0.2);
550
+ --btn-shadow-strong: rgba(53, 113, 157, 0.25);
551
+ }
552
+
553
+ .upload-dropzone-hint {
554
+ color: #4d647b;
555
+ font-size: 0.83rem;
556
+ }
557
+
558
+ .upload-dropzone-file {
559
+ margin-top: 4px;
560
+ color: #2f4358;
561
+ font-size: 0.84rem;
562
+ font-weight: 700;
563
+ word-break: break-word;
564
+ }
565
+
566
  .coords-section-groups {
567
  display: grid;
568
  gap: 14px;
 
727
  color: #1f4e7b;
728
  }
729
 
730
+ .variavel-chip-inline {
731
+ justify-self: start;
732
+ width: fit-content;
733
+ max-width: 100%;
734
+ }
735
+
736
  .variavel-chip-transform {
737
  color: #6a7f94;
738
  font-weight: 600;
 
872
  }
873
 
874
  .compact-option-group {
875
+ margin: 14px 0 18px;
876
+ padding: 12px 13px;
877
  border: 1px solid #dbe6f1;
878
  border-radius: 12px;
879
  background: #fbfdff;
880
  }
881
 
882
  .compact-option-group + .compact-option-group {
883
+ margin-top: 24px;
884
  }
885
 
886
  .compact-option-group h4 {
 
991
 
992
  .transform-suggestion-head {
993
  display: flex;
994
+ align-items: flex-start;
995
  justify-content: space-between;
996
  gap: 8px;
997
+ margin-bottom: 16px;
998
+ }
999
+
1000
+ .transform-suggestion-metrics {
1001
+ display: flex;
1002
+ flex-direction: column;
1003
+ align-items: flex-end;
1004
+ gap: 7px;
1005
  }
1006
 
1007
  .transform-suggestion-rank {
 
1028
 
1029
  .transform-suggestion-list {
1030
  display: grid;
1031
+ gap: 8px;
1032
+ margin-top: 2px;
1033
+ }
1034
+
1035
+ .manual-transform-toggle {
1036
+ margin: 8px 0 10px;
1037
+ }
1038
+
1039
+ .btn-manual-toggle {
1040
+ min-width: 320px;
1041
+ }
1042
+
1043
+ .btn-manual-toggle.active {
1044
+ --btn-bg-start: #6b7f93;
1045
+ --btn-bg-end: #586b7d;
1046
+ --btn-border: #4a5c6e;
1047
+ --btn-shadow-soft: rgba(86, 105, 122, 0.18);
1048
+ --btn-shadow-strong: rgba(86, 105, 122, 0.24);
1049
  }
1050
 
1051
  .transform-suggestion-item {
 
1059
  background: #f9fbfd;
1060
  }
1061
 
1062
+ .transform-suggestion-item.transform-suggestion-item-y {
1063
+ border-color: #cfe1f4;
1064
+ background: #edf5fe;
1065
+ }
1066
+
1067
  .transform-suggestion-col {
1068
  color: #2e4358;
1069
  font-weight: 700;
 
1079
  font-family: 'JetBrains Mono', monospace;
1080
  }
1081
 
1082
+ .transform-suggestion-item-note {
1083
+ color: #4f6880;
1084
+ font-size: 0.74rem;
1085
+ font-weight: 700;
1086
+ white-space: nowrap;
1087
+ }
1088
+
1089
  .transform-suggestion-foot {
1090
  padding-top: 3px;
1091
  border-top: 1px solid #e9eef4;
 
1133
  justify-content: center;
1134
  }
1135
 
1136
+ .transform-suggestion-card button.btn-adopt-model {
1137
+ --btn-bg-start: #6b7f93;
1138
+ --btn-bg-end: #586b7d;
1139
+ --btn-border: #4a5c6e;
1140
+ --btn-shadow-soft: rgba(86, 105, 122, 0.19);
1141
+ --btn-shadow-strong: rgba(86, 105, 122, 0.25);
1142
+ font-size: 0.84rem;
1143
+ padding: 7px 12px;
1144
+ }
1145
+
1146
  .plot-grid-2 {
1147
  display: grid;
1148
  grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
 
1429
  margin-bottom: 10px;
1430
  }
1431
 
1432
+ .transformacoes-aplicadas-wrap {
1433
+ margin-top: 12px;
1434
+ }
1435
+
1436
+ .transformacoes-aplicadas-badge-card {
1437
+ margin-top: 0;
1438
+ }
1439
+
1440
+ .btn-fit-model {
1441
+ margin-top: 14px;
1442
+ margin-bottom: 16px;
1443
+ }
1444
+
1445
+ .transformacao-origem-info {
1446
+ margin-top: 6px;
1447
+ margin-bottom: 4px;
1448
+ color: #4e6378;
1449
+ font-size: 0.83rem;
1450
+ font-weight: 600;
1451
+ }
1452
+
1453
  .geo-correcoes {
1454
  display: grid;
1455
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
 
1752
  margin-top: 12px;
1753
  }
1754
 
1755
+ .micro-group-title {
1756
+ margin-top: 22px;
1757
+ margin-bottom: 18px;
1758
+ }
1759
+
1760
  .micro-card {
1761
  margin: 0;
1762
  padding: 10px 11px;
 
1777
  align-items: center;
1778
  justify-content: flex-start;
1779
  gap: 6px;
1780
+ margin-bottom: 13px;
1781
  }
1782
 
1783
  .micro-title {