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

update a lot of things

Browse files
backend/app/api/elaboracao.py CHANGED
@@ -160,6 +160,12 @@ def geocodificar_correcoes(payload: GeocodeCorrecaoPayload) -> dict[str, Any]:
160
  return elaboracao_service.aplicar_correcoes_geocodificacao(session, correcoes, auto_200=payload.auto_200)
161
 
162
 
 
 
 
 
 
 
163
  @router.post("/apply-selection")
164
  def apply_selection(payload: ApplySelectionPayload) -> dict[str, Any]:
165
  session = session_store.get(payload.session_id)
 
160
  return elaboracao_service.aplicar_correcoes_geocodificacao(session, correcoes, auto_200=payload.auto_200)
161
 
162
 
163
+ @router.post("/geocodificar-reiniciar")
164
+ def geocodificar_reiniciar(payload: SessionPayload) -> dict[str, Any]:
165
+ session = session_store.get(payload.session_id)
166
+ return elaboracao_service.reiniciar_geocodificacao(session)
167
+
168
+
169
  @router.post("/apply-selection")
170
  def apply_selection(payload: ApplySelectionPayload) -> dict[str, Any]:
171
  session = session_store.get(payload.session_id)
backend/app/core/elaboracao/charts.py CHANGED
@@ -93,8 +93,7 @@ def criar_graficos_dispersao(X, y, max_por_linha=3):
93
  if np.nanstd(x_valid) > 0 and np.nanstd(y_valid) > 0:
94
  corr = float(np.corrcoef(x_valid, y_valid)[0, 1])
95
 
96
- # Para variáveis dicotômicas, não desenha reta de regressão no gráfico de inspeção.
97
- if not eh_dicotomica and len(x_valid) >= 3 and np.nanstd(x_valid) > 0:
98
  a, b = np.polyfit(x_valid, y_valid, 1)
99
  x_sorted = np.sort(x_valid)
100
  y_linha = a * x_sorted + b
@@ -316,7 +315,7 @@ def criar_graficos_dispersao_residuos(X, residuos, max_por_linha=3):
316
 
317
  # Nome dos resíduos
318
  y_nome = "Resíduo Padronizado"
319
- y_vals = residuos
320
 
321
  titulos = [f"{col} × {y_nome}" for col in X.columns]
322
 
@@ -332,10 +331,10 @@ def criar_graficos_dispersao_residuos(X, residuos, max_por_linha=3):
332
  indices_x = X.index.values
333
 
334
  for idx, coluna in enumerate(X.columns):
335
- x_vals = X[coluna].values
336
 
337
  # Alinha dados válidos
338
- mask = ~(np.isnan(x_vals) | np.isnan(y_vals))
339
  x_valid = x_vals[mask]
340
  y_valid = y_vals[mask]
341
  indices_valid = indices_x[mask]
@@ -608,10 +607,15 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=
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
 
@@ -792,32 +796,6 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, indice_destacado=
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 = (
 
93
  if np.nanstd(x_valid) > 0 and np.nanstd(y_valid) > 0:
94
  corr = float(np.corrcoef(x_valid, y_valid)[0, 1])
95
 
96
+ if len(x_valid) >= 3 and np.nanstd(x_valid) > 0:
 
97
  a, b = np.polyfit(x_valid, y_valid, 1)
98
  x_sorted = np.sort(x_valid)
99
  y_linha = a * x_sorted + b
 
315
 
316
  # Nome dos resíduos
317
  y_nome = "Resíduo Padronizado"
318
+ y_vals = pd.to_numeric(pd.Series(residuos), errors="coerce").to_numpy()
319
 
320
  titulos = [f"{col} × {y_nome}" for col in X.columns]
321
 
 
331
  indices_x = X.index.values
332
 
333
  for idx, coluna in enumerate(X.columns):
334
+ x_vals = pd.to_numeric(X[coluna], errors="coerce").to_numpy()
335
 
336
  # Alinha dados válidos
337
+ mask = np.isfinite(x_vals) & np.isfinite(y_vals)
338
  x_valid = x_vals[mask]
339
  y_valid = y_vals[mask]
340
  indices_valid = indices_x[mask]
 
607
  df_mapa[lat_real] = pd.to_numeric(df_mapa[lat_real], errors="coerce")
608
  df_mapa[lon_real] = pd.to_numeric(df_mapa[lon_real], errors="coerce")
609
  df_mapa = df_mapa.dropna(subset=[lat_real, lon_real])
610
+
611
+ # Remove as coordenadas (0, 0) que distorcem totalmente o centro (Null Island)
612
+ df_mapa = df_mapa[~((df_mapa[lat_real] == 0.0) & (df_mapa[lon_real] == 0.0))]
613
+
614
  df_mapa = df_mapa[
615
  (df_mapa[lat_real] >= -90.0) & (df_mapa[lat_real] <= 90.0) &
616
  (df_mapa[lon_real] >= -180.0) & (df_mapa[lon_real] <= 180.0)
617
  ].copy()
618
+
619
  if df_mapa.empty:
620
  return "<p>Sem coordenadas válidas para exibir.</p>"
621
 
 
796
  else:
797
  bounds = [[lat_min, lon_min], [lat_max, lon_max]]
798
  m.fit_bounds(bounds, padding=(20, 20), max_zoom=17)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
799
 
800
  if houve_amostragem:
801
  aviso_html = (
backend/app/core/visualizacao/app.py CHANGED
@@ -893,32 +893,6 @@ def criar_mapa(df, lat_col="lat", lon_col="lon", cor_col=None, tamanho_col=None,
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()
 
893
  else:
894
  bounds = [[lat_min, lon_min], [lat_max, lon_max]]
895
  m.fit_bounds(bounds, padding=(20, 20), max_zoom=17)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896
 
897
  # Evita o wrapper de notebook (_repr_html_), que pode falhar dentro de iframe srcDoc.
898
  return m.get_root().render()
backend/app/models/session.py CHANGED
@@ -18,6 +18,7 @@ class SessionState:
18
 
19
  df_original: pd.DataFrame | None = None
20
  df_filtrado: pd.DataFrame | None = None
 
21
 
22
  coluna_y: str | None = None
23
  colunas_x: list[str] = field(default_factory=list)
 
18
 
19
  df_original: pd.DataFrame | None = None
20
  df_filtrado: pd.DataFrame | None = None
21
+ df_geo_origem: pd.DataFrame | None = None
22
 
23
  coluna_y: str | None = None
24
  colunas_x: list[str] = field(default_factory=list)
backend/app/services/elaboracao_service.py CHANGED
@@ -172,13 +172,20 @@ def _build_coords_payload(df: pd.DataFrame, tem_coords: bool) -> dict[str, Any]:
172
  }
173
 
174
 
175
- def _set_dataframe_base(session: SessionState, df: pd.DataFrame, clear_models: bool = True) -> dict[str, Any]:
 
 
 
 
 
176
  tem_coords, col_lat, col_lon = geocodificacao.verificar_coords(df)
177
  if tem_coords and col_lat and col_lon:
178
  df = geocodificacao.padronizar_coords(df, col_lat, col_lon)
179
 
180
  session.df_original = df.copy()
181
  session.df_filtrado = df.copy()
 
 
182
  session.geo_falhas_df = None
183
  session.geo_col_cdlog = None
184
  session.geo_col_num = None
@@ -592,7 +599,20 @@ def gerar_grafico_dispersao_modelo(session: SessionState, tipo: str) -> dict[str
592
  raise HTTPException(status_code=400, detail="Ajuste um modelo primeiro")
593
 
594
  try:
595
- if "Residuo" in tipo or "Resíduo" in tipo:
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  tabela = session.resultado_modelo.get("tabela_obs_calc")
597
  residuos = tabela["Resíduo Pad."].values if tabela is not None and "Resíduo Pad." in tabela.columns else None
598
  fig = charts.criar_graficos_dispersao_residuos(session.resultado_modelo["X_transformado"], residuos)
@@ -1034,6 +1054,9 @@ def geocodificar(session: SessionState, col_cdlog: str, col_num: str, auto_200:
1034
  if session.df_original is None:
1035
  raise HTTPException(status_code=400, detail="Carregue dados primeiro")
1036
 
 
 
 
1037
  try:
1038
  df_resultado, df_falhas, ajustados = geocodificacao.geocodificar(session.df_original, col_cdlog, col_num, auto_200=auto_200)
1039
  except Exception as exc:
@@ -1058,6 +1081,38 @@ def geocodificar(session: SessionState, col_cdlog: str, col_num: str, auto_200:
1058
  }
1059
 
1060
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1061
  def aplicar_correcoes_geocodificacao(
1062
  session: SessionState,
1063
  correcoes: list[dict[str, Any]],
 
172
  }
173
 
174
 
175
+ def _set_dataframe_base(
176
+ session: SessionState,
177
+ df: pd.DataFrame,
178
+ clear_models: bool = True,
179
+ save_geo_origin: bool = True,
180
+ ) -> dict[str, Any]:
181
  tem_coords, col_lat, col_lon = geocodificacao.verificar_coords(df)
182
  if tem_coords and col_lat and col_lon:
183
  df = geocodificacao.padronizar_coords(df, col_lat, col_lon)
184
 
185
  session.df_original = df.copy()
186
  session.df_filtrado = df.copy()
187
+ if save_geo_origin:
188
+ session.df_geo_origem = df.copy()
189
  session.geo_falhas_df = None
190
  session.geo_col_cdlog = None
191
  session.geo_col_num = None
 
599
  raise HTTPException(status_code=400, detail="Ajuste um modelo primeiro")
600
 
601
  try:
602
+ if "Nao Transformadas" in tipo or "Não Transformadas" in tipo:
603
+ tabela = session.resultado_modelo.get("tabela_obs_calc")
604
+ residuos = tabela["Resíduo Pad."].values if tabela is not None and "Resíduo Pad." in tabela.columns else None
605
+ indices = list(session.resultado_modelo.get("indices_usados", []) or [])
606
+ if session.df_filtrado is not None and session.colunas_x:
607
+ if indices:
608
+ x_base = session.df_filtrado.reindex(indices)
609
+ else:
610
+ x_base = session.df_filtrado
611
+ x_base = x_base.loc[:, [c for c in session.colunas_x if c in x_base.columns]]
612
+ else:
613
+ x_base = session.resultado_modelo["X_transformado"]
614
+ fig = charts.criar_graficos_dispersao_residuos(x_base, residuos)
615
+ elif "Residuo" in tipo or "Resíduo" in tipo:
616
  tabela = session.resultado_modelo.get("tabela_obs_calc")
617
  residuos = tabela["Resíduo Pad."].values if tabela is not None and "Resíduo Pad." in tabela.columns else None
618
  fig = charts.criar_graficos_dispersao_residuos(session.resultado_modelo["X_transformado"], residuos)
 
1054
  if session.df_original is None:
1055
  raise HTTPException(status_code=400, detail="Carregue dados primeiro")
1056
 
1057
+ if session.df_geo_origem is None:
1058
+ session.df_geo_origem = session.df_original.copy()
1059
+
1060
  try:
1061
  df_resultado, df_falhas, ajustados = geocodificacao.geocodificar(session.df_original, col_cdlog, col_num, auto_200=auto_200)
1062
  except Exception as exc:
 
1081
  }
1082
 
1083
 
1084
+ def reiniciar_geocodificacao(session: SessionState) -> dict[str, Any]:
1085
+ if session.df_geo_origem is None:
1086
+ raise HTTPException(status_code=400, detail="Não há base de origem para reiniciar a geocodificação")
1087
+
1088
+ df_base = session.df_geo_origem.copy()
1089
+ tem_coords, col_lat, col_lon = geocodificacao.verificar_coords(df_base)
1090
+ if tem_coords and col_lat and col_lon:
1091
+ df_base = geocodificacao.padronizar_coords(df_base, col_lat, col_lon)
1092
+
1093
+ session.df_original = df_base.copy()
1094
+ session.df_filtrado = df_base.drop(index=session.outliers_anteriores, errors="ignore")
1095
+ session.geo_falhas_df = None
1096
+ session.geo_col_cdlog = None
1097
+ session.geo_col_num = None
1098
+ session.mapa_habilitado = False
1099
+
1100
+ if tem_coords:
1101
+ status = "Base original já possui coordenadas; geocodificação reiniciada para o estado inicial."
1102
+ else:
1103
+ status = "Processo de geocodificação reiniciado."
1104
+
1105
+ return {
1106
+ "status": status,
1107
+ "status_html": "",
1108
+ "falhas_html": "",
1109
+ "falhas_para_correcao": dataframe_to_payload(pd.DataFrame(), 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(df_base, tem_coords),
1113
+ }
1114
+
1115
+
1116
  def aplicar_correcoes_geocodificacao(
1117
  session: SessionState,
1118
  correcoes: list[dict[str, Any]],
frontend/src/api.js CHANGED
@@ -70,6 +70,7 @@ export const api = {
70
  mapCoords: (sessionId, colLat, colLon) => postJson('/api/elaboracao/map-coords', { session_id: sessionId, col_lat: colLat, col_lon: colLon }),
71
  geocodificar: (sessionId, colCdlog, colNum, auto200) => postJson('/api/elaboracao/geocodificar', { session_id: sessionId, col_cdlog: colCdlog, col_num: colNum, auto_200: auto200 }),
72
  geocodificarCorrecoes: (sessionId, correcoes, auto200) => postJson('/api/elaboracao/geocodificar-correcoes', { session_id: sessionId, correcoes, auto_200: auto200 }),
 
73
 
74
  applySelection(payload) {
75
  return postJson('/api/elaboracao/apply-selection', payload)
 
70
  mapCoords: (sessionId, colLat, colLon) => postJson('/api/elaboracao/map-coords', { session_id: sessionId, col_lat: colLat, col_lon: colLon }),
71
  geocodificar: (sessionId, colCdlog, colNum, auto200) => postJson('/api/elaboracao/geocodificar', { session_id: sessionId, col_cdlog: colCdlog, col_num: colNum, auto_200: auto200 }),
72
  geocodificarCorrecoes: (sessionId, correcoes, auto200) => postJson('/api/elaboracao/geocodificar-correcoes', { session_id: sessionId, correcoes, auto_200: auto200 }),
73
+ geocodificarReiniciar: (sessionId) => postJson('/api/elaboracao/geocodificar-reiniciar', { session_id: sessionId }),
74
 
75
  applySelection(payload) {
76
  return postJson('/api/elaboracao/apply-selection', payload)
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -136,6 +136,8 @@ export default function ElaboracaoTab({ sessionId }) {
136
 
137
  const [transformacaoY, setTransformacaoY] = useState('(x)')
138
  const [transformacoesX, setTransformacoesX] = useState({})
 
 
139
 
140
  const [fit, setFit] = useState(null)
141
  const [tipoDispersao, setTipoDispersao] = useState('Variáveis Independentes Transformadas X Variável Dependente Transformada')
@@ -348,6 +350,7 @@ export default function ElaboracaoTab({ sessionId }) {
348
 
349
  function applySelectionResponse(resp) {
350
  setSelection(resp)
 
351
  if (resp.transformacao_y) {
352
  setTransformacaoY(resp.transformacao_y)
353
  }
@@ -377,6 +380,7 @@ export default function ElaboracaoTab({ sessionId }) {
377
 
378
  function applyFitResponse(resp) {
379
  setFit(resp)
 
380
  setResumoOutliers(resp.resumo_outliers || resumoOutliers)
381
  setCamposAvaliacao(resp.avaliacao_campos || [])
382
  const init = {}
@@ -520,6 +524,26 @@ export default function ElaboracaoTab({ sessionId }) {
520
  }
521
  }
522
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  function onPreencherCorrecoesSugestaoProxima() {
524
  setGeoCorrecoes((prev) => prev.map((item) => (
525
  item.sugestao_proxima
@@ -613,6 +637,18 @@ export default function ElaboracaoTab({ sessionId }) {
613
  })
614
  }
615
 
 
 
 
 
 
 
 
 
 
 
 
 
616
  async function onRestartIteration() {
617
  if (!sessionId) return
618
  await withBusy(async () => {
@@ -1012,6 +1048,12 @@ export default function ElaboracaoTab({ sessionId }) {
1012
  >
1013
  Voltar
1014
  </button>
 
 
 
 
 
 
1015
  </div>
1016
  <h4>Geocodificação por eixo</h4>
1017
  <div className="row">
@@ -1054,10 +1096,10 @@ export default function ElaboracaoTab({ sessionId }) {
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>
@@ -1084,7 +1126,7 @@ export default function ElaboracaoTab({ sessionId }) {
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>
@@ -1094,7 +1136,14 @@ export default function ElaboracaoTab({ sessionId }) {
1094
  ) : null}
1095
  </>
1096
  ) : (
1097
- <div className="section1-empty-hint">Coordenadas já disponíveis ou etapa concluída.</div>
 
 
 
 
 
 
 
1098
  )}
1099
  </div>
1100
  </SectionBlock>
@@ -1161,7 +1210,7 @@ export default function ElaboracaoTab({ sessionId }) {
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">
@@ -1192,7 +1241,7 @@ export default function ElaboracaoTab({ sessionId }) {
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) => (
@@ -1204,7 +1253,7 @@ export default function ElaboracaoTab({ sessionId }) {
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) => (
@@ -1216,7 +1265,7 @@ export default function ElaboracaoTab({ sessionId }) {
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) => (
@@ -1249,12 +1298,21 @@ export default function ElaboracaoTab({ sessionId }) {
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)"
1255
- forceHideLegend
1256
- className="plot-stretch"
1257
- />
 
 
 
 
 
 
 
 
 
1258
  </SectionBlock>
1259
 
1260
  <SectionBlock step="9" title="Transformações Sugeridas" subtitle="Busca automática de combinações por R² e enquadramento.">
@@ -1345,14 +1403,26 @@ export default function ElaboracaoTab({ sessionId }) {
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)}>
1351
- <option value="Variáveis Independentes Transformadas X Variável Dependente Transformada">X transformado x Y transformado</option>
1352
- <option value="Variáveis Independentes Transformadas X Resíduo Padronizado">X transformado x Resíduo</option>
1353
- </select>
1354
- </div>
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.">
@@ -1429,11 +1499,20 @@ export default function ElaboracaoTab({ sessionId }) {
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
 
 
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)
143
  const [tipoDispersao, setTipoDispersao] = useState('Variáveis Independentes Transformadas X Variável Dependente Transformada')
 
350
 
351
  function applySelectionResponse(resp) {
352
  setSelection(resp)
353
+ setSection8Open(false)
354
  if (resp.transformacao_y) {
355
  setTransformacaoY(resp.transformacao_y)
356
  }
 
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 = {}
 
524
  }
525
  }
526
 
527
+ async function onReiniciarGeocodificacao() {
528
+ if (!sessionId) return
529
+ await withBusy(async () => {
530
+ const resp = await api.geocodificarReiniciar(sessionId)
531
+ setStatus(resp.status || '')
532
+ setGeoStatusHtml(resp.status_html || '')
533
+ setGeoFalhasHtml(resp.falhas_html || '')
534
+ setGeoCorrecoes(parseCorrecoes(resp.falhas_para_correcao))
535
+ setMapaHtml(resp.mapa_html || '')
536
+ setDados(resp.dados || null)
537
+ setCoordsInfo(resp.coords || null)
538
+ setGeoCdlog(escolherColunaCdlogPadrao(resp.coords || null))
539
+ setGeoNum(resp.coords?.num_auto || '')
540
+ setGeoAuto200(true)
541
+ setManualMapError('')
542
+ setGeoProcessError('')
543
+ setCoordsMode(resp.coords?.tem_coords ? 'menu' : 'geocodificar')
544
+ })
545
+ }
546
+
547
  function onPreencherCorrecoesSugestaoProxima() {
548
  setGeoCorrecoes((prev) => prev.map((item) => (
549
  item.sugestao_proxima
 
637
  })
638
  }
639
 
640
+ function onAddFiltro() {
641
+ const variavelPadrao = fit?.variaveis_filtro?.[0] || 'Resíduo Pad.'
642
+ setFiltros((prev) => [...prev, { variavel: variavelPadrao, operador: '>=', valor: 0 }])
643
+ }
644
+
645
+ function onRemoveFiltro(index) {
646
+ setFiltros((prev) => {
647
+ if (prev.length <= 1) return prev
648
+ return prev.filter((_, idx) => idx !== index)
649
+ })
650
+ }
651
+
652
  async function onRestartIteration() {
653
  if (!sessionId) return
654
  await withBusy(async () => {
 
1048
  >
1049
  Voltar
1050
  </button>
1051
+ <button
1052
+ onClick={onReiniciarGeocodificacao}
1053
+ disabled={loading}
1054
+ >
1055
+ Reiniciar geocodificação
1056
+ </button>
1057
  </div>
1058
  <h4>Geocodificação por eixo</h4>
1059
  <div className="row">
 
1096
  <div className="subpanel coords-section-group coords-correcoes-group">
1097
  <h5>Correções manuais</h5>
1098
  <div className="row compact geo-correcoes-actions">
1099
+ <button type="button" className="btn-geo-fill" onClick={onPreencherCorrecoesSugestaoProxima} disabled={loading}>
1100
  Preencher todas com opção mais próxima
1101
  </button>
1102
+ <button type="button" className="btn-geo-clear" onClick={onLimparCorrecoesGeo} disabled={loading}>
1103
  Limpar preenchimentos
1104
  </button>
1105
  </div>
 
1126
  ))}
1127
  </div>
1128
  <div className="row geo-correcoes-apply-row">
1129
+ <button type="button" className="btn-geo-apply" onClick={onAplicarCorrecoesGeo} disabled={loading}>
1130
  Aplicar correções
1131
  </button>
1132
  </div>
 
1136
  ) : null}
1137
  </>
1138
  ) : (
1139
+ <div className="subpanel coords-section-group">
1140
+ <div className="section1-empty-hint">Coordenadas já disponíveis ou etapa concluída.</div>
1141
+ <div className="row coords-restart-row">
1142
+ <button type="button" onClick={onReiniciarGeocodificacao} disabled={loading}>
1143
+ Reiniciar geocodificação
1144
+ </button>
1145
+ </div>
1146
+ </div>
1147
  )}
1148
  </div>
1149
  </SectionBlock>
 
1210
  </SectionBlock>
1211
 
1212
  <SectionBlock step="5" title="Selecionar Variáveis Independentes" subtitle="Escolha regressoras e grupos de tipologia.">
1213
+ <div className="compact-option-group compact-option-group-x">
1214
  <h4>Variáveis Independentes (X)</h4>
1215
  <div className="checkbox-inline-wrap checkbox-inline-wrap-tools">
1216
  <label className="compact-checkbox compact-checkbox-toggle-all">
 
1241
  </div>
1242
  </div>
1243
 
1244
+ <div className="compact-option-group compact-option-group-dicotomicas">
1245
  <h4>Variáveis Dicotômicas (0/1)</h4>
1246
  <div className="checkbox-inline-wrap">
1247
  {colunasX.map((col) => (
 
1253
  </div>
1254
  </div>
1255
 
1256
+ <div className="compact-option-group compact-option-group-codigo">
1257
  <h4>Variáveis de Código Alocado/Ajustado</h4>
1258
  <div className="checkbox-inline-wrap">
1259
  {colunasX.map((col) => (
 
1265
  </div>
1266
  </div>
1267
 
1268
+ <div className="compact-option-group compact-option-group-percentuais">
1269
  <h4>Variáveis Percentuais (0 a 1)</h4>
1270
  <div className="checkbox-inline-wrap">
1271
  {colunasX.map((col) => (
 
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.">
1301
+ <details
1302
+ className="section-content-toggle"
1303
+ open={section8Open}
1304
+ onToggle={(event) => setSection8Open(event.currentTarget.open)}
1305
+ >
1306
+ <summary>Mostrar/Ocultar gráficos da seção</summary>
1307
+ {section8Open ? (
1308
+ <PlotFigure
1309
+ figure={selection.grafico_dispersao}
1310
+ title="Dispersão (dados filtrados)"
1311
+ forceHideLegend
1312
+ className="plot-stretch"
1313
+ />
1314
+ ) : null}
1315
+ </details>
1316
  </SectionBlock>
1317
 
1318
  <SectionBlock step="9" title="Transformações Sugeridas" subtitle="Busca automática de combinações por R² e enquadramento.">
 
1403
  {fit ? (
1404
  <>
1405
  <SectionBlock step="11" title="Gráficos de Dispersão (Variáveis Transformadas)" subtitle="Dispersão com variáveis já transformadas.">
1406
+ <details
1407
+ className="section-content-toggle"
1408
+ open={section11Open}
1409
+ onToggle={(event) => setSection11Open(event.currentTarget.open)}
1410
+ >
1411
+ <summary>Mostrar/Ocultar gráficos da seção</summary>
1412
+ {section11Open ? (
1413
+ <>
1414
+ <div className="row">
1415
+ <label>Tipo de dispersão</label>
1416
+ <select value={tipoDispersao} onChange={(e) => onTipoDispersaoChange(e.target.value)}>
1417
+ <option value="Variáveis Independentes Transformadas X Variável Dependente Transformada">X transformado x Y transformado</option>
1418
+ <option value="Variáveis Independentes Transformadas X Resíduo Padronizado">X transformado x Resíduo</option>
1419
+ <option value="Variáveis Independentes Não Transformadas X Resíduo Padronizado">X não transformado x Resíduo</option>
1420
+ </select>
1421
+ </div>
1422
+ <PlotFigure figure={fit.grafico_dispersao_modelo} title="Dispersão do modelo" forceHideLegend className="plot-stretch" />
1423
+ </>
1424
+ ) : null}
1425
+ </details>
1426
  </SectionBlock>
1427
 
1428
  <SectionBlock step="12" title="Diagnóstico de Modelo" subtitle="Resumo diagnóstico e tabelas principais do ajuste.">
 
1499
  setFiltros(next)
1500
  }}
1501
  />
1502
+ <button
1503
+ type="button"
1504
+ className="btn-filtro-remove"
1505
+ onClick={() => onRemoveFiltro(idx)}
1506
+ disabled={loading || filtros.length <= 1}
1507
+ >
1508
+ Remover
1509
+ </button>
1510
  </div>
1511
  ))}
1512
  </div>
1513
  <div className="outlier-actions-row">
1514
  <button onClick={onApplyOutlierFilters} disabled={loading}>Aplicar filtros</button>
1515
+ <button type="button" className="btn-filtro-add" onClick={onAddFiltro} disabled={loading}>Adicionar filtro</button>
1516
  </div>
1517
  </div>
1518
 
frontend/src/components/MapFrame.jsx CHANGED
@@ -1,16 +1,105 @@
1
- import React from 'react'
 
 
 
 
 
 
 
 
 
 
2
 
3
  export default function MapFrame({ html }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  if (!html) {
5
  return <div className="empty-box">Mapa indisponivel.</div>
6
  }
7
 
8
  return (
9
  <iframe
 
 
10
  title="mapa"
11
  className="map-frame"
12
  srcDoc={html}
13
  sandbox="allow-scripts allow-same-origin allow-popups"
 
14
  />
15
  )
16
  }
 
1
+ import React, { useCallback, useEffect, useMemo, useRef } from 'react'
2
+
3
+ function hashHtml(value) {
4
+ let hash = 0
5
+ const step = Math.max(1, Math.floor(value.length / 64))
6
+ for (let i = 0; i < value.length; i += step) {
7
+ hash = ((hash << 5) - hash) + value.charCodeAt(i)
8
+ hash |= 0
9
+ }
10
+ return `${value.length}-${Math.abs(hash)}`
11
+ }
12
 
13
  export default function MapFrame({ html }) {
14
+ const iframeRef = useRef(null)
15
+ const timersRef = useRef([])
16
+ const frameKey = useMemo(() => hashHtml(html || ''), [html])
17
+
18
+ const clearTimers = useCallback(() => {
19
+ timersRef.current.forEach((id) => window.clearTimeout(id))
20
+ timersRef.current = []
21
+ }, [])
22
+
23
+ const recenterFromLayers = useCallback(() => {
24
+ const iframe = iframeRef.current
25
+ if (!iframe) return
26
+
27
+ try {
28
+ const win = iframe.contentWindow
29
+ const doc = iframe.contentDocument || win?.document
30
+ if (!win || !doc || !win.L) return
31
+
32
+ const maps = Object.values(win).filter(
33
+ (item) => item
34
+ && typeof item.fitBounds === 'function'
35
+ && typeof item.eachLayer === 'function'
36
+ && typeof item.invalidateSize === 'function',
37
+ )
38
+
39
+ const map = maps[0]
40
+ if (!map) return
41
+
42
+ map.invalidateSize(true)
43
+ const bounds = win.L.latLngBounds([])
44
+
45
+ map.eachLayer((layer) => {
46
+ try {
47
+ if (typeof layer.getLatLng === 'function') {
48
+ const latlng = layer.getLatLng()
49
+ if (latlng && Number.isFinite(latlng.lat) && Number.isFinite(latlng.lng)) {
50
+ bounds.extend(latlng)
51
+ }
52
+ return
53
+ }
54
+
55
+ if (typeof layer.getBounds === 'function') {
56
+ const layerBounds = layer.getBounds()
57
+ if (layerBounds && typeof layerBounds.isValid === 'function' && layerBounds.isValid()) {
58
+ bounds.extend(layerBounds)
59
+ }
60
+ }
61
+ } catch {
62
+ // no-op
63
+ }
64
+ })
65
+
66
+ if (bounds.isValid()) {
67
+ map.fitBounds(bounds, { padding: [20, 20], maxZoom: 17, animate: false })
68
+ }
69
+ } catch {
70
+ // no-op
71
+ }
72
+ }, [])
73
+
74
+ const scheduleRecenter = useCallback(() => {
75
+ clearTimers()
76
+ ;[40, 180, 520, 1100].forEach((delay) => {
77
+ const timerId = window.setTimeout(() => {
78
+ recenterFromLayers()
79
+ }, delay)
80
+ timersRef.current.push(timerId)
81
+ })
82
+ }, [clearTimers, recenterFromLayers])
83
+
84
+ useEffect(() => {
85
+ return () => {
86
+ clearTimers()
87
+ }
88
+ }, [clearTimers])
89
+
90
  if (!html) {
91
  return <div className="empty-box">Mapa indisponivel.</div>
92
  }
93
 
94
  return (
95
  <iframe
96
+ key={frameKey}
97
+ ref={iframeRef}
98
  title="mapa"
99
  className="map-frame"
100
  srcDoc={html}
101
  sandbox="allow-scripts allow-same-origin allow-popups"
102
+ onLoad={scheduleRecenter}
103
  />
104
  )
105
  }
frontend/src/styles.css CHANGED
@@ -38,6 +38,7 @@ body {
38
  margin: 0;
39
  color: var(--ink-0);
40
  font-family: 'Nunito Sans', sans-serif;
 
41
  background:
42
  radial-gradient(1000px 430px at -8% -12%, #ffe8cf 0%, transparent 62%),
43
  radial-gradient(860px 360px at 105% 10%, #dbeaf6 0%, transparent 60%),
@@ -217,6 +218,7 @@ textarea {
217
  display: flex;
218
  flex-direction: column;
219
  gap: 24px;
 
220
  }
221
 
222
  .tab-pane[hidden] {
@@ -253,6 +255,8 @@ textarea {
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;
@@ -308,15 +312,25 @@ textarea {
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 {
@@ -384,6 +398,7 @@ textarea {
384
  flex-wrap: wrap;
385
  gap: 12px;
386
  margin-bottom: 12px;
 
387
  }
388
 
389
  .row.compact {
@@ -395,6 +410,7 @@ textarea {
395
  display: flex;
396
  flex-wrap: wrap;
397
  gap: 12px;
 
398
  }
399
 
400
  .avaliacao-actions-row {
@@ -474,6 +490,8 @@ button:disabled {
474
  background: #fcfdff;
475
  padding: 14px 15px;
476
  margin-bottom: 12px;
 
 
477
  }
478
 
479
  .subpanel.warning {
@@ -524,6 +542,11 @@ button:disabled {
524
  margin-bottom: 10px;
525
  }
526
 
 
 
 
 
 
527
  .section1-empty-hint {
528
  color: #5b7288;
529
  font-size: 0.86rem;
@@ -699,7 +722,9 @@ button:disabled {
699
  }
700
 
701
  .map-frame {
 
702
  width: 100%;
 
703
  min-height: 560px;
704
  border: 1px solid #d3dfe9;
705
  border-radius: 12px;
@@ -711,6 +736,7 @@ button:disabled {
711
  border: 1px solid #d8e2ec;
712
  border-radius: 12px;
713
  max-height: 360px;
 
714
  }
715
 
716
  .table-wrapper table {
@@ -797,16 +823,42 @@ button:disabled {
797
  }
798
 
799
  .compact-option-group {
800
- margin: 10px 0 14px;
 
 
 
 
 
 
 
 
801
  }
802
 
803
  .compact-option-group h4 {
804
- margin: 0 0 6px;
 
 
805
  color: #3a4f64;
806
  font-family: 'Sora', sans-serif;
807
  font-size: 0.84rem;
808
  }
809
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
810
  .checkbox-inline-wrap {
811
  display: flex;
812
  flex-wrap: wrap;
@@ -1041,7 +1093,7 @@ button:disabled {
1041
 
1042
  .filtro-row-react {
1043
  display: grid;
1044
- grid-template-columns: minmax(180px, 1.7fr) 120px minmax(120px, 0.8fr);
1045
  gap: 8px;
1046
  align-items: center;
1047
  border: 1px solid #e2e9f1;
@@ -1196,6 +1248,41 @@ button:disabled {
1196
  --btn-shadow-strong: rgba(94, 108, 122, 0.27);
1197
  }
1198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1199
  .resumo-outliers-box {
1200
  color: #4d647b;
1201
  font-weight: 700;
@@ -1208,6 +1295,44 @@ button:disabled {
1208
  margin-top: 8px;
1209
  }
1210
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1211
  .geo-correcoes {
1212
  display: grid;
1213
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
@@ -1632,7 +1757,7 @@ button:disabled {
1632
  }
1633
 
1634
  .filtro-row-react {
1635
- grid-template-columns: 1.2fr 110px minmax(110px, 0.8fr);
1636
  }
1637
 
1638
  .micro-msg-grid-codigo {
 
38
  margin: 0;
39
  color: var(--ink-0);
40
  font-family: 'Nunito Sans', sans-serif;
41
+ overflow-x: hidden;
42
  background:
43
  radial-gradient(1000px 430px at -8% -12%, #ffe8cf 0%, transparent 62%),
44
  radial-gradient(860px 360px at 105% 10%, #dbeaf6 0%, transparent 60%),
 
218
  display: flex;
219
  flex-direction: column;
220
  gap: 24px;
221
+ min-width: 0;
222
  }
223
 
224
  .tab-pane[hidden] {
 
255
  border: 1px solid #c9d6e3;
256
  border-radius: var(--radius-lg);
257
  background: var(--bg-2);
258
+ min-width: 0;
259
+ max-width: 100%;
260
  box-shadow:
261
  0 6px 18px rgba(20, 28, 36, 0.08),
262
  inset 0 0 0 1px #edf3f9;
 
312
 
313
  .section-body {
314
  padding: 18px;
315
+ min-width: 0;
316
+ max-width: 100%;
317
+ overflow-x: hidden;
318
+ }
319
+
320
+ .section-body > * {
321
+ min-width: 0;
322
+ max-width: 100%;
323
  }
324
 
325
  .dados-visualizacao-groups {
326
  display: grid;
327
  gap: 16px;
328
+ min-width: 0;
329
  }
330
 
331
  .dados-visualizacao-group {
332
  margin: 0;
333
+ min-width: 0;
334
  }
335
 
336
  .dados-outliers-resumo {
 
398
  flex-wrap: wrap;
399
  gap: 12px;
400
  margin-bottom: 12px;
401
+ min-width: 0;
402
  }
403
 
404
  .row.compact {
 
410
  display: flex;
411
  flex-wrap: wrap;
412
  gap: 12px;
413
+ min-width: 0;
414
  }
415
 
416
  .avaliacao-actions-row {
 
490
  background: #fcfdff;
491
  padding: 14px 15px;
492
  margin-bottom: 12px;
493
+ min-width: 0;
494
+ max-width: 100%;
495
  }
496
 
497
  .subpanel.warning {
 
542
  margin-bottom: 10px;
543
  }
544
 
545
+ .coords-restart-row {
546
+ margin-top: 10px;
547
+ margin-bottom: 0;
548
+ }
549
+
550
  .section1-empty-hint {
551
  color: #5b7288;
552
  font-size: 0.86rem;
 
722
  }
723
 
724
  .map-frame {
725
+ display: block;
726
  width: 100%;
727
+ max-width: 100%;
728
  min-height: 560px;
729
  border: 1px solid #d3dfe9;
730
  border-radius: 12px;
 
736
  border: 1px solid #d8e2ec;
737
  border-radius: 12px;
738
  max-height: 360px;
739
+ max-width: 100%;
740
  }
741
 
742
  .table-wrapper table {
 
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 {
838
+ margin: 0 0 9px;
839
+ padding-bottom: 6px;
840
+ border-bottom: 1px solid #e5edf5;
841
  color: #3a4f64;
842
  font-family: 'Sora', sans-serif;
843
  font-size: 0.84rem;
844
  }
845
 
846
+ .compact-option-group-x {
847
+ border-left: 4px solid #2f80cf;
848
+ }
849
+
850
+ .compact-option-group-dicotomicas {
851
+ border-left: 4px solid #269065;
852
+ }
853
+
854
+ .compact-option-group-codigo {
855
+ border-left: 4px solid #8f6f42;
856
+ }
857
+
858
+ .compact-option-group-percentuais {
859
+ border-left: 4px solid #7c5ba5;
860
+ }
861
+
862
  .checkbox-inline-wrap {
863
  display: flex;
864
  flex-wrap: wrap;
 
1093
 
1094
  .filtro-row-react {
1095
  display: grid;
1096
+ grid-template-columns: minmax(170px, 1.6fr) 120px minmax(120px, 0.9fr) auto;
1097
  gap: 8px;
1098
  align-items: center;
1099
  border: 1px solid #e2e9f1;
 
1248
  --btn-shadow-strong: rgba(94, 108, 122, 0.27);
1249
  }
1250
 
1251
+ .geo-correcoes-actions button.btn-geo-fill,
1252
+ .row button.btn-geo-fill {
1253
+ --btn-bg-start: #2f80cf;
1254
+ --btn-bg-end: #2368af;
1255
+ --btn-border: #1f5f9f;
1256
+ --btn-shadow-soft: rgba(47, 128, 207, 0.2);
1257
+ --btn-shadow-strong: rgba(47, 128, 207, 0.27);
1258
+ }
1259
+
1260
+ .geo-correcoes-actions button.btn-geo-clear,
1261
+ .row button.btn-geo-clear,
1262
+ .filtro-row-react button.btn-filtro-remove {
1263
+ --btn-bg-start: #6f7f90;
1264
+ --btn-bg-end: #576574;
1265
+ --btn-border: #4b5a69;
1266
+ --btn-shadow-soft: rgba(94, 108, 122, 0.2);
1267
+ --btn-shadow-strong: rgba(94, 108, 122, 0.27);
1268
+ }
1269
+
1270
+ .row button.btn-geo-apply {
1271
+ --btn-bg-start: #ff8c00;
1272
+ --btn-bg-end: #e67900;
1273
+ --btn-border: #cf6f00;
1274
+ --btn-shadow-soft: rgba(255, 140, 0, 0.2);
1275
+ --btn-shadow-strong: rgba(255, 140, 0, 0.28);
1276
+ }
1277
+
1278
+ .outlier-actions-row button.btn-filtro-add {
1279
+ --btn-bg-start: #2f80cf;
1280
+ --btn-bg-end: #2368af;
1281
+ --btn-border: #1f5f9f;
1282
+ --btn-shadow-soft: rgba(47, 128, 207, 0.2);
1283
+ --btn-shadow-strong: rgba(47, 128, 207, 0.27);
1284
+ }
1285
+
1286
  .resumo-outliers-box {
1287
  color: #4d647b;
1288
  font-weight: 700;
 
1295
  margin-top: 8px;
1296
  }
1297
 
1298
+ .section-content-toggle {
1299
+ border: 1px solid #dce7f1;
1300
+ border-radius: 12px;
1301
+ background: #fbfdff;
1302
+ padding: 10px;
1303
+ }
1304
+
1305
+ .section-content-toggle > summary {
1306
+ list-style: none;
1307
+ cursor: pointer;
1308
+ user-select: none;
1309
+ display: inline-flex;
1310
+ align-items: center;
1311
+ gap: 7px;
1312
+ font-family: 'Sora', sans-serif;
1313
+ color: #30485f;
1314
+ font-size: 0.86rem;
1315
+ }
1316
+
1317
+ .section-content-toggle > summary::-webkit-details-marker {
1318
+ display: none;
1319
+ }
1320
+
1321
+ .section-content-toggle > summary::before {
1322
+ content: '▸';
1323
+ color: #5f7489;
1324
+ font-size: 0.85rem;
1325
+ transition: transform 0.15s ease;
1326
+ }
1327
+
1328
+ .section-content-toggle[open] > summary::before {
1329
+ transform: rotate(90deg);
1330
+ }
1331
+
1332
+ .section-content-toggle[open] > summary {
1333
+ margin-bottom: 10px;
1334
+ }
1335
+
1336
  .geo-correcoes {
1337
  display: grid;
1338
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
 
1757
  }
1758
 
1759
  .filtro-row-react {
1760
+ grid-template-columns: 1.2fr 110px minmax(110px, 0.8fr) auto;
1761
  }
1762
 
1763
  .micro-msg-grid-codigo {