Guilherme Silberfarb Costa commited on
Commit
0a8b8ba
·
1 Parent(s): 2e13456

otimizacoes de funcoes

Browse files
backend/app/core/elaboracao/geocodificacao.py CHANGED
@@ -8,6 +8,7 @@ e, caso contrário, oferece geocodificação por interpolação em eixos de logr
8
  """
9
 
10
  import os
 
11
  import pandas as pd
12
 
13
  from .core import NOMES_LAT, NOMES_LON
@@ -214,20 +215,23 @@ def geocodificar(df, col_cdlog, col_num, auto_200=False):
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 = []
219
  falhas = []
220
  ajustados = []
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]
229
 
230
- if segmentos.empty:
231
  lats.append(None)
232
  lons.append(None)
233
  falhas.append({
@@ -240,106 +244,116 @@ def geocodificar(df, col_cdlog, col_num, auto_200=False):
240
  })
241
  continue
242
 
243
- # --- Passo 2: determinar lado par/ímpar ---
244
  lado = "Par" if numero % 2 == 0 else "Ímpar"
245
  ini_col, fim_col = (
246
  ("NRPARINI", "NRPARFIN") if lado == "Par" else ("NRIMPINI", "NRIMPFIN")
247
  )
248
 
249
- segmentos = segmentos.copy()
250
- segmentos[ini_col] = pd.to_numeric(segmentos[ini_col], errors="coerce")
251
- segmentos[fim_col] = pd.to_numeric(segmentos[fim_col], errors="coerce")
252
- segmentos = segmentos.dropna(subset=[ini_col, fim_col])
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
  # --- Passo 3: encontrar segmento com intervalo válido ---
255
- cond = (segmentos[ini_col] <= numero) & (segmentos[fim_col] >= numero)
256
- segmentos_validos = segmentos[cond]
257
-
258
- if segmentos_validos.empty:
259
- # --- Passo 5: intervalo não encontrado — gera sugestões ---
260
- sugestoes_str = ""
261
- numero_para_interpolar = None
262
-
263
- if not segmentos.empty:
264
- diffs = (segmentos[ini_col] - numero).abs()
265
- min_index = diffs.idxmin()
266
- linha_proxima = segmentos.loc[min_index]
267
-
268
- ini = linha_proxima[ini_col]
269
- fim = linha_proxima[fim_col]
270
-
271
- if pd.notna(ini) and pd.notna(fim):
272
- ini_i, fim_i = int(ini), int(fim)
273
- todos = list(range(ini_i, fim_i + 1))
274
- pares = sorted([n for n in todos if n % 2 == 0], key=lambda x: abs(x - numero))
275
- impares = sorted([n for n in todos if n % 2 != 0], key=lambda x: abs(x - numero))
276
- sugestoes = sorted(pares[:5] + impares[:5], key=lambda x: abs(x - numero))
277
- sugestoes_str = ", ".join(map(str, sugestoes))
278
-
279
- # Auto-correção ≤ 200
280
- if sugestoes and auto_200:
281
- melhor = sugestoes[0]
282
- diferenca = abs(numero - melhor)
283
- if diferenca <= 200:
284
- numero_para_interpolar = melhor
285
- ajustados.append({
286
- "idx": idx,
287
- "numero_original": numero,
288
- "numero_usado": melhor,
289
- })
290
-
291
- if numero_para_interpolar is not None:
292
- # Interpola com o número corrigido automaticamente
293
- cond2 = (segmentos[ini_col] <= numero_para_interpolar) & \
294
- (segmentos[fim_col] >= numero_para_interpolar)
295
- seg2 = segmentos[cond2]
296
- if seg2.empty:
297
- # Usa segmento mais próximo mesmo assim
298
- seg2 = segmentos.loc[[min_index]]
299
-
300
- linha = seg2.iloc[0]
301
- geom = linha.geometry
302
- ini_v = linha[ini_col]
303
- fim_v = linha[fim_col]
304
-
305
- if fim_v == ini_v:
306
- lats.append(None)
307
- lons.append(None)
308
- else:
309
- frac = (numero_para_interpolar - ini_v) / (fim_v - ini_v)
310
- frac = max(0.0, min(1.0, frac))
311
- ponto = geom.interpolate(geom.length * frac)
312
- lons.append(ponto.x)
313
- lats.append(ponto.y)
314
- else:
315
  lats.append(None)
316
  lons.append(None)
317
- falhas.append({
318
- "_idx": idx,
319
- "cdlog": cdlog,
320
- "numero_atual": numero,
321
- "motivo": "Numeração fora do intervalo",
322
- "sugestoes": sugestoes_str,
323
- "numero_corrigido": "",
324
- })
325
  continue
326
 
327
- # --- Passo 4: interpolação normal ---
328
- linha = segmentos_validos.iloc[0]
329
- geom = linha.geometry
330
- ini = linha[ini_col]
331
- fim = linha[fim_col]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
 
333
- if fim == ini:
334
- lats.append(None)
335
- lons.append(None)
 
 
 
 
 
 
 
 
 
 
 
336
  continue
337
 
338
- frac = (numero - ini) / (fim - ini)
339
- frac = max(0.0, min(1.0, frac))
340
- ponto = geom.interpolate(geom.length * frac)
341
- lons.append(ponto.x)
342
- lats.append(ponto.y)
 
 
 
 
 
343
 
344
  df["lat"] = lats
345
  df["lon"] = lons
@@ -408,18 +422,21 @@ def aplicar_correcoes_e_regeodificar(df_original, df_falhas, col_cdlog, col_num,
408
  if "_idx" not in df.columns:
409
  df["_idx"] = range(len(df))
410
 
411
- # Aplica correções e rastreia quais _idx foram corrigidos manualmente
412
- manuais = []
413
- for _, row in df_falhas.iterrows():
414
- corrigido = str(row.get("numero_corrigido", "")).strip()
415
- if corrigido and corrigido not in ("", "nan"):
416
- try:
417
- novo_num = int(float(corrigido))
418
- idx = row["_idx"]
419
- df.loc[df["_idx"] == idx, col_num] = novo_num
420
- manuais.append(idx)
421
- except (ValueError, TypeError):
422
- pass
 
 
 
423
 
424
  df_resultado, df_falhas_novas, ajustados = geocodificar(df, col_cdlog, col_num, auto_200)
425
  return df_resultado, df_falhas_novas, ajustados, manuais
 
8
  """
9
 
10
  import os
11
+ import numpy as np
12
  import pandas as pd
13
 
14
  from .core import NOMES_LAT, NOMES_LON
 
215
  df["__geo_cdlog"] = obter_serie_coluna(df, col_cdlog)
216
  df["__geo_num"] = pd.to_numeric(obter_serie_coluna(df, col_num), errors="coerce").fillna(0).astype(int)
217
 
218
+ # Pré-processa segmentos dos eixos apenas uma vez por execução.
219
+ eixos = gdf_eixos.copy()
220
+ for col in ("NRPARINI", "NRPARFIN", "NRIMPINI", "NRIMPFIN"):
221
+ if col in eixos.columns:
222
+ eixos[col] = pd.to_numeric(eixos[col], errors="coerce")
223
+ segmentos_por_cdlog = {cdlog: grupo.reset_index(drop=True) for cdlog, grupo in eixos.groupby("CDLOG", sort=False)}
224
+
225
  lats = []
226
  lons = []
227
  falhas = []
228
  ajustados = []
229
 
230
+ for idx, cdlog, numero_raw in df[["_idx", "__geo_cdlog", "__geo_num"]].itertuples(index=False, name=None):
231
+ numero = int(numero_raw)
232
+ segmentos = segmentos_por_cdlog.get(cdlog)
 
 
 
 
233
 
234
+ if segmentos is None or segmentos.empty:
235
  lats.append(None)
236
  lons.append(None)
237
  falhas.append({
 
244
  })
245
  continue
246
 
 
247
  lado = "Par" if numero % 2 == 0 else "Ímpar"
248
  ini_col, fim_col = (
249
  ("NRPARINI", "NRPARFIN") if lado == "Par" else ("NRIMPINI", "NRIMPFIN")
250
  )
251
 
252
+ if ini_col not in segmentos.columns or fim_col not in segmentos.columns:
253
+ lats.append(None)
254
+ lons.append(None)
255
+ falhas.append({
256
+ "_idx": idx,
257
+ "cdlog": cdlog,
258
+ "numero_atual": numero,
259
+ "motivo": "Numeração fora do intervalo",
260
+ "sugestoes": "",
261
+ "numero_corrigido": "",
262
+ })
263
+ continue
264
+
265
+ ini_vals = segmentos[ini_col].to_numpy(dtype=float, copy=False)
266
+ fim_vals = segmentos[fim_col].to_numpy(dtype=float, copy=False)
267
+ valid_mask = np.isfinite(ini_vals) & np.isfinite(fim_vals)
268
 
269
  # --- Passo 3: encontrar segmento com intervalo válido ---
270
+ cond = valid_mask & (ini_vals <= numero) & (fim_vals >= numero)
271
+
272
+ if cond.any():
273
+ pos = int(np.flatnonzero(cond)[0])
274
+ linha = segmentos.iloc[pos]
275
+ geom = linha.geometry
276
+ ini = ini_vals[pos]
277
+ fim = fim_vals[pos]
278
+
279
+ if fim == ini:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  lats.append(None)
281
  lons.append(None)
282
+ continue
283
+
284
+ frac = (numero - ini) / (fim - ini)
285
+ frac = max(0.0, min(1.0, frac))
286
+ ponto = geom.interpolate(geom.length * frac)
287
+ lons.append(ponto.x)
288
+ lats.append(ponto.y)
 
289
  continue
290
 
291
+ # --- Passo 5: intervalo não encontrado — gera sugestões ---
292
+ sugestoes_str = ""
293
+ numero_para_interpolar = None
294
+ min_pos = None
295
+ valid_positions = np.flatnonzero(valid_mask)
296
+
297
+ if valid_positions.size > 0:
298
+ ini_valid = ini_vals[valid_positions]
299
+ diffs = np.abs(ini_valid - numero)
300
+ min_pos = int(valid_positions[int(np.argmin(diffs))])
301
+
302
+ ini = ini_vals[min_pos]
303
+ fim = fim_vals[min_pos]
304
+
305
+ if pd.notna(ini) and pd.notna(fim):
306
+ ini_i, fim_i = int(ini), int(fim)
307
+ todos = list(range(ini_i, fim_i + 1))
308
+ pares = sorted([n for n in todos if n % 2 == 0], key=lambda x: abs(x - numero))
309
+ impares = sorted([n for n in todos if n % 2 != 0], key=lambda x: abs(x - numero))
310
+ sugestoes = sorted(pares[:5] + impares[:5], key=lambda x: abs(x - numero))
311
+ sugestoes_str = ", ".join(map(str, sugestoes))
312
+
313
+ if sugestoes and auto_200:
314
+ melhor = sugestoes[0]
315
+ diferenca = abs(numero - melhor)
316
+ if diferenca <= 200:
317
+ numero_para_interpolar = melhor
318
+ ajustados.append({
319
+ "idx": idx,
320
+ "numero_original": numero,
321
+ "numero_usado": melhor,
322
+ })
323
+
324
+ if numero_para_interpolar is not None and min_pos is not None:
325
+ cond2 = valid_mask & (ini_vals <= numero_para_interpolar) & (fim_vals >= numero_para_interpolar)
326
+ if cond2.any():
327
+ pos2 = int(np.flatnonzero(cond2)[0])
328
+ else:
329
+ pos2 = min_pos
330
 
331
+ linha = segmentos.iloc[pos2]
332
+ geom = linha.geometry
333
+ ini_v = ini_vals[pos2]
334
+ fim_v = fim_vals[pos2]
335
+
336
+ if fim_v == ini_v:
337
+ lats.append(None)
338
+ lons.append(None)
339
+ else:
340
+ frac = (numero_para_interpolar - ini_v) / (fim_v - ini_v)
341
+ frac = max(0.0, min(1.0, frac))
342
+ ponto = geom.interpolate(geom.length * frac)
343
+ lons.append(ponto.x)
344
+ lats.append(ponto.y)
345
  continue
346
 
347
+ lats.append(None)
348
+ lons.append(None)
349
+ falhas.append({
350
+ "_idx": idx,
351
+ "cdlog": cdlog,
352
+ "numero_atual": numero,
353
+ "motivo": "Numeração fora do intervalo",
354
+ "sugestoes": sugestoes_str,
355
+ "numero_corrigido": "",
356
+ })
357
 
358
  df["lat"] = lats
359
  df["lon"] = lons
 
422
  if "_idx" not in df.columns:
423
  df["_idx"] = range(len(df))
424
 
425
+ # Aplica correções de forma vetorizada por _idx.
426
+ manuais: list[int] = []
427
+ if isinstance(df_falhas, pd.DataFrame) and not df_falhas.empty and "_idx" in df_falhas.columns:
428
+ correcoes = df_falhas.loc[:, ["_idx", "numero_corrigido"]].copy()
429
+ correcoes["__novo_num"] = pd.to_numeric(correcoes["numero_corrigido"], errors="coerce")
430
+ correcoes = correcoes.dropna(subset=["_idx", "__novo_num"])
431
+ if not correcoes.empty:
432
+ correcoes["__novo_num"] = correcoes["__novo_num"].astype(int)
433
+ manuais = correcoes["_idx"].tolist()
434
+
435
+ mapa = correcoes.drop_duplicates(subset=["_idx"], keep="last").set_index("_idx")["__novo_num"]
436
+ novos = df["_idx"].map(mapa)
437
+ mask = novos.notna()
438
+ if mask.any():
439
+ df.loc[mask, col_num] = novos[mask].astype(int).to_numpy()
440
 
441
  df_resultado, df_falhas_novas, ajustados = geocodificar(df, col_cdlog, col_num, auto_200)
442
  return df_resultado, df_falhas_novas, ajustados, manuais
backend/app/services/elaboracao_service.py CHANGED
@@ -1408,11 +1408,12 @@ def aplicar_correcoes_geocodificacao(
1408
 
1409
  if "numero_corrigido" not in df_falhas.columns:
1410
  df_falhas["numero_corrigido"] = ""
1411
-
1412
- for idx, row in df_falhas.iterrows():
1413
- linha = int(row.get("_idx"))
1414
- valor = mapa_correcao.get(linha, "")
1415
- df_falhas.at[idx, "numero_corrigido"] = valor
 
1416
 
1417
  try:
1418
  df_resultado, df_falhas_novas, ajustados, manuais = geocodificacao.aplicar_correcoes_e_regeodificar(
 
1408
 
1409
  if "numero_corrigido" not in df_falhas.columns:
1410
  df_falhas["numero_corrigido"] = ""
1411
+ if mapa_correcao:
1412
+ linhas_ref = pd.to_numeric(df_falhas["_idx"], errors="coerce")
1413
+ valores_corrigidos = linhas_ref.map(mapa_correcao).fillna("")
1414
+ df_falhas["numero_corrigido"] = valores_corrigidos.astype(str)
1415
+ else:
1416
+ df_falhas["numero_corrigido"] = ""
1417
 
1418
  try:
1419
  df_resultado, df_falhas_novas, ajustados, manuais = geocodificacao.aplicar_correcoes_e_regeodificar(
backend/app/services/pesquisa_service.py CHANGED
@@ -11,6 +11,7 @@ from threading import Lock
11
  from typing import Any
12
 
13
  import folium
 
14
  import pandas as pd
15
  from fastapi import HTTPException
16
  from joblib import load
@@ -741,10 +742,9 @@ def _coletar_pontos_modelo(df_modelo: pd.DataFrame, limite_pontos: int) -> list[
741
  passo = max(1, math.ceil(len(base) / limite_pontos))
742
  base = base.iloc[::passo].head(limite_pontos)
743
 
744
- pontos: list[dict[str, float]] = []
745
- for _, row in base.iterrows():
746
- pontos.append({"lat": float(row[col_lat]), "lon": float(row[col_lon])})
747
- return pontos
748
 
749
 
750
  def _identificar_coluna_por_alias(colunas: Any, aliases: list[str]) -> str | None:
@@ -873,19 +873,17 @@ def _extrair_faixa_por_alias(estat_df: pd.DataFrame | None, aliases: list[str])
873
  if min_col is None or max_col is None:
874
  return None
875
 
876
- mins: list[Any] = []
877
- maxs: list[Any] = []
 
878
 
879
- for var, linha in estat_indexado.iterrows():
880
- nome = str(var)
881
- if not _has_alias(nome, aliases):
882
- continue
883
- min_val = linha.get(min_col)
884
- max_val = linha.get(max_col)
885
- if _is_empty(min_val) or _is_empty(max_val):
886
- continue
887
- mins.append(min_val)
888
- maxs.append(max_val)
889
 
890
  if not mins and not maxs:
891
  return None
@@ -912,14 +910,15 @@ def _resumo_variaveis(estat_df: pd.DataFrame | None, limite: int = 12) -> list[d
912
  return []
913
 
914
  linhas: list[dict[str, Any]] = []
915
- for var, linha in trabalho.iterrows():
916
- min_val = linha.get(min_col)
917
- max_val = linha.get(max_col)
 
918
  if _is_empty(min_val) and _is_empty(max_val):
919
  continue
920
  linhas.append(
921
  {
922
- "variavel": str(var),
923
  "min": sanitize_value(min_val),
924
  "max": sanitize_value(max_val),
925
  }
@@ -1605,13 +1604,15 @@ def _indexar_faixas_variaveis(estat_df: pd.DataFrame | None, variaveis_modelo: l
1605
  variaveis_norm = {_normalize(item) for item in (variaveis_modelo or []) if _str_or_none(item)}
1606
  indice: dict[str, dict[str, Any]] = {}
1607
 
1608
- for variavel, linha in trabalho.iterrows():
1609
- nome = str(variavel)
 
 
1610
  if variaveis_norm and _normalize(nome) not in variaveis_norm:
1611
  continue
1612
 
1613
- min_val = sanitize_value(linha.get(min_col))
1614
- max_val = sanitize_value(linha.get(max_col))
1615
  if _is_empty(min_val) and _is_empty(max_val):
1616
  continue
1617
  indice[nome] = {
 
11
  from typing import Any
12
 
13
  import folium
14
+ import numpy as np
15
  import pandas as pd
16
  from fastapi import HTTPException
17
  from joblib import load
 
742
  passo = max(1, math.ceil(len(base) / limite_pontos))
743
  base = base.iloc[::passo].head(limite_pontos)
744
 
745
+ lat_vals = base[col_lat].to_numpy(dtype=float, copy=False)
746
+ lon_vals = base[col_lon].to_numpy(dtype=float, copy=False)
747
+ return [{"lat": float(lat), "lon": float(lon)} for lat, lon in zip(lat_vals, lon_vals)]
 
748
 
749
 
750
  def _identificar_coluna_por_alias(colunas: Any, aliases: list[str]) -> str | None:
 
873
  if min_col is None or max_col is None:
874
  return None
875
 
876
+ nomes = estat_indexado.index.astype(str).tolist()
877
+ if not nomes:
878
+ return None
879
 
880
+ mask_alias = np.fromiter((_has_alias(nome, aliases) for nome in nomes), dtype=bool, count=len(nomes))
881
+ if not mask_alias.any():
882
+ return None
883
+
884
+ trabalho = estat_indexado.loc[mask_alias, [min_col, max_col]]
885
+ mins = [valor for valor in trabalho[min_col].tolist() if not _is_empty(valor)]
886
+ maxs = [valor for valor in trabalho[max_col].tolist() if not _is_empty(valor)]
 
 
 
887
 
888
  if not mins and not maxs:
889
  return None
 
910
  return []
911
 
912
  linhas: list[dict[str, Any]] = []
913
+ nomes = trabalho.index.astype(str).tolist()
914
+ mins = trabalho[min_col].tolist()
915
+ maxs = trabalho[max_col].tolist()
916
+ for var, min_val, max_val in zip(nomes, mins, maxs):
917
  if _is_empty(min_val) and _is_empty(max_val):
918
  continue
919
  linhas.append(
920
  {
921
+ "variavel": var,
922
  "min": sanitize_value(min_val),
923
  "max": sanitize_value(max_val),
924
  }
 
1604
  variaveis_norm = {_normalize(item) for item in (variaveis_modelo or []) if _str_or_none(item)}
1605
  indice: dict[str, dict[str, Any]] = {}
1606
 
1607
+ nomes = trabalho.index.astype(str).tolist()
1608
+ mins = trabalho[min_col].tolist()
1609
+ maxs = trabalho[max_col].tolist()
1610
+ for nome, min_bruto, max_bruto in zip(nomes, mins, maxs):
1611
  if variaveis_norm and _normalize(nome) not in variaveis_norm:
1612
  continue
1613
 
1614
+ min_val = sanitize_value(min_bruto)
1615
+ max_val = sanitize_value(max_bruto)
1616
  if _is_empty(min_val) and _is_empty(max_val):
1617
  continue
1618
  indice[nome] = {
backend/app/services/serializers.py CHANGED
@@ -67,7 +67,8 @@ def dataframe_to_payload(df: pd.DataFrame | None, decimals: int | None = None, m
67
  if df is None:
68
  return None
69
 
70
- df_work = df.copy()
 
71
  if decimals is not None:
72
  numeric_cols = [
73
  col
@@ -82,16 +83,18 @@ def dataframe_to_payload(df: pd.DataFrame | None, decimals: int | None = None, m
82
  if truncated:
83
  df_work = df_work.head(max_rows)
84
 
 
85
  rows: list[dict[str, Any]] = []
86
- for idx, row in df_work.iterrows():
87
- payload_row = {"_index": sanitize_value(idx)}
88
- for col, value in row.items():
89
- payload_row[str(col)] = sanitize_value(value)
 
90
  rows.append(payload_row)
91
 
92
- columns = ["_index"] + [str(c) for c in df_work.columns]
93
  return {
94
- "columns": columns,
95
  "rows": rows,
96
  "total_rows": total_rows,
97
  "returned_rows": len(rows),
 
67
  if df is None:
68
  return None
69
 
70
+ # Evita cópia quando não há transformação necessária.
71
+ df_work = df if decimals is None else df.copy()
72
  if decimals is not None:
73
  numeric_cols = [
74
  col
 
83
  if truncated:
84
  df_work = df_work.head(max_rows)
85
 
86
+ columns = [str(c) for c in df_work.columns]
87
  rows: list[dict[str, Any]] = []
88
+ # itertuples é significativamente mais leve que iterrows para payloads grandes.
89
+ for tuple_row in df_work.itertuples(index=True, name=None):
90
+ payload_row = {"_index": sanitize_value(tuple_row[0])}
91
+ for col, value in zip(columns, tuple_row[1:]):
92
+ payload_row[col] = sanitize_value(value)
93
  rows.append(payload_row)
94
 
95
+ columns_payload = ["_index"] + columns
96
  return {
97
+ "columns": columns_payload,
98
  "rows": rows,
99
  "total_rows": total_rows,
100
  "returned_rows": len(rows),
frontend/src/App.jsx CHANGED
@@ -64,11 +64,9 @@ export default function App() {
64
  <InicioTab />
65
  </div>
66
 
67
- {activeTab === 'Pesquisa' ? (
68
- <div className="tab-pane">
69
- <PesquisaTab />
70
- </div>
71
- ) : null}
72
 
73
  <div className="tab-pane" hidden={activeTab !== 'Elaboração/Edição'}>
74
  <ElaboracaoTab sessionId={sessionId} />
 
64
  <InicioTab />
65
  </div>
66
 
67
+ <div className="tab-pane" hidden={activeTab !== 'Pesquisa'}>
68
+ <PesquisaTab />
69
+ </div>
 
 
70
 
71
  <div className="tab-pane" hidden={activeTab !== 'Elaboração/Edição'}>
72
  <ElaboracaoTab sessionId={sessionId} />
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -1,5 +1,6 @@
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
 
3
  import DataTable from './DataTable'
4
  import LoadingOverlay from './LoadingOverlay'
5
  import MapFrame from './MapFrame'
@@ -45,6 +46,355 @@ function joinSelection(values) {
45
  return values.join(', ')
46
  }
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  function formatConselhoRegistro(elaborador) {
49
  if (!elaborador) return ''
50
  const conselho = String(elaborador.conselho || '').trim()
@@ -144,6 +494,7 @@ function obterLabelGrau(listaGraus, valor) {
144
 
145
  export default function ElaboracaoTab({ sessionId }) {
146
  const [loading, setLoading] = useState(false)
 
147
  const [error, setError] = useState('')
148
  const [status, setStatus] = useState('')
149
 
@@ -175,6 +526,7 @@ export default function ElaboracaoTab({ sessionId }) {
175
 
176
  const [colunasNumericas, setColunasNumericas] = useState([])
177
  const [colunaY, setColunaY] = useState('')
 
178
  const [colunasX, setColunasX] = useState([])
179
  const [dicotomicas, setDicotomicas] = useState([])
180
  const [codigoAlocado, setCodigoAlocado] = useState([])
@@ -182,6 +534,7 @@ export default function ElaboracaoTab({ sessionId }) {
182
  const [colunasDataMercado, setColunasDataMercado] = useState([])
183
  const [colunaDataMercadoSugerida, setColunaDataMercadoSugerida] = useState('')
184
  const [colunaDataMercado, setColunaDataMercado] = useState('')
 
185
  const [periodoDadosMercado, setPeriodoDadosMercado] = useState(null)
186
  const [periodoDadosMercadoPreview, setPeriodoDadosMercadoPreview] = useState(null)
187
  const [dataMercadoError, setDataMercadoError] = useState('')
@@ -214,6 +567,7 @@ export default function ElaboracaoTab({ sessionId }) {
214
  const [camposAvaliacao, setCamposAvaliacao] = useState([])
215
  const valoresAvaliacaoRef = useRef({})
216
  const [avaliacaoFormVersion, setAvaliacaoFormVersion] = useState(0)
 
217
  const [resultadoAvaliacaoHtml, setResultadoAvaliacaoHtml] = useState('')
218
  const [baseChoices, setBaseChoices] = useState([])
219
  const [baseValue, setBaseValue] = useState('')
@@ -222,6 +576,11 @@ export default function ElaboracaoTab({ sessionId }) {
222
  const [avaliadores, setAvaliadores] = useState([])
223
  const [avaliadorSelecionado, setAvaliadorSelecionado] = useState('')
224
  const [tipoFonteDados, setTipoFonteDados] = useState('')
 
 
 
 
 
225
  const marcarTodasXRef = useRef(null)
226
  const classificarXReqRef = useRef(0)
227
  const deleteConfirmTimersRef = useRef({})
@@ -229,7 +588,7 @@ export default function ElaboracaoTab({ sessionId }) {
229
 
230
  const mapaChoices = useMemo(() => ['Visualização Padrão', ...colunasNumericas], [colunasNumericas])
231
  const colunasXDisponiveis = useMemo(
232
- () => colunasNumericas.filter((coluna) => coluna !== colunaY),
233
  [colunasNumericas, colunaY],
234
  )
235
  const todasXMarcadas = useMemo(
@@ -269,6 +628,65 @@ export default function ElaboracaoTab({ sessionId }) {
269
  () => formatPeriodoDadosMercado(periodoDadosMercadoPreview),
270
  [periodoDadosMercadoPreview],
271
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  const transformacaoAplicadaYBadge = useMemo(
273
  () => formatTransformacaoBadge(transformacoesAplicadas?.transformacao_y),
274
  [transformacoesAplicadas],
@@ -297,6 +715,32 @@ export default function ElaboracaoTab({ sessionId }) {
297
  }
298
  return ''
299
  }, [origemTransformacoes])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  const showCoordsPanel = Boolean(
301
  coordsInfo && (
302
  !coordsInfo.tem_coords ||
@@ -408,6 +852,7 @@ export default function ElaboracaoTab({ sessionId }) {
408
 
409
  function applyBaseResponse(resp, options = {}) {
410
  const resetXSelection = Boolean(options.resetXSelection)
 
411
  setDataMercadoError('')
412
  if (resp.status) setStatus(resp.status)
413
  if (resp.dados) setDados(resp.dados)
@@ -420,44 +865,74 @@ export default function ElaboracaoTab({ sessionId }) {
420
  setColunaDataMercadoSugerida(String(resp.coluna_data_mercado_sugerida || ''))
421
  }
422
  if (Object.prototype.hasOwnProperty.call(resp, 'coluna_data_mercado')) {
423
- setColunaDataMercado(String(resp.coluna_data_mercado || ''))
 
 
424
  }
425
  if (Object.prototype.hasOwnProperty.call(resp, 'periodo_dados_mercado')) {
426
  const periodo = normalizePeriodoDadosMercado(resp.periodo_dados_mercado)
427
  setPeriodoDadosMercado(periodo)
428
  setPeriodoDadosMercadoPreview(periodo)
429
  }
430
- if (resp.coluna_y_padrao) setColunaY(resp.coluna_y_padrao)
 
 
 
431
 
432
  if (resp.contexto && !resetXSelection) {
433
- setColunaY(resp.contexto.coluna_y || resp.coluna_y_padrao || '')
434
- setColunasX(resp.contexto.colunas_x || [])
435
- setDicotomicas(resp.contexto.dicotomicas || [])
436
- setCodigoAlocado(resp.contexto.codigo_alocado || [])
437
- setPercentuais(resp.contexto.percentuais || [])
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  setOutliersAnteriores(resp.contexto.outliers_anteriores || [])
439
  setIteracao(resp.contexto.iteracao || 1)
440
  } else if (resetXSelection) {
 
 
441
  setColunasX([])
442
  setDicotomicas([])
443
  setCodigoAlocado([])
444
  setPercentuais([])
 
445
  setTransformacaoY('(x)')
446
  setTransformacoesX({})
447
  setTransformacoesAplicadas(null)
448
  setOrigemTransformacoes(null)
 
 
449
  setOutliersAnteriores([])
450
  setIteracao(1)
451
  setColunaDataMercadoSugerida('')
452
  setColunaDataMercado('')
 
453
  setPeriodoDadosMercado(null)
454
  setPeriodoDadosMercadoPreview(null)
455
  setDataMercadoError('')
456
  setSelection(null)
457
  setFit(null)
 
 
 
 
 
458
  setCamposAvaliacao([])
459
  valoresAvaliacaoRef.current = {}
460
  setAvaliacaoFormVersion((prev) => prev + 1)
 
461
  setResultadoAvaliacaoHtml('')
462
  setOutliersHtml('')
463
  }
@@ -488,6 +963,7 @@ export default function ElaboracaoTab({ sessionId }) {
488
  setSection10ManualOpen(false)
489
  setTransformacoesAplicadas(null)
490
  setOrigemTransformacoes(null)
 
491
  if (resp.transformacao_y) {
492
  setTransformacaoY(resp.transformacao_y)
493
  }
@@ -497,11 +973,15 @@ export default function ElaboracaoTab({ sessionId }) {
497
  map[field.coluna] = field.valor || '(x)'
498
  })
499
  setTransformacoesX(map)
 
500
 
 
 
501
  if (resp.busca) {
502
- setGrauCoef(resp.busca.grau_coef ?? 0)
503
- setGrauF(resp.busca.grau_f ?? 0)
504
  }
 
505
 
506
  if (resp.resumo_outliers) {
507
  setResumoOutliers(resp.resumo_outliers)
@@ -518,6 +998,8 @@ export default function ElaboracaoTab({ sessionId }) {
518
  function applyFitResponse(resp, origemMeta = null) {
519
  setFit(resp)
520
  setSection11Open(false)
 
 
521
  if (resp.transformacao_y) {
522
  setTransformacaoY(resp.transformacao_y)
523
  }
@@ -533,12 +1015,15 @@ export default function ElaboracaoTab({ sessionId }) {
533
  transformacoes_x: resp.transformacoes_x || transformacoesX,
534
  })
535
  }
 
536
  if (origemMeta && origemMeta.origem) {
537
  setOrigemTransformacoes(origemMeta)
538
  } else if (resp.origem_transformacoes?.origem) {
539
  setOrigemTransformacoes(resp.origem_transformacoes)
540
  }
541
  setResumoOutliers(resp.resumo_outliers || resumoOutliers)
 
 
542
  setCamposAvaliacao(resp.avaliacao_campos || [])
543
  const init = {}
544
  ;(resp.avaliacao_campos || []).forEach((campo) => {
@@ -546,6 +1031,7 @@ export default function ElaboracaoTab({ sessionId }) {
546
  })
547
  valoresAvaliacaoRef.current = init
548
  setAvaliacaoFormVersion((prev) => prev + 1)
 
549
  setResultadoAvaliacaoHtml('')
550
  setBaseChoices([])
551
  setBaseValue('')
@@ -580,8 +1066,11 @@ export default function ElaboracaoTab({ sessionId }) {
580
  applyBaseResponse(resp, { resetXSelection })
581
  } else if (resp.status) {
582
  setTipoFonteDados('tabular')
 
 
583
  setSelection(null)
584
  setFit(null)
 
585
  setColunasX([])
586
  setDicotomicas([])
587
  setCodigoAlocado([])
@@ -590,15 +1079,24 @@ export default function ElaboracaoTab({ sessionId }) {
590
  setTransformacoesX({})
591
  setTransformacoesAplicadas(null)
592
  setOrigemTransformacoes(null)
 
 
593
  setColunasDataMercado([])
594
  setColunaDataMercadoSugerida('')
595
  setColunaDataMercado('')
 
596
  setPeriodoDadosMercado(null)
597
  setPeriodoDadosMercadoPreview(null)
598
  setDataMercadoError('')
 
 
 
 
 
599
  setCamposAvaliacao([])
600
  valoresAvaliacaoRef.current = {}
601
  setAvaliacaoFormVersion((prev) => prev + 1)
 
602
  setResultadoAvaliacaoHtml('')
603
  setStatus(resp.status)
604
  }
@@ -661,6 +1159,7 @@ export default function ElaboracaoTab({ sessionId }) {
661
  const coluna = String(value || '')
662
  setColunaDataMercado(coluna)
663
  setDataMercadoError('')
 
664
 
665
  if (!sessionId || !coluna) {
666
  setPeriodoDadosMercadoPreview(null)
@@ -672,7 +1171,6 @@ export default function ElaboracaoTab({ sessionId }) {
672
  const resp = await api.previewMarketDateColumn(sessionId, coluna)
673
  const periodo = normalizePeriodoDadosMercado(resp.periodo_dados_mercado)
674
  setPeriodoDadosMercadoPreview(periodo)
675
- if (resp.status) setStatus(resp.status)
676
  } catch (err) {
677
  setPeriodoDadosMercadoPreview(null)
678
  setDataMercadoError(err.message || 'Falha ao identificar período da coluna selecionada.')
@@ -686,7 +1184,9 @@ export default function ElaboracaoTab({ sessionId }) {
686
  await withBusy(async () => {
687
  const resp = await api.applyMarketDateColumn(sessionId, colunaDataMercado)
688
  const periodo = normalizePeriodoDadosMercado(resp.periodo_dados_mercado)
689
- setColunaDataMercado(String(resp.coluna_data_mercado || colunaDataMercado))
 
 
690
  setPeriodoDadosMercado(periodo)
691
  setPeriodoDadosMercadoPreview(periodo)
692
  setDataMercadoError('')
@@ -701,6 +1201,46 @@ export default function ElaboracaoTab({ sessionId }) {
701
  })
702
  }
703
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
704
  async function onMapCoords() {
705
  if (!manualLat || !manualLon || !sessionId) return
706
  setLoading(true)
@@ -802,7 +1342,7 @@ export default function ElaboracaoTab({ sessionId }) {
802
  async function onApplySelection() {
803
  if (!sessionId || !colunaY || colunasX.length === 0) return
804
  await withBusy(async () => {
805
- const resp = await api.applySelection({
806
  session_id: sessionId,
807
  coluna_y: colunaY,
808
  colunas_x: colunasX,
@@ -812,10 +1352,17 @@ export default function ElaboracaoTab({ sessionId }) {
812
  outliers_anteriores: outliersAnteriores,
813
  grau_min_coef: grauCoef,
814
  grau_min_f: grauF,
815
- })
 
816
  applySelectionResponse(resp)
 
817
  setFit(null)
 
 
818
  setCamposAvaliacao([])
 
 
 
819
  setResultadoAvaliacaoHtml('')
820
  })
821
  }
@@ -824,9 +1371,12 @@ export default function ElaboracaoTab({ sessionId }) {
824
  if (!sessionId) return
825
  await withBusy(async () => {
826
  const busca = await api.searchTransformations(sessionId, grauCoef, grauF)
 
 
827
  setSelection((prev) => ({ ...prev, busca }))
828
- setGrauCoef(busca.grau_coef ?? grauCoef)
829
- setGrauF(busca.grau_f ?? grauF)
 
830
  })
831
  }
832
 
@@ -888,6 +1438,7 @@ export default function ElaboracaoTab({ sessionId }) {
888
  const filtrosValidos = filtros.filter((f) => f.variavel && f.operador)
889
  const resp = await api.applyOutlierFilters(sessionId, filtrosValidos)
890
  setOutliersTexto(resp.texto || '')
 
891
  })
892
  }
893
 
@@ -920,6 +1471,7 @@ export default function ElaboracaoTab({ sessionId }) {
920
  setIteracao(resp.iteracao || iteracao)
921
  setOutliersTexto('')
922
  setReincluirTexto('')
 
923
  setFit(null)
924
  setTransformacoesAplicadas(null)
925
  setOrigemTransformacoes(null)
@@ -945,10 +1497,16 @@ export default function ElaboracaoTab({ sessionId }) {
945
  setTransformacoesAplicadas(null)
946
  setOrigemTransformacoes(null)
947
  setCamposAvaliacao([])
 
 
 
948
  valoresAvaliacaoRef.current = {}
949
  setAvaliacaoFormVersion((prev) => prev + 1)
 
950
  setResultadoAvaliacaoHtml('')
951
  setOutliersHtml(resp.outliers_html || '')
 
 
952
  })
953
  }
954
 
@@ -959,6 +1517,7 @@ export default function ElaboracaoTab({ sessionId }) {
959
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
960
  setBaseChoices(resp.base_choices || [])
961
  setBaseValue(resp.base_value || '')
 
962
  })
963
  }
964
 
@@ -975,6 +1534,7 @@ export default function ElaboracaoTab({ sessionId }) {
975
  })
976
  valoresAvaliacaoRef.current = limpo
977
  setAvaliacaoFormVersion((prev) => prev + 1)
 
978
  })
979
  }
980
 
@@ -1078,6 +1638,132 @@ export default function ElaboracaoTab({ sessionId }) {
1078
  })
1079
  }
1080
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1081
  function toggleSelection(setter, value) {
1082
  setter((prev) => {
1083
  if (prev.includes(value)) return prev.filter((item) => item !== value)
@@ -1473,6 +2159,16 @@ export default function ElaboracaoTab({ sessionId }) {
1473
  ))}
1474
  </select>
1475
  </div>
 
 
 
 
 
 
 
 
 
 
1476
  <MapFrame html={mapaHtml} />
1477
  </details>
1478
  )}
@@ -1490,6 +2186,16 @@ export default function ElaboracaoTab({ sessionId }) {
1490
  <div className="resumo-outliers-box">Índices excluídos: {joinSelection(outliersAnteriores)}</div>
1491
  ) : null}
1492
  </div>
 
 
 
 
 
 
 
 
 
 
1493
  <DataTable table={dados} maxHeight={540} />
1494
  </div>
1495
  </div>
@@ -1530,6 +2236,7 @@ export default function ElaboracaoTab({ sessionId }) {
1530
  >
1531
  Aplicar período
1532
  </button>
 
1533
  </div>
1534
  {dataMercadoError ? <div className="error-line inline-error">{dataMercadoError}</div> : null}
1535
  </div>
@@ -1538,16 +2245,29 @@ export default function ElaboracaoTab({ sessionId }) {
1538
  <SectionBlock step="5" title="Selecionar Variável Dependente" subtitle="Defina a variável dependente (Y).">
1539
  <div className="row">
1540
  <label>Variável Dependente (Y)</label>
1541
- <select value={colunaY} onChange={(e) => setColunaY(e.target.value)}>
1542
  <option value="">Selecione</option>
1543
  {colunasNumericas.map((col) => (
1544
  <option key={col} value={col}>{col}</option>
1545
  ))}
1546
  </select>
 
 
 
 
 
 
 
 
1547
  </div>
1548
  </SectionBlock>
1549
 
1550
  <SectionBlock step="6" title="Selecionar Variáveis Independentes" subtitle="Escolha regressoras e grupos de tipologia.">
 
 
 
 
 
1551
  <div className="compact-option-group compact-option-group-x">
1552
  <h4>Variáveis Independentes (X)</h4>
1553
  <div className="checkbox-inline-wrap checkbox-inline-wrap-tools">
@@ -1616,7 +2336,8 @@ export default function ElaboracaoTab({ sessionId }) {
1616
  </div>
1617
 
1618
  <div className="row">
1619
- <button onClick={onApplySelection} disabled={loading || !colunaY || colunasX.length === 0}>Aplicar seleção</button>
 
1620
  </div>
1621
  {selection?.aviso_multicolinearidade?.visible ? (
1622
  <div dangerouslySetInnerHTML={{ __html: selection.aviso_multicolinearidade.html }} />
@@ -1631,6 +2352,16 @@ export default function ElaboracaoTab({ sessionId }) {
1631
  {selection ? (
1632
  <>
1633
  <SectionBlock step="7" title="Estatísticas das Variáveis Selecionadas" subtitle="Resumo estatístico para Y e regressoras.">
 
 
 
 
 
 
 
 
 
 
1634
  <DataTable table={selection.estatisticas} />
1635
  </SectionBlock>
1636
 
@@ -1646,12 +2377,59 @@ export default function ElaboracaoTab({ sessionId }) {
1646
  >
1647
  <summary>Mostrar/Ocultar gráficos da seção</summary>
1648
  {section8Open ? (
1649
- <PlotFigure
1650
- figure={selection.grafico_dispersao}
1651
- title="Dispersão (dados filtrados)"
1652
- forceHideLegend
1653
- className="plot-stretch"
1654
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1655
  ) : null}
1656
  </details>
1657
  </SectionBlock>
@@ -1671,6 +2449,7 @@ export default function ElaboracaoTab({ sessionId }) {
1671
  ))}
1672
  </select>
1673
  <button onClick={onSearchTransform} disabled={loading}>Buscar transformações</button>
 
1674
  </div>
1675
  {(selection.busca?.resultados || []).length > 0 ? (
1676
  <div className="transform-suggestions-grid">
@@ -1752,7 +2531,10 @@ export default function ElaboracaoTab({ sessionId }) {
1752
  ))}
1753
  </div>
1754
 
1755
- <button className="btn-fit-model" onClick={onFitModel} disabled={loading}>Aplicar transformações e ajustar modelo</button>
 
 
 
1756
  </>
1757
  ) : null}
1758
  {transformacoesAplicadas?.coluna_y ? (
@@ -1814,7 +2596,57 @@ export default function ElaboracaoTab({ sessionId }) {
1814
  <option value="Variáveis Independentes Não Transformadas X Resíduo Padronizado">X não transformado x Resíduo</option>
1815
  </select>
1816
  </div>
1817
- <PlotFigure figure={fit.grafico_dispersao_modelo} title="Dispersão do modelo" forceHideLegend className="plot-stretch" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1818
  </>
1819
  ) : null}
1820
  </details>
@@ -1822,6 +2654,36 @@ export default function ElaboracaoTab({ sessionId }) {
1822
 
1823
  <SectionBlock step="13" title="Diagnóstico de Modelo" subtitle="Resumo diagnóstico e tabelas principais do ajuste.">
1824
  <div dangerouslySetInnerHTML={{ __html: fit.diagnosticos_html || '' }} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1825
  <div className="two-col diagnostic-tables">
1826
  <div className="pane">
1827
  <h4>Tabela de Coeficientes</h4>
@@ -1835,6 +2697,63 @@ export default function ElaboracaoTab({ sessionId }) {
1835
  </SectionBlock>
1836
 
1837
  <SectionBlock step="14" title="Gráficos de Diagnóstico do Modelo" subtitle="Obs x calc, resíduos, histograma, Cook e correlação.">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1838
  <div className="plot-grid-2-fixed">
1839
  <PlotFigure figure={fit.grafico_obs_calc} title="Obs x Calc" />
1840
  <PlotFigure figure={fit.grafico_residuos} title="Resíduos" />
@@ -1847,6 +2766,16 @@ export default function ElaboracaoTab({ sessionId }) {
1847
  </SectionBlock>
1848
 
1849
  <SectionBlock step="15" title="Analisar Outliers" subtitle="Métricas para identificação de observações influentes.">
 
 
 
 
 
 
 
 
 
 
1850
  <DataTable table={fit.tabela_metricas} maxHeight={320} />
1851
  </SectionBlock>
1852
 
@@ -1908,6 +2837,7 @@ export default function ElaboracaoTab({ sessionId }) {
1908
  <div className="outlier-actions-row">
1909
  <button onClick={onApplyOutlierFilters} disabled={loading}>Aplicar filtros</button>
1910
  <button type="button" className="btn-filtro-add" onClick={onAddFiltro} disabled={loading}>Adicionar filtro</button>
 
1911
  </div>
1912
  </div>
1913
 
@@ -1928,6 +2858,7 @@ export default function ElaboracaoTab({ sessionId }) {
1928
  <button onClick={onRestartIteration} disabled={loading} className="btn-reiniciar-iteracao">
1929
  Atualizar Modelo (Excluir/Reincluir Outliers)
1930
  </button>
 
1931
  </div>
1932
  </div>
1933
  <div className="resumo-outliers-box">Iteração: {iteracao} | {resumoOutliers}</div>
@@ -1944,6 +2875,7 @@ export default function ElaboracaoTab({ sessionId }) {
1944
  defaultValue={String(valoresAvaliacaoRef.current[campo.coluna] ?? '')}
1945
  onChange={(e) => {
1946
  valoresAvaliacaoRef.current[campo.coluna] = e.target.value
 
1947
  }}
1948
  >
1949
  <option value="">Selecione</option>
@@ -1960,6 +2892,7 @@ export default function ElaboracaoTab({ sessionId }) {
1960
  placeholder={campo.placeholder || ''}
1961
  onChange={(e) => {
1962
  valoresAvaliacaoRef.current[campo.coluna] = e.target.value
 
1963
  }}
1964
  />
1965
  )}
@@ -1970,6 +2903,7 @@ export default function ElaboracaoTab({ sessionId }) {
1970
  <button onClick={onCalculateAvaliacao} disabled={loading}>Calcular</button>
1971
  <button onClick={onClearAvaliacao} disabled={loading}>Limpar</button>
1972
  <button onClick={onExportAvaliacoes} disabled={loading}>Exportar avaliações (Excel)</button>
 
1973
  </div>
1974
  <div className="row avaliacao-base-row">
1975
  <label>Base comparação</label>
 
1
  import React, { useEffect, useMemo, useRef, useState } from 'react'
2
  import { api, downloadBlob } from '../api'
3
+ import Plotly from 'plotly.js-dist-min'
4
  import DataTable from './DataTable'
5
  import LoadingOverlay from './LoadingOverlay'
6
  import MapFrame from './MapFrame'
 
46
  return values.join(', ')
47
  }
48
 
49
+ function normalizeSnapshotArray(values) {
50
+ if (!Array.isArray(values)) return []
51
+ return [...new Set(values.map((item) => String(item).trim()).filter(Boolean))].sort()
52
+ }
53
+
54
+ function buildSelectionSnapshot(payload = {}) {
55
+ return JSON.stringify({
56
+ coluna_y: String(payload.coluna_y || '').trim(),
57
+ colunas_x: normalizeSnapshotArray(payload.colunas_x),
58
+ dicotomicas: normalizeSnapshotArray(payload.dicotomicas),
59
+ codigo_alocado: normalizeSnapshotArray(payload.codigo_alocado),
60
+ percentuais: normalizeSnapshotArray(payload.percentuais),
61
+ })
62
+ }
63
+
64
+ function buildGrauSnapshot(grauCoef, grauF) {
65
+ return `${Number(grauCoef) || 0}|${Number(grauF) || 0}`
66
+ }
67
+
68
+ function buildTransformacoesSnapshot(transformacaoY, transformacoesX) {
69
+ const transformacoesOrdenadas = Object.entries(transformacoesX || {})
70
+ .map(([coluna, valor]) => [String(coluna), String(valor || '(x)')])
71
+ .sort(([a], [b]) => a.localeCompare(b, 'pt-BR'))
72
+ return JSON.stringify({
73
+ transformacao_y: String(transformacaoY || '(x)'),
74
+ transformacoes_x: transformacoesOrdenadas,
75
+ })
76
+ }
77
+
78
+ function buildFiltrosSnapshot(filtros) {
79
+ if (!Array.isArray(filtros)) return '[]'
80
+ return JSON.stringify(
81
+ filtros.map((item) => ({
82
+ variavel: String(item?.variavel || ''),
83
+ operador: String(item?.operador || ''),
84
+ valor: Number(item?.valor ?? 0),
85
+ })),
86
+ )
87
+ }
88
+
89
+ function buildOutlierTextSnapshot(outliersTexto, reincluirTexto) {
90
+ return JSON.stringify({
91
+ outliers: String(outliersTexto || '').trim(),
92
+ reincluir: String(reincluirTexto || '').trim(),
93
+ })
94
+ }
95
+
96
+ function sleep(ms) {
97
+ return new Promise((resolve) => {
98
+ window.setTimeout(resolve, ms)
99
+ })
100
+ }
101
+
102
+ function toCsvCell(value, separator = ';') {
103
+ const text = String(value ?? '')
104
+ const escaped = text.replace(/"/g, '""')
105
+ if (escaped.includes('"') || escaped.includes('\n') || escaped.includes('\r') || escaped.includes(separator)) {
106
+ return `"${escaped}"`
107
+ }
108
+ return escaped
109
+ }
110
+
111
+ function tableToCsvBlob(table) {
112
+ if (!table || !Array.isArray(table.columns) || !Array.isArray(table.rows) || table.columns.length === 0) return null
113
+
114
+ const separator = ';'
115
+ const header = table.columns.map((col) => toCsvCell(col, separator)).join(separator)
116
+ const lines = [header]
117
+
118
+ table.rows.forEach((row) => {
119
+ const values = table.columns.map((col) => toCsvCell(row?.[col], separator))
120
+ lines.push(values.join(separator))
121
+ })
122
+
123
+ const csv = `\uFEFF${lines.join('\n')}`
124
+ return new Blob([csv], { type: 'text/csv;charset=utf-8;' })
125
+ }
126
+
127
+ function sanitizeFileName(name, fallback = 'arquivo') {
128
+ const safe = String(name || '')
129
+ .normalize('NFD')
130
+ .replace(/[\u0300-\u036f]/g, '')
131
+ .replace(/[^A-Za-z0-9._-]+/g, '_')
132
+ .replace(/^_+|_+$/g, '')
133
+ return safe || fallback
134
+ }
135
+
136
+ function buildFigureForExport(figure, forceHideLegend = false) {
137
+ if (!figure || typeof figure !== 'object') return null
138
+
139
+ const data = (figure.data || []).map((trace) => {
140
+ if (!forceHideLegend) return trace
141
+ return { ...trace, showlegend: false }
142
+ })
143
+
144
+ const baseLayout = figure.layout || {}
145
+ const { width: _ignoreWidth, ...layoutNoWidth } = baseLayout
146
+ const safeAnnotations = Array.isArray(baseLayout.annotations)
147
+ ? baseLayout.annotations.map((annotation) => {
148
+ const {
149
+ ax,
150
+ ay,
151
+ axref,
152
+ ayref,
153
+ arrowhead,
154
+ arrowsize,
155
+ arrowwidth,
156
+ arrowcolor,
157
+ standoff,
158
+ ...clean
159
+ } = annotation || {}
160
+ return { ...clean, showarrow: false }
161
+ })
162
+ : baseLayout.annotations
163
+
164
+ const width = Math.max(1100, Number(baseLayout.width) || 1100)
165
+ const height = Math.max(640, Number(baseLayout.height) || 640)
166
+ const layout = {
167
+ ...layoutNoWidth,
168
+ autosize: false,
169
+ width,
170
+ height,
171
+ annotations: safeAnnotations,
172
+ margin: baseLayout.margin || { t: 40, r: 20, b: 50, l: 50 },
173
+ }
174
+ if (forceHideLegend) {
175
+ layout.showlegend = false
176
+ }
177
+
178
+ return { data, layout, width, height }
179
+ }
180
+
181
+ function normalizeAxisRef(axisRef, axisType) {
182
+ const raw = String(axisRef || '').trim().toLowerCase()
183
+ if (!raw || raw === axisType) return `${axisType}axis`
184
+ if (raw.startsWith(`${axisType}axis`)) return raw
185
+ if (raw.startsWith(axisType)) return `${axisType}axis${raw.slice(axisType.length)}`
186
+ return `${axisType}axis`
187
+ }
188
+
189
+ function axisKeyToToken(axisKey, axisType) {
190
+ const normalized = normalizeAxisRef(axisKey, axisType)
191
+ if (normalized === `${axisType}axis`) return axisType
192
+ return `${axisType}${normalized.replace(`${axisType}axis`, '')}`
193
+ }
194
+
195
+ function getAxisTitleText(axisObj) {
196
+ if (!axisObj || typeof axisObj !== 'object') return ''
197
+ const title = axisObj.title
198
+ if (typeof title === 'string') return String(title).trim()
199
+ if (title && typeof title === 'object') {
200
+ return String(title.text || '').trim()
201
+ }
202
+ return ''
203
+ }
204
+
205
+ function stripHtmlText(value) {
206
+ return String(value || '')
207
+ .replace(/<br\s*\/?>/gi, ' ')
208
+ .replace(/<[^>]*>/g, ' ')
209
+ .replace(/\s+/g, ' ')
210
+ .trim()
211
+ }
212
+
213
+ function parsePanelHeading(tituloHtml, fallbackTitle) {
214
+ const raw = String(tituloHtml || '').trim()
215
+ const fallback = String(fallbackTitle || '').trim() || 'Gráfico'
216
+ if (!raw) {
217
+ return { title: fallback, subtitle: '' }
218
+ }
219
+
220
+ const parts = raw
221
+ .split(/<br\s*\/?>|\n/gi)
222
+ .map((part) => stripHtmlText(part))
223
+ .filter(Boolean)
224
+
225
+ if (parts.length === 0) {
226
+ return { title: fallback, subtitle: '' }
227
+ }
228
+
229
+ const [first, ...rest] = parts
230
+ const subtitleRaw = rest.join(' • ').trim()
231
+ const subtitle = /^r\s*=/i.test(subtitleRaw)
232
+ ? `Correlação (${subtitleRaw.replace(/\s+/g, ' ').replace(/^r\s*=/i, 'r =').trim()})`
233
+ : subtitleRaw
234
+
235
+ return {
236
+ title: first || fallback,
237
+ subtitle,
238
+ }
239
+ }
240
+
241
+ function matchesShapeAxisRef(ref, token) {
242
+ const text = String(ref || '').trim().toLowerCase()
243
+ if (!text) return false
244
+ return text === token || text === `${token} domain`
245
+ }
246
+
247
+ function filterShapesForPanel(layoutShapes, xToken, yToken) {
248
+ if (!Array.isArray(layoutShapes)) return []
249
+ return layoutShapes.filter((shape) => {
250
+ const xref = String(shape?.xref || '').trim().toLowerCase()
251
+ const yref = String(shape?.yref || '').trim().toLowerCase()
252
+ const hasX = matchesShapeAxisRef(xref, xToken)
253
+ const hasY = matchesShapeAxisRef(yref, yToken)
254
+
255
+ if (hasX && hasY) return true
256
+ if (hasX && !yref) return true
257
+ if (hasY && !xref) return true
258
+ return false
259
+ })
260
+ }
261
+
262
+ function stylePanelTrace(trace) {
263
+ const mode = String(trace?.mode || '')
264
+ const traceName = String(trace?.name || '').toLowerCase()
265
+ const hasMarkers = mode.includes('markers')
266
+ const hasLines = mode.includes('lines')
267
+ const next = { ...trace, showlegend: false }
268
+
269
+ if (hasMarkers) {
270
+ next.marker = {
271
+ ...(trace?.marker || {}),
272
+ color: '#FF8C00',
273
+ size: Number(trace?.marker?.size) || 8,
274
+ opacity: trace?.marker?.opacity ?? 0.84,
275
+ line: {
276
+ color: '#1f2933',
277
+ width: 1,
278
+ ...(trace?.marker?.line || {}),
279
+ },
280
+ }
281
+ }
282
+
283
+ if (hasLines) {
284
+ const currentDash = String(trace?.line?.dash || '').trim()
285
+ next.line = {
286
+ ...(trace?.line || {}),
287
+ color: trace?.line?.color || '#dc3545',
288
+ width: Number(trace?.line?.width) || 2,
289
+ dash: currentDash || (traceName.includes('regress') ? 'solid' : 'dash'),
290
+ }
291
+ }
292
+
293
+ return next
294
+ }
295
+
296
+ function buildScatterPanels(figure, options = {}) {
297
+ if (!figure || typeof figure !== 'object') return []
298
+ const data = Array.isArray(figure.data) ? figure.data : []
299
+ const rawLayout = figure.layout || {}
300
+ const { title: _ignoreTitle, ...layout } = rawLayout
301
+ if (data.length === 0) return []
302
+
303
+ const grouped = new Map()
304
+ data.forEach((trace, index) => {
305
+ const xKey = normalizeAxisRef(trace?.xaxis, 'x')
306
+ const yKey = normalizeAxisRef(trace?.yaxis, 'y')
307
+ const groupKey = `${xKey}|${yKey}`
308
+ const current = grouped.get(groupKey) || []
309
+ current.push({ trace, index, xKey, yKey })
310
+ grouped.set(groupKey, current)
311
+ })
312
+
313
+ const keys = Array.from(grouped.keys())
314
+ return keys.map((groupKey, idx) => {
315
+ const traces = grouped.get(groupKey) || []
316
+ const markerTrace = traces.find((item) => String(item?.trace?.mode || '').includes('markers'))?.trace || {}
317
+ const xKey = traces[0]?.xKey || 'xaxis'
318
+ const yKey = traces[0]?.yKey || 'yaxis'
319
+ const xToken = axisKeyToToken(xKey, 'x')
320
+ const yToken = axisKeyToToken(yKey, 'y')
321
+ const axisX = layout[xKey] || layout.xaxis || {}
322
+ const axisY = layout[yKey] || layout.yaxis || {}
323
+ const xTitle = getAxisTitleText(axisX)
324
+ const yTitle = getAxisTitleText(axisY)
325
+ const markerName = String(markerTrace?.name || '').replace(/^Regress[aã]o\s+/i, '').trim()
326
+ const xName = xTitle || markerName || `Gráfico ${idx + 1}`
327
+ const yName = String(options.yLabel || yTitle || 'Y').trim()
328
+ const annotationTitleHtml = String(layout?.annotations?.[idx]?.text || '').trim()
329
+ const tituloHtml = annotationTitleHtml || `${xName} × ${yName}`
330
+ const heading = parsePanelHeading(tituloHtml, `${xName} × ${yName}`)
331
+
332
+ const label = xName
333
+ const panelTraces = traces.map((item) => stylePanelTrace({
334
+ ...item.trace,
335
+ xaxis: 'x',
336
+ yaxis: 'y',
337
+ }))
338
+
339
+ const panelFigure = {
340
+ data: panelTraces,
341
+ layout: {
342
+ ...layout,
343
+ showlegend: false,
344
+ autosize: true,
345
+ height: options.height || 360,
346
+ plot_bgcolor: 'white',
347
+ paper_bgcolor: 'white',
348
+ margin: { t: 18, r: 18, b: 44, l: 56 },
349
+ annotations: [],
350
+ shapes: filterShapesForPanel(layout.shapes, xToken, yToken),
351
+ xaxis: {
352
+ ...axisX,
353
+ domain: undefined,
354
+ anchor: undefined,
355
+ matches: undefined,
356
+ scaleanchor: undefined,
357
+ scaleratio: undefined,
358
+ overlaying: undefined,
359
+ position: undefined,
360
+ side: axisX.side || 'bottom',
361
+ showgrid: true,
362
+ gridcolor: '#d7dde3',
363
+ showline: true,
364
+ linecolor: '#1f2933',
365
+ zeroline: false,
366
+ },
367
+ yaxis: {
368
+ ...axisY,
369
+ domain: undefined,
370
+ anchor: undefined,
371
+ matches: undefined,
372
+ scaleanchor: undefined,
373
+ scaleratio: undefined,
374
+ overlaying: undefined,
375
+ position: undefined,
376
+ side: axisY.side || 'left',
377
+ showgrid: true,
378
+ gridcolor: '#d7dde3',
379
+ showline: true,
380
+ linecolor: '#1f2933',
381
+ zeroline: false,
382
+ },
383
+ },
384
+ }
385
+
386
+ const legenda = [heading.title, heading.subtitle].filter(Boolean).join(' - ')
387
+ return {
388
+ id: `${groupKey}-${idx}`,
389
+ label,
390
+ title: heading.title,
391
+ subtitle: heading.subtitle,
392
+ legenda,
393
+ figure: panelFigure,
394
+ }
395
+ })
396
+ }
397
+
398
  function formatConselhoRegistro(elaborador) {
399
  if (!elaborador) return ''
400
  const conselho = String(elaborador.conselho || '').trim()
 
494
 
495
  export default function ElaboracaoTab({ sessionId }) {
496
  const [loading, setLoading] = useState(false)
497
+ const [downloadingAssets, setDownloadingAssets] = useState(false)
498
  const [error, setError] = useState('')
499
  const [status, setStatus] = useState('')
500
 
 
526
 
527
  const [colunasNumericas, setColunasNumericas] = useState([])
528
  const [colunaY, setColunaY] = useState('')
529
+ const [colunaYDraft, setColunaYDraft] = useState('')
530
  const [colunasX, setColunasX] = useState([])
531
  const [dicotomicas, setDicotomicas] = useState([])
532
  const [codigoAlocado, setCodigoAlocado] = useState([])
 
534
  const [colunasDataMercado, setColunasDataMercado] = useState([])
535
  const [colunaDataMercadoSugerida, setColunaDataMercadoSugerida] = useState('')
536
  const [colunaDataMercado, setColunaDataMercado] = useState('')
537
+ const [colunaDataMercadoAplicada, setColunaDataMercadoAplicada] = useState('')
538
  const [periodoDadosMercado, setPeriodoDadosMercado] = useState(null)
539
  const [periodoDadosMercadoPreview, setPeriodoDadosMercadoPreview] = useState(null)
540
  const [dataMercadoError, setDataMercadoError] = useState('')
 
567
  const [camposAvaliacao, setCamposAvaliacao] = useState([])
568
  const valoresAvaliacaoRef = useRef({})
569
  const [avaliacaoFormVersion, setAvaliacaoFormVersion] = useState(0)
570
+ const [avaliacaoPendente, setAvaliacaoPendente] = useState(false)
571
  const [resultadoAvaliacaoHtml, setResultadoAvaliacaoHtml] = useState('')
572
  const [baseChoices, setBaseChoices] = useState([])
573
  const [baseValue, setBaseValue] = useState('')
 
576
  const [avaliadores, setAvaliadores] = useState([])
577
  const [avaliadorSelecionado, setAvaliadorSelecionado] = useState('')
578
  const [tipoFonteDados, setTipoFonteDados] = useState('')
579
+ const [selectionAppliedSnapshot, setSelectionAppliedSnapshot] = useState(() => buildSelectionSnapshot())
580
+ const [buscaTransformAppliedSnapshot, setBuscaTransformAppliedSnapshot] = useState(() => buildGrauSnapshot(0, 0))
581
+ const [manualTransformAppliedSnapshot, setManualTransformAppliedSnapshot] = useState(() => buildTransformacoesSnapshot('(x)', {}))
582
+ const [outlierFiltrosAplicadosSnapshot, setOutlierFiltrosAplicadosSnapshot] = useState(() => buildFiltrosSnapshot(defaultFiltros()))
583
+ const [outlierTextosAplicadosSnapshot, setOutlierTextosAplicadosSnapshot] = useState(() => buildOutlierTextSnapshot('', ''))
584
  const marcarTodasXRef = useRef(null)
585
  const classificarXReqRef = useRef(0)
586
  const deleteConfirmTimersRef = useRef({})
 
588
 
589
  const mapaChoices = useMemo(() => ['Visualização Padrão', ...colunasNumericas], [colunasNumericas])
590
  const colunasXDisponiveis = useMemo(
591
+ () => (colunaY ? colunasNumericas.filter((coluna) => coluna !== colunaY) : []),
592
  [colunasNumericas, colunaY],
593
  )
594
  const todasXMarcadas = useMemo(
 
628
  () => formatPeriodoDadosMercado(periodoDadosMercadoPreview),
629
  [periodoDadosMercadoPreview],
630
  )
631
+ const pendingWarningText = 'Há alterações em campos que ainda não foram aplicadas.'
632
+ const dataMercadoPendente = useMemo(
633
+ () => String(colunaDataMercado || '') !== String(colunaDataMercadoAplicada || ''),
634
+ [colunaDataMercado, colunaDataMercadoAplicada],
635
+ )
636
+ const dependentePendente = useMemo(
637
+ () => String(colunaYDraft || '') !== String(colunaY || ''),
638
+ [colunaYDraft, colunaY],
639
+ )
640
+ const selectionSnapshotAtual = useMemo(
641
+ () => buildSelectionSnapshot({
642
+ coluna_y: colunaY,
643
+ colunas_x: colunasX,
644
+ dicotomicas,
645
+ codigo_alocado: codigoAlocado,
646
+ percentuais,
647
+ }),
648
+ [colunaY, colunasX, dicotomicas, codigoAlocado, percentuais],
649
+ )
650
+ const selecaoTemDados = useMemo(
651
+ () => Boolean(colunaY) && colunasX.length > 0,
652
+ [colunaY, colunasX],
653
+ )
654
+ const selecaoPendente = useMemo(
655
+ () => selecaoTemDados && selectionSnapshotAtual !== selectionAppliedSnapshot,
656
+ [selecaoTemDados, selectionSnapshotAtual, selectionAppliedSnapshot],
657
+ )
658
+ const buscaTransformSnapshotAtual = useMemo(
659
+ () => buildGrauSnapshot(grauCoef, grauF),
660
+ [grauCoef, grauF],
661
+ )
662
+ const buscaTransformPendente = useMemo(
663
+ () => Boolean(selection) && buscaTransformSnapshotAtual !== buscaTransformAppliedSnapshot,
664
+ [selection, buscaTransformSnapshotAtual, buscaTransformAppliedSnapshot],
665
+ )
666
+ const manualTransformSnapshotAtual = useMemo(
667
+ () => buildTransformacoesSnapshot(transformacaoY, transformacoesX),
668
+ [transformacaoY, transformacoesX],
669
+ )
670
+ const manualTransformPendente = useMemo(
671
+ () => Boolean(selection) && Boolean(section10ManualOpen) && manualTransformSnapshotAtual !== manualTransformAppliedSnapshot,
672
+ [selection, section10ManualOpen, manualTransformSnapshotAtual, manualTransformAppliedSnapshot],
673
+ )
674
+ const outlierFiltrosSnapshotAtual = useMemo(
675
+ () => buildFiltrosSnapshot(filtros),
676
+ [filtros],
677
+ )
678
+ const outlierFiltrosPendentes = useMemo(
679
+ () => Boolean(fit) && outlierFiltrosSnapshotAtual !== outlierFiltrosAplicadosSnapshot,
680
+ [fit, outlierFiltrosSnapshotAtual, outlierFiltrosAplicadosSnapshot],
681
+ )
682
+ const outlierTextosSnapshotAtual = useMemo(
683
+ () => buildOutlierTextSnapshot(outliersTexto, reincluirTexto),
684
+ [outliersTexto, reincluirTexto],
685
+ )
686
+ const outlierTextosPendentes = useMemo(
687
+ () => Boolean(fit) && outlierTextosSnapshotAtual !== outlierTextosAplicadosSnapshot,
688
+ [fit, outlierTextosSnapshotAtual, outlierTextosAplicadosSnapshot],
689
+ )
690
  const transformacaoAplicadaYBadge = useMemo(
691
  () => formatTransformacaoBadge(transformacoesAplicadas?.transformacao_y),
692
  [transformacoesAplicadas],
 
715
  }
716
  return ''
717
  }, [origemTransformacoes])
718
+ const graficosSecao9 = useMemo(
719
+ () => buildScatterPanels(selection?.grafico_dispersao, {
720
+ singleLabel: 'Dispersão',
721
+ height: 360,
722
+ yLabel: colunaY || 'Y',
723
+ }),
724
+ [selection?.grafico_dispersao, colunaY],
725
+ )
726
+ const colunasGraficosSecao9 = useMemo(
727
+ () => Math.max(1, Math.min(3, graficosSecao9.length || 1)),
728
+ [graficosSecao9.length],
729
+ )
730
+ const graficosSecao12 = useMemo(
731
+ () => buildScatterPanels(fit?.grafico_dispersao_modelo, {
732
+ singleLabel: 'Dispersão do modelo',
733
+ height: 360,
734
+ yLabel: tipoDispersao.includes('Resíduo')
735
+ ? 'Resíduo Padronizado'
736
+ : `${colunaY || 'Y'} (transformada)`,
737
+ }),
738
+ [fit?.grafico_dispersao_modelo, tipoDispersao, colunaY],
739
+ )
740
+ const colunasGraficosSecao12 = useMemo(
741
+ () => Math.max(1, Math.min(3, graficosSecao12.length || 1)),
742
+ [graficosSecao12.length],
743
+ )
744
  const showCoordsPanel = Boolean(
745
  coordsInfo && (
746
  !coordsInfo.tem_coords ||
 
852
 
853
  function applyBaseResponse(resp, options = {}) {
854
  const resetXSelection = Boolean(options.resetXSelection)
855
+ const colunaYPadrao = String(resp.coluna_y_padrao || '')
856
  setDataMercadoError('')
857
  if (resp.status) setStatus(resp.status)
858
  if (resp.dados) setDados(resp.dados)
 
865
  setColunaDataMercadoSugerida(String(resp.coluna_data_mercado_sugerida || ''))
866
  }
867
  if (Object.prototype.hasOwnProperty.call(resp, 'coluna_data_mercado')) {
868
+ const colunaData = String(resp.coluna_data_mercado || '')
869
+ setColunaDataMercado(colunaData)
870
+ setColunaDataMercadoAplicada(colunaData)
871
  }
872
  if (Object.prototype.hasOwnProperty.call(resp, 'periodo_dados_mercado')) {
873
  const periodo = normalizePeriodoDadosMercado(resp.periodo_dados_mercado)
874
  setPeriodoDadosMercado(periodo)
875
  setPeriodoDadosMercadoPreview(periodo)
876
  }
877
+ if (Object.prototype.hasOwnProperty.call(resp, 'coluna_y_padrao')) {
878
+ setColunaY(colunaYPadrao)
879
+ setColunaYDraft(colunaYPadrao)
880
+ }
881
 
882
  if (resp.contexto && !resetXSelection) {
883
+ const colunaYContexto = String(resp.contexto.coluna_y || colunaYPadrao || '')
884
+ const colunasXContexto = resp.contexto.colunas_x || []
885
+ const dicotomicasContexto = resp.contexto.dicotomicas || []
886
+ const codigoAlocadoContexto = resp.contexto.codigo_alocado || []
887
+ const percentuaisContexto = resp.contexto.percentuais || []
888
+ setColunaY(colunaYContexto)
889
+ setColunaYDraft(colunaYContexto)
890
+ setColunasX(colunasXContexto)
891
+ setDicotomicas(dicotomicasContexto)
892
+ setCodigoAlocado(codigoAlocadoContexto)
893
+ setPercentuais(percentuaisContexto)
894
+ setSelectionAppliedSnapshot(buildSelectionSnapshot({
895
+ coluna_y: colunaYContexto,
896
+ colunas_x: colunasXContexto,
897
+ dicotomicas: dicotomicasContexto,
898
+ codigo_alocado: codigoAlocadoContexto,
899
+ percentuais: percentuaisContexto,
900
+ }))
901
  setOutliersAnteriores(resp.contexto.outliers_anteriores || [])
902
  setIteracao(resp.contexto.iteracao || 1)
903
  } else if (resetXSelection) {
904
+ setColunaY('')
905
+ setColunaYDraft(colunaYPadrao)
906
  setColunasX([])
907
  setDicotomicas([])
908
  setCodigoAlocado([])
909
  setPercentuais([])
910
+ setSelectionAppliedSnapshot(buildSelectionSnapshot({ coluna_y: '' }))
911
  setTransformacaoY('(x)')
912
  setTransformacoesX({})
913
  setTransformacoesAplicadas(null)
914
  setOrigemTransformacoes(null)
915
+ setBuscaTransformAppliedSnapshot(buildGrauSnapshot(grauCoef, grauF))
916
+ setManualTransformAppliedSnapshot(buildTransformacoesSnapshot('(x)', {}))
917
  setOutliersAnteriores([])
918
  setIteracao(1)
919
  setColunaDataMercadoSugerida('')
920
  setColunaDataMercado('')
921
+ setColunaDataMercadoAplicada('')
922
  setPeriodoDadosMercado(null)
923
  setPeriodoDadosMercadoPreview(null)
924
  setDataMercadoError('')
925
  setSelection(null)
926
  setFit(null)
927
+ setFiltros(defaultFiltros())
928
+ setOutliersTexto('')
929
+ setReincluirTexto('')
930
+ setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
931
+ setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
932
  setCamposAvaliacao([])
933
  valoresAvaliacaoRef.current = {}
934
  setAvaliacaoFormVersion((prev) => prev + 1)
935
+ setAvaliacaoPendente(false)
936
  setResultadoAvaliacaoHtml('')
937
  setOutliersHtml('')
938
  }
 
963
  setSection10ManualOpen(false)
964
  setTransformacoesAplicadas(null)
965
  setOrigemTransformacoes(null)
966
+ const transformacaoYAplicada = resp.transformacao_y || transformacaoY
967
  if (resp.transformacao_y) {
968
  setTransformacaoY(resp.transformacao_y)
969
  }
 
973
  map[field.coluna] = field.valor || '(x)'
974
  })
975
  setTransformacoesX(map)
976
+ setManualTransformAppliedSnapshot(buildTransformacoesSnapshot(transformacaoYAplicada, map))
977
 
978
+ const grauCoefAplicado = resp.busca?.grau_coef ?? grauCoef
979
+ const grauFAplicado = resp.busca?.grau_f ?? grauF
980
  if (resp.busca) {
981
+ setGrauCoef(grauCoefAplicado)
982
+ setGrauF(grauFAplicado)
983
  }
984
+ setBuscaTransformAppliedSnapshot(buildGrauSnapshot(grauCoefAplicado, grauFAplicado))
985
 
986
  if (resp.resumo_outliers) {
987
  setResumoOutliers(resp.resumo_outliers)
 
998
  function applyFitResponse(resp, origemMeta = null) {
999
  setFit(resp)
1000
  setSection11Open(false)
1001
+ const transformacaoYAplicada = resp.transformacao_y || transformacaoY
1002
+ const transformacoesXAplicadas = resp.transformacoes_x || transformacoesX
1003
  if (resp.transformacao_y) {
1004
  setTransformacaoY(resp.transformacao_y)
1005
  }
 
1015
  transformacoes_x: resp.transformacoes_x || transformacoesX,
1016
  })
1017
  }
1018
+ setManualTransformAppliedSnapshot(buildTransformacoesSnapshot(transformacaoYAplicada, transformacoesXAplicadas))
1019
  if (origemMeta && origemMeta.origem) {
1020
  setOrigemTransformacoes(origemMeta)
1021
  } else if (resp.origem_transformacoes?.origem) {
1022
  setOrigemTransformacoes(resp.origem_transformacoes)
1023
  }
1024
  setResumoOutliers(resp.resumo_outliers || resumoOutliers)
1025
+ setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(filtros))
1026
+ setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot(outliersTexto, reincluirTexto))
1027
  setCamposAvaliacao(resp.avaliacao_campos || [])
1028
  const init = {}
1029
  ;(resp.avaliacao_campos || []).forEach((campo) => {
 
1031
  })
1032
  valoresAvaliacaoRef.current = init
1033
  setAvaliacaoFormVersion((prev) => prev + 1)
1034
+ setAvaliacaoPendente(false)
1035
  setResultadoAvaliacaoHtml('')
1036
  setBaseChoices([])
1037
  setBaseValue('')
 
1066
  applyBaseResponse(resp, { resetXSelection })
1067
  } else if (resp.status) {
1068
  setTipoFonteDados('tabular')
1069
+ setColunaY('')
1070
+ setColunaYDraft('')
1071
  setSelection(null)
1072
  setFit(null)
1073
+ setSelectionAppliedSnapshot(buildSelectionSnapshot())
1074
  setColunasX([])
1075
  setDicotomicas([])
1076
  setCodigoAlocado([])
 
1079
  setTransformacoesX({})
1080
  setTransformacoesAplicadas(null)
1081
  setOrigemTransformacoes(null)
1082
+ setBuscaTransformAppliedSnapshot(buildGrauSnapshot(0, 0))
1083
+ setManualTransformAppliedSnapshot(buildTransformacoesSnapshot('(x)', {}))
1084
  setColunasDataMercado([])
1085
  setColunaDataMercadoSugerida('')
1086
  setColunaDataMercado('')
1087
+ setColunaDataMercadoAplicada('')
1088
  setPeriodoDadosMercado(null)
1089
  setPeriodoDadosMercadoPreview(null)
1090
  setDataMercadoError('')
1091
+ setFiltros(defaultFiltros())
1092
+ setOutliersTexto('')
1093
+ setReincluirTexto('')
1094
+ setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
1095
+ setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
1096
  setCamposAvaliacao([])
1097
  valoresAvaliacaoRef.current = {}
1098
  setAvaliacaoFormVersion((prev) => prev + 1)
1099
+ setAvaliacaoPendente(false)
1100
  setResultadoAvaliacaoHtml('')
1101
  setStatus(resp.status)
1102
  }
 
1159
  const coluna = String(value || '')
1160
  setColunaDataMercado(coluna)
1161
  setDataMercadoError('')
1162
+ setStatus((prev) => (String(prev || '').startsWith('Prévia do período') ? '' : prev))
1163
 
1164
  if (!sessionId || !coluna) {
1165
  setPeriodoDadosMercadoPreview(null)
 
1171
  const resp = await api.previewMarketDateColumn(sessionId, coluna)
1172
  const periodo = normalizePeriodoDadosMercado(resp.periodo_dados_mercado)
1173
  setPeriodoDadosMercadoPreview(periodo)
 
1174
  } catch (err) {
1175
  setPeriodoDadosMercadoPreview(null)
1176
  setDataMercadoError(err.message || 'Falha ao identificar período da coluna selecionada.')
 
1184
  await withBusy(async () => {
1185
  const resp = await api.applyMarketDateColumn(sessionId, colunaDataMercado)
1186
  const periodo = normalizePeriodoDadosMercado(resp.periodo_dados_mercado)
1187
+ const colunaAplicada = String(resp.coluna_data_mercado || colunaDataMercado)
1188
+ setColunaDataMercado(colunaAplicada)
1189
+ setColunaDataMercadoAplicada(colunaAplicada)
1190
  setPeriodoDadosMercado(periodo)
1191
  setPeriodoDadosMercadoPreview(periodo)
1192
  setDataMercadoError('')
 
1201
  })
1202
  }
1203
 
1204
+ function onAplicarVariavelDependente() {
1205
+ const proximaColunaY = String(colunaYDraft || '')
1206
+ const colunaYAtual = String(colunaY || '')
1207
+ if (proximaColunaY === colunaYAtual) return
1208
+
1209
+ const proximasDisponiveis = colunasNumericas.filter((coluna) => coluna !== proximaColunaY)
1210
+ const proximasX = colunasX.filter((coluna) => proximasDisponiveis.includes(coluna))
1211
+ const proximasDicotomicas = dicotomicas.filter((coluna) => proximasX.includes(coluna))
1212
+ const proximoCodigoAlocado = codigoAlocado.filter((coluna) => proximasX.includes(coluna))
1213
+ const proximosPercentuais = percentuais.filter((coluna) => proximasX.includes(coluna))
1214
+
1215
+ setColunaY(proximaColunaY)
1216
+ setColunasX(proximasX)
1217
+ setDicotomicas(proximasDicotomicas)
1218
+ setCodigoAlocado(proximoCodigoAlocado)
1219
+ setPercentuais(proximosPercentuais)
1220
+ setSelectionAppliedSnapshot(buildSelectionSnapshot({ coluna_y: proximaColunaY }))
1221
+ setSelection(null)
1222
+ setFit(null)
1223
+ setTransformacaoY('(x)')
1224
+ setTransformacoesX({})
1225
+ setTransformacoesAplicadas(null)
1226
+ setOrigemTransformacoes(null)
1227
+ setBuscaTransformAppliedSnapshot(buildGrauSnapshot(grauCoef, grauF))
1228
+ setManualTransformAppliedSnapshot(buildTransformacoesSnapshot('(x)', {}))
1229
+ setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
1230
+ setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
1231
+ setFiltros(defaultFiltros())
1232
+ setCamposAvaliacao([])
1233
+ valoresAvaliacaoRef.current = {}
1234
+ setAvaliacaoFormVersion((prev) => prev + 1)
1235
+ setAvaliacaoPendente(false)
1236
+ setResultadoAvaliacaoHtml('')
1237
+ setOutliersHtml('')
1238
+ setOutliersTexto('')
1239
+ setReincluirTexto('')
1240
+ setBaseChoices([])
1241
+ setBaseValue('')
1242
+ }
1243
+
1244
  async function onMapCoords() {
1245
  if (!manualLat || !manualLon || !sessionId) return
1246
  setLoading(true)
 
1342
  async function onApplySelection() {
1343
  if (!sessionId || !colunaY || colunasX.length === 0) return
1344
  await withBusy(async () => {
1345
+ const payload = {
1346
  session_id: sessionId,
1347
  coluna_y: colunaY,
1348
  colunas_x: colunasX,
 
1352
  outliers_anteriores: outliersAnteriores,
1353
  grau_min_coef: grauCoef,
1354
  grau_min_f: grauF,
1355
+ }
1356
+ const resp = await api.applySelection(payload)
1357
  applySelectionResponse(resp)
1358
+ setSelectionAppliedSnapshot(buildSelectionSnapshot(payload))
1359
  setFit(null)
1360
+ setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
1361
+ setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
1362
  setCamposAvaliacao([])
1363
+ valoresAvaliacaoRef.current = {}
1364
+ setAvaliacaoFormVersion((prev) => prev + 1)
1365
+ setAvaliacaoPendente(false)
1366
  setResultadoAvaliacaoHtml('')
1367
  })
1368
  }
 
1371
  if (!sessionId) return
1372
  await withBusy(async () => {
1373
  const busca = await api.searchTransformations(sessionId, grauCoef, grauF)
1374
+ const grauCoefAplicado = busca.grau_coef ?? grauCoef
1375
+ const grauFAplicado = busca.grau_f ?? grauF
1376
  setSelection((prev) => ({ ...prev, busca }))
1377
+ setGrauCoef(grauCoefAplicado)
1378
+ setGrauF(grauFAplicado)
1379
+ setBuscaTransformAppliedSnapshot(buildGrauSnapshot(grauCoefAplicado, grauFAplicado))
1380
  })
1381
  }
1382
 
 
1438
  const filtrosValidos = filtros.filter((f) => f.variavel && f.operador)
1439
  const resp = await api.applyOutlierFilters(sessionId, filtrosValidos)
1440
  setOutliersTexto(resp.texto || '')
1441
+ setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(filtros))
1442
  })
1443
  }
1444
 
 
1471
  setIteracao(resp.iteracao || iteracao)
1472
  setOutliersTexto('')
1473
  setReincluirTexto('')
1474
+ setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
1475
  setFit(null)
1476
  setTransformacoesAplicadas(null)
1477
  setOrigemTransformacoes(null)
 
1497
  setTransformacoesAplicadas(null)
1498
  setOrigemTransformacoes(null)
1499
  setCamposAvaliacao([])
1500
+ setOutliersTexto('')
1501
+ setReincluirTexto('')
1502
+ setFiltros(defaultFiltros())
1503
  valoresAvaliacaoRef.current = {}
1504
  setAvaliacaoFormVersion((prev) => prev + 1)
1505
+ setAvaliacaoPendente(false)
1506
  setResultadoAvaliacaoHtml('')
1507
  setOutliersHtml(resp.outliers_html || '')
1508
+ setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
1509
+ setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
1510
  })
1511
  }
1512
 
 
1517
  setResultadoAvaliacaoHtml(resp.resultado_html || '')
1518
  setBaseChoices(resp.base_choices || [])
1519
  setBaseValue(resp.base_value || '')
1520
+ setAvaliacaoPendente(false)
1521
  })
1522
  }
1523
 
 
1534
  })
1535
  valoresAvaliacaoRef.current = limpo
1536
  setAvaliacaoFormVersion((prev) => prev + 1)
1537
+ setAvaliacaoPendente(false)
1538
  })
1539
  }
1540
 
 
1638
  })
1639
  }
1640
 
1641
+ function onDownloadTableCsv(table, fileNameBase) {
1642
+ const blob = tableToCsvBlob(table)
1643
+ if (!blob) {
1644
+ setError('Tabela indisponível para download.')
1645
+ return
1646
+ }
1647
+ downloadBlob(blob, `${sanitizeFileName(fileNameBase, 'tabela')}.csv`)
1648
+ }
1649
+
1650
+ async function onDownloadTablesCsvBatch(items) {
1651
+ const validItems = (items || []).filter((item) => item?.table)
1652
+ if (validItems.length === 0) {
1653
+ setError('Não há tabelas disponíveis para download.')
1654
+ return
1655
+ }
1656
+
1657
+ setDownloadingAssets(true)
1658
+ setError('')
1659
+ try {
1660
+ for (let i = 0; i < validItems.length; i += 1) {
1661
+ const item = validItems[i]
1662
+ const blob = tableToCsvBlob(item.table)
1663
+ if (!blob) continue
1664
+ downloadBlob(blob, `${sanitizeFileName(item.fileNameBase, `tabela_${i + 1}`)}.csv`)
1665
+ if (i < validItems.length - 1) {
1666
+ await sleep(180)
1667
+ }
1668
+ }
1669
+ } catch (err) {
1670
+ setError(err.message || 'Falha ao baixar tabelas.')
1671
+ } finally {
1672
+ setDownloadingAssets(false)
1673
+ }
1674
+ }
1675
+
1676
+ function onDownloadMapaSecao3() {
1677
+ if (!mapaHtml) {
1678
+ setError('Mapa indisponível para download.')
1679
+ return
1680
+ }
1681
+ const blob = new Blob([mapaHtml], { type: 'text/html;charset=utf-8;' })
1682
+ downloadBlob(blob, 'secao3_mapa_dados_mercado.html')
1683
+ }
1684
+
1685
+ async function exportFigureAsPng(figure, fileNameBase, options = {}) {
1686
+ const payload = buildFigureForExport(figure, Boolean(options.forceHideLegend))
1687
+ if (!payload) {
1688
+ throw new Error('Gráfico indisponível para download.')
1689
+ }
1690
+
1691
+ const container = document.createElement('div')
1692
+ container.style.position = 'fixed'
1693
+ container.style.left = '-10000px'
1694
+ container.style.top = '-10000px'
1695
+ container.style.width = `${payload.width}px`
1696
+ container.style.height = `${payload.height}px`
1697
+ container.style.pointerEvents = 'none'
1698
+ container.style.opacity = '0'
1699
+ document.body.appendChild(container)
1700
+
1701
+ try {
1702
+ await Plotly.newPlot(
1703
+ container,
1704
+ payload.data,
1705
+ payload.layout,
1706
+ { displaylogo: false, responsive: false, staticPlot: true },
1707
+ )
1708
+ const dataUrl = await Plotly.toImage(container, {
1709
+ format: 'png',
1710
+ width: payload.width,
1711
+ height: payload.height,
1712
+ scale: 2,
1713
+ })
1714
+ const response = await fetch(dataUrl)
1715
+ const blob = await response.blob()
1716
+ downloadBlob(blob, `${sanitizeFileName(fileNameBase, 'grafico')}.png`)
1717
+ } finally {
1718
+ try {
1719
+ Plotly.purge(container)
1720
+ } catch {
1721
+ // no-op
1722
+ }
1723
+ container.remove()
1724
+ }
1725
+ }
1726
+
1727
+ async function onDownloadFigurePng(figure, fileNameBase, options = {}) {
1728
+ if (!figure) {
1729
+ setError('Gráfico indisponível para download.')
1730
+ return
1731
+ }
1732
+ setDownloadingAssets(true)
1733
+ setError('')
1734
+ try {
1735
+ await exportFigureAsPng(figure, fileNameBase, options)
1736
+ } catch (err) {
1737
+ setError(err.message || 'Falha ao baixar gráfico.')
1738
+ } finally {
1739
+ setDownloadingAssets(false)
1740
+ }
1741
+ }
1742
+
1743
+ async function onDownloadFiguresPngBatch(items) {
1744
+ const validItems = (items || []).filter((item) => item?.figure)
1745
+ if (validItems.length === 0) {
1746
+ setError('Não há gráficos disponíveis para download.')
1747
+ return
1748
+ }
1749
+
1750
+ setDownloadingAssets(true)
1751
+ setError('')
1752
+ try {
1753
+ for (let i = 0; i < validItems.length; i += 1) {
1754
+ const item = validItems[i]
1755
+ await exportFigureAsPng(item.figure, item.fileNameBase, { forceHideLegend: item.forceHideLegend })
1756
+ if (i < validItems.length - 1) {
1757
+ await sleep(220)
1758
+ }
1759
+ }
1760
+ } catch (err) {
1761
+ setError(err.message || 'Falha ao baixar gráficos.')
1762
+ } finally {
1763
+ setDownloadingAssets(false)
1764
+ }
1765
+ }
1766
+
1767
  function toggleSelection(setter, value) {
1768
  setter((prev) => {
1769
  if (prev.includes(value)) return prev.filter((item) => item !== value)
 
2159
  ))}
2160
  </select>
2161
  </div>
2162
+ <div className="download-actions-bar">
2163
+ <button
2164
+ type="button"
2165
+ className="btn-download-subtle"
2166
+ onClick={onDownloadMapaSecao3}
2167
+ disabled={loading || downloadingAssets || !mapaHtml}
2168
+ >
2169
+ Fazer download
2170
+ </button>
2171
+ </div>
2172
  <MapFrame html={mapaHtml} />
2173
  </details>
2174
  )}
 
2186
  <div className="resumo-outliers-box">Índices excluídos: {joinSelection(outliersAnteriores)}</div>
2187
  ) : null}
2188
  </div>
2189
+ <div className="download-actions-bar">
2190
+ <button
2191
+ type="button"
2192
+ className="btn-download-subtle"
2193
+ onClick={() => onDownloadTableCsv(dados, 'secao3_dados_mercado')}
2194
+ disabled={loading || downloadingAssets || !dados}
2195
+ >
2196
+ Fazer download
2197
+ </button>
2198
+ </div>
2199
  <DataTable table={dados} maxHeight={540} />
2200
  </div>
2201
  </div>
 
2236
  >
2237
  Aplicar período
2238
  </button>
2239
+ {dataMercadoPendente ? <span className="pending-apply-note">{pendingWarningText}</span> : null}
2240
  </div>
2241
  {dataMercadoError ? <div className="error-line inline-error">{dataMercadoError}</div> : null}
2242
  </div>
 
2245
  <SectionBlock step="5" title="Selecionar Variável Dependente" subtitle="Defina a variável dependente (Y).">
2246
  <div className="row">
2247
  <label>Variável Dependente (Y)</label>
2248
+ <select value={colunaYDraft} onChange={(e) => setColunaYDraft(e.target.value)}>
2249
  <option value="">Selecione</option>
2250
  {colunasNumericas.map((col) => (
2251
  <option key={col} value={col}>{col}</option>
2252
  ))}
2253
  </select>
2254
+ <button
2255
+ type="button"
2256
+ onClick={onAplicarVariavelDependente}
2257
+ disabled={loading || !dependentePendente}
2258
+ >
2259
+ Aplicar variável dependente
2260
+ </button>
2261
+ {dependentePendente ? <span className="pending-apply-note">{pendingWarningText}</span> : null}
2262
  </div>
2263
  </SectionBlock>
2264
 
2265
  <SectionBlock step="6" title="Selecionar Variáveis Independentes" subtitle="Escolha regressoras e grupos de tipologia.">
2266
+ {!colunaY ? (
2267
+ <div className="section1-empty-hint">
2268
+ Aplique a variável dependente na etapa anterior para liberar as opções de variáveis independentes.
2269
+ </div>
2270
+ ) : null}
2271
  <div className="compact-option-group compact-option-group-x">
2272
  <h4>Variáveis Independentes (X)</h4>
2273
  <div className="checkbox-inline-wrap checkbox-inline-wrap-tools">
 
2336
  </div>
2337
 
2338
  <div className="row">
2339
+ <button onClick={onApplySelection} disabled={loading || dependentePendente || !colunaY || colunasX.length === 0}>Aplicar seleção</button>
2340
+ {selecaoPendente ? <span className="pending-apply-note">{pendingWarningText}</span> : null}
2341
  </div>
2342
  {selection?.aviso_multicolinearidade?.visible ? (
2343
  <div dangerouslySetInnerHTML={{ __html: selection.aviso_multicolinearidade.html }} />
 
2352
  {selection ? (
2353
  <>
2354
  <SectionBlock step="7" title="Estatísticas das Variáveis Selecionadas" subtitle="Resumo estatístico para Y e regressoras.">
2355
+ <div className="download-actions-bar">
2356
+ <button
2357
+ type="button"
2358
+ className="btn-download-subtle"
2359
+ onClick={() => onDownloadTableCsv(selection.estatisticas, 'secao7_estatisticas')}
2360
+ disabled={loading || downloadingAssets || !selection.estatisticas}
2361
+ >
2362
+ Fazer download
2363
+ </button>
2364
+ </div>
2365
  <DataTable table={selection.estatisticas} />
2366
  </SectionBlock>
2367
 
 
2377
  >
2378
  <summary>Mostrar/Ocultar gráficos da seção</summary>
2379
  {section8Open ? (
2380
+ <>
2381
+ <div className="download-actions-bar">
2382
+ {graficosSecao9.length > 1 ? <span className="download-actions-label">Fazer download:</span> : null}
2383
+ {graficosSecao9.map((item, idx) => {
2384
+ const fileBase = `secao9_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`
2385
+ return (
2386
+ <button
2387
+ key={`s9-dl-${item.id}`}
2388
+ type="button"
2389
+ className="btn-download-subtle"
2390
+ title={item.legenda}
2391
+ onClick={() => onDownloadFigurePng(item.figure, fileBase, { forceHideLegend: true })}
2392
+ disabled={loading || downloadingAssets || !item.figure}
2393
+ >
2394
+ {graficosSecao9.length > 1 ? item.label : 'Fazer download'}
2395
+ </button>
2396
+ )
2397
+ })}
2398
+ {graficosSecao9.length > 1 ? (
2399
+ <button
2400
+ type="button"
2401
+ className="btn-download-subtle"
2402
+ onClick={() => onDownloadFiguresPngBatch(
2403
+ graficosSecao9.map((item, idx) => ({
2404
+ figure: item.figure,
2405
+ fileNameBase: `secao9_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`,
2406
+ forceHideLegend: true,
2407
+ })),
2408
+ )}
2409
+ disabled={loading || downloadingAssets || graficosSecao9.length === 0}
2410
+ >
2411
+ Todos
2412
+ </button>
2413
+ ) : null}
2414
+ </div>
2415
+ {graficosSecao9.length > 0 ? (
2416
+ <div className="plot-grid-scatter" style={{ '--plot-cols': colunasGraficosSecao9 }}>
2417
+ {graficosSecao9.map((item) => (
2418
+ <PlotFigure
2419
+ key={`s9-plot-${item.id}`}
2420
+ figure={item.figure}
2421
+ title={item.title}
2422
+ subtitle={item.subtitle}
2423
+ forceHideLegend
2424
+ className="plot-stretch"
2425
+ lazy
2426
+ />
2427
+ ))}
2428
+ </div>
2429
+ ) : (
2430
+ <div className="empty-box">Grafico indisponivel.</div>
2431
+ )}
2432
+ </>
2433
  ) : null}
2434
  </details>
2435
  </SectionBlock>
 
2449
  ))}
2450
  </select>
2451
  <button onClick={onSearchTransform} disabled={loading}>Buscar transformações</button>
2452
+ {buscaTransformPendente ? <span className="pending-apply-note">{pendingWarningText}</span> : null}
2453
  </div>
2454
  {(selection.busca?.resultados || []).length > 0 ? (
2455
  <div className="transform-suggestions-grid">
 
2531
  ))}
2532
  </div>
2533
 
2534
+ <div className="row row-fit-transformacoes">
2535
+ <button className="btn-fit-model" onClick={onFitModel} disabled={loading}>Aplicar transformações e ajustar modelo</button>
2536
+ {manualTransformPendente ? <span className="pending-apply-note">{pendingWarningText}</span> : null}
2537
+ </div>
2538
  </>
2539
  ) : null}
2540
  {transformacoesAplicadas?.coluna_y ? (
 
2596
  <option value="Variáveis Independentes Não Transformadas X Resíduo Padronizado">X não transformado x Resíduo</option>
2597
  </select>
2598
  </div>
2599
+ <div className="download-actions-bar">
2600
+ {graficosSecao12.length > 1 ? <span className="download-actions-label">Fazer download:</span> : null}
2601
+ {graficosSecao12.map((item, idx) => {
2602
+ const fileBase = `secao12_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`
2603
+ return (
2604
+ <button
2605
+ key={`s12-dl-${item.id}`}
2606
+ type="button"
2607
+ className="btn-download-subtle"
2608
+ title={item.legenda}
2609
+ onClick={() => onDownloadFigurePng(item.figure, fileBase, { forceHideLegend: true })}
2610
+ disabled={loading || downloadingAssets || !item.figure}
2611
+ >
2612
+ {graficosSecao12.length > 1 ? item.label : 'Fazer download'}
2613
+ </button>
2614
+ )
2615
+ })}
2616
+ {graficosSecao12.length > 1 ? (
2617
+ <button
2618
+ type="button"
2619
+ className="btn-download-subtle"
2620
+ onClick={() => onDownloadFiguresPngBatch(
2621
+ graficosSecao12.map((item, idx) => ({
2622
+ figure: item.figure,
2623
+ fileNameBase: `secao12_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`,
2624
+ forceHideLegend: true,
2625
+ })),
2626
+ )}
2627
+ disabled={loading || downloadingAssets || graficosSecao12.length === 0}
2628
+ >
2629
+ Todos
2630
+ </button>
2631
+ ) : null}
2632
+ </div>
2633
+ {graficosSecao12.length > 0 ? (
2634
+ <div className="plot-grid-scatter" style={{ '--plot-cols': colunasGraficosSecao12 }}>
2635
+ {graficosSecao12.map((item) => (
2636
+ <PlotFigure
2637
+ key={`s12-plot-${item.id}`}
2638
+ figure={item.figure}
2639
+ title={item.title}
2640
+ subtitle={item.subtitle}
2641
+ forceHideLegend
2642
+ className="plot-stretch"
2643
+ lazy
2644
+ />
2645
+ ))}
2646
+ </div>
2647
+ ) : (
2648
+ <div className="empty-box">Grafico indisponivel.</div>
2649
+ )}
2650
  </>
2651
  ) : null}
2652
  </details>
 
2654
 
2655
  <SectionBlock step="13" title="Diagnóstico de Modelo" subtitle="Resumo diagnóstico e tabelas principais do ajuste.">
2656
  <div dangerouslySetInnerHTML={{ __html: fit.diagnosticos_html || '' }} />
2657
+ <div className="download-actions-bar">
2658
+ <span className="download-actions-label">Fazer download:</span>
2659
+ <button
2660
+ type="button"
2661
+ className="btn-download-subtle"
2662
+ onClick={() => onDownloadTableCsv(fit.tabela_coef, 'secao13_coeficientes')}
2663
+ disabled={loading || downloadingAssets || !fit.tabela_coef}
2664
+ >
2665
+ Coeficientes
2666
+ </button>
2667
+ <button
2668
+ type="button"
2669
+ className="btn-download-subtle"
2670
+ onClick={() => onDownloadTableCsv(fit.tabela_obs_calc, 'secao13_obs_calc')}
2671
+ disabled={loading || downloadingAssets || !fit.tabela_obs_calc}
2672
+ >
2673
+ Obs x Calc
2674
+ </button>
2675
+ <button
2676
+ type="button"
2677
+ className="btn-download-subtle"
2678
+ onClick={() => onDownloadTablesCsvBatch([
2679
+ { table: fit.tabela_coef, fileNameBase: 'secao13_coeficientes' },
2680
+ { table: fit.tabela_obs_calc, fileNameBase: 'secao13_obs_calc' },
2681
+ ])}
2682
+ disabled={loading || downloadingAssets || (!fit.tabela_coef && !fit.tabela_obs_calc)}
2683
+ >
2684
+ Todos
2685
+ </button>
2686
+ </div>
2687
  <div className="two-col diagnostic-tables">
2688
  <div className="pane">
2689
  <h4>Tabela de Coeficientes</h4>
 
2697
  </SectionBlock>
2698
 
2699
  <SectionBlock step="14" title="Gráficos de Diagnóstico do Modelo" subtitle="Obs x calc, resíduos, histograma, Cook e correlação.">
2700
+ <div className="download-actions-bar">
2701
+ <span className="download-actions-label">Fazer download:</span>
2702
+ <button
2703
+ type="button"
2704
+ className="btn-download-subtle"
2705
+ onClick={() => onDownloadFigurePng(fit.grafico_obs_calc, 'secao14_obs_calc')}
2706
+ disabled={loading || downloadingAssets || !fit.grafico_obs_calc}
2707
+ >
2708
+ Obs x calc
2709
+ </button>
2710
+ <button
2711
+ type="button"
2712
+ className="btn-download-subtle"
2713
+ onClick={() => onDownloadFigurePng(fit.grafico_residuos, 'secao14_residuos')}
2714
+ disabled={loading || downloadingAssets || !fit.grafico_residuos}
2715
+ >
2716
+ Resíduos
2717
+ </button>
2718
+ <button
2719
+ type="button"
2720
+ className="btn-download-subtle"
2721
+ onClick={() => onDownloadFigurePng(fit.grafico_histograma, 'secao14_histograma')}
2722
+ disabled={loading || downloadingAssets || !fit.grafico_histograma}
2723
+ >
2724
+ Histograma
2725
+ </button>
2726
+ <button
2727
+ type="button"
2728
+ className="btn-download-subtle"
2729
+ onClick={() => onDownloadFigurePng(fit.grafico_cook, 'secao14_cook', { forceHideLegend: true })}
2730
+ disabled={loading || downloadingAssets || !fit.grafico_cook}
2731
+ >
2732
+ Cook
2733
+ </button>
2734
+ <button
2735
+ type="button"
2736
+ className="btn-download-subtle"
2737
+ onClick={() => onDownloadFigurePng(fit.grafico_correlacao, 'secao14_correlacao')}
2738
+ disabled={loading || downloadingAssets || !fit.grafico_correlacao}
2739
+ >
2740
+ Correlação
2741
+ </button>
2742
+ <button
2743
+ type="button"
2744
+ className="btn-download-subtle"
2745
+ onClick={() => onDownloadFiguresPngBatch([
2746
+ { figure: fit.grafico_obs_calc, fileNameBase: 'secao14_obs_calc' },
2747
+ { figure: fit.grafico_residuos, fileNameBase: 'secao14_residuos' },
2748
+ { figure: fit.grafico_histograma, fileNameBase: 'secao14_histograma' },
2749
+ { figure: fit.grafico_cook, fileNameBase: 'secao14_cook', forceHideLegend: true },
2750
+ { figure: fit.grafico_correlacao, fileNameBase: 'secao14_correlacao' },
2751
+ ])}
2752
+ disabled={loading || downloadingAssets || (!fit.grafico_obs_calc && !fit.grafico_residuos && !fit.grafico_histograma && !fit.grafico_cook && !fit.grafico_correlacao)}
2753
+ >
2754
+ Todos
2755
+ </button>
2756
+ </div>
2757
  <div className="plot-grid-2-fixed">
2758
  <PlotFigure figure={fit.grafico_obs_calc} title="Obs x Calc" />
2759
  <PlotFigure figure={fit.grafico_residuos} title="Resíduos" />
 
2766
  </SectionBlock>
2767
 
2768
  <SectionBlock step="15" title="Analisar Outliers" subtitle="Métricas para identificação de observações influentes.">
2769
+ <div className="download-actions-bar">
2770
+ <button
2771
+ type="button"
2772
+ className="btn-download-subtle"
2773
+ onClick={() => onDownloadTableCsv(fit.tabela_metricas, 'secao15_tabela_metricas')}
2774
+ disabled={loading || downloadingAssets || !fit.tabela_metricas}
2775
+ >
2776
+ Fazer download
2777
+ </button>
2778
+ </div>
2779
  <DataTable table={fit.tabela_metricas} maxHeight={320} />
2780
  </SectionBlock>
2781
 
 
2837
  <div className="outlier-actions-row">
2838
  <button onClick={onApplyOutlierFilters} disabled={loading}>Aplicar filtros</button>
2839
  <button type="button" className="btn-filtro-add" onClick={onAddFiltro} disabled={loading}>Adicionar filtro</button>
2840
+ {outlierFiltrosPendentes ? <span className="pending-apply-note">{pendingWarningText}</span> : null}
2841
  </div>
2842
  </div>
2843
 
 
2858
  <button onClick={onRestartIteration} disabled={loading} className="btn-reiniciar-iteracao">
2859
  Atualizar Modelo (Excluir/Reincluir Outliers)
2860
  </button>
2861
+ {outlierTextosPendentes ? <span className="pending-apply-note">{pendingWarningText}</span> : null}
2862
  </div>
2863
  </div>
2864
  <div className="resumo-outliers-box">Iteração: {iteracao} | {resumoOutliers}</div>
 
2875
  defaultValue={String(valoresAvaliacaoRef.current[campo.coluna] ?? '')}
2876
  onChange={(e) => {
2877
  valoresAvaliacaoRef.current[campo.coluna] = e.target.value
2878
+ setAvaliacaoPendente(true)
2879
  }}
2880
  >
2881
  <option value="">Selecione</option>
 
2892
  placeholder={campo.placeholder || ''}
2893
  onChange={(e) => {
2894
  valoresAvaliacaoRef.current[campo.coluna] = e.target.value
2895
+ setAvaliacaoPendente(true)
2896
  }}
2897
  />
2898
  )}
 
2903
  <button onClick={onCalculateAvaliacao} disabled={loading}>Calcular</button>
2904
  <button onClick={onClearAvaliacao} disabled={loading}>Limpar</button>
2905
  <button onClick={onExportAvaliacoes} disabled={loading}>Exportar avaliações (Excel)</button>
2906
+ {avaliacaoPendente ? <span className="pending-apply-note">{pendingWarningText}</span> : null}
2907
  </div>
2908
  <div className="row avaliacao-base-row">
2909
  <label>Base comparação</label>
frontend/src/components/PlotFigure.jsx CHANGED
@@ -1,8 +1,37 @@
1
- import React from 'react'
2
  import Plot from 'react-plotly.js'
3
  import Plotly from 'plotly.js-dist-min'
4
 
5
- function PlotFigure({ figure, title, forceHideLegend = false, className = '' }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  if (!figure) {
7
  return <div className="empty-box">Grafico indisponivel.</div>
8
  }
@@ -33,16 +62,25 @@ function PlotFigure({ figure, title, forceHideLegend = false, className = '' })
33
  const cardClassName = `plot-card ${className}`.trim()
34
 
35
  return (
36
- <div className={cardClassName}>
37
- {title ? <h4>{title}</h4> : null}
38
- <Plot
39
- data={data}
40
- layout={layout}
41
- config={{ responsive: true, displaylogo: false }}
42
- style={{ width: '100%', height: plotHeight, minHeight: '320px' }}
43
- useResizeHandler
44
- plotly={Plotly}
45
- />
 
 
 
 
 
 
 
 
 
46
  </div>
47
  )
48
  }
 
1
+ import React, { useEffect, useRef, useState } from 'react'
2
  import Plot from 'react-plotly.js'
3
  import Plotly from 'plotly.js-dist-min'
4
 
5
+ function PlotFigure({ figure, title, subtitle = '', forceHideLegend = false, className = '', lazy = false }) {
6
+ const containerRef = useRef(null)
7
+ const [shouldRenderPlot, setShouldRenderPlot] = useState(() => !lazy)
8
+
9
+ useEffect(() => {
10
+ if (!lazy) {
11
+ setShouldRenderPlot(true)
12
+ return undefined
13
+ }
14
+ if (shouldRenderPlot) return undefined
15
+ if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
16
+ setShouldRenderPlot(true)
17
+ return undefined
18
+ }
19
+ const target = containerRef.current
20
+ if (!target) return undefined
21
+ const observer = new IntersectionObserver(
22
+ (entries) => {
23
+ const isVisible = entries.some((entry) => entry.isIntersecting || entry.intersectionRatio > 0)
24
+ if (isVisible) {
25
+ setShouldRenderPlot(true)
26
+ observer.disconnect()
27
+ }
28
+ },
29
+ { rootMargin: '240px 0px', threshold: 0.01 },
30
+ )
31
+ observer.observe(target)
32
+ return () => observer.disconnect()
33
+ }, [lazy, shouldRenderPlot])
34
+
35
  if (!figure) {
36
  return <div className="empty-box">Grafico indisponivel.</div>
37
  }
 
62
  const cardClassName = `plot-card ${className}`.trim()
63
 
64
  return (
65
+ <div ref={containerRef} className={cardClassName}>
66
+ {title || subtitle ? (
67
+ <div className="plot-card-head">
68
+ {title ? <h4 className="plot-card-title">{title}</h4> : null}
69
+ {subtitle ? <div className="plot-card-subtitle">{subtitle}</div> : null}
70
+ </div>
71
+ ) : null}
72
+ {shouldRenderPlot ? (
73
+ <Plot
74
+ data={data}
75
+ layout={layout}
76
+ config={{ responsive: true, displaylogo: false }}
77
+ style={{ width: '100%', height: plotHeight, minHeight: '320px' }}
78
+ useResizeHandler
79
+ plotly={Plotly}
80
+ />
81
+ ) : (
82
+ <div className="plot-lazy-placeholder">Carregando gráfico...</div>
83
+ )}
84
  </div>
85
  )
86
  }
frontend/src/components/VisualizacaoTab.jsx CHANGED
@@ -8,7 +8,8 @@ import SectionBlock from './SectionBlock'
8
 
9
  const INNER_TABS = [
10
  { key: 'mapa', label: 'Mapa' },
11
- { key: 'dados', label: 'Dados' },
 
12
  { key: 'transformacoes', label: 'Transformações' },
13
  { key: 'resumo', label: 'Resumo' },
14
  { key: 'coeficientes', label: 'Coeficientes' },
@@ -339,22 +340,18 @@ export default function VisualizacaoTab({ sessionId }) {
339
  </>
340
  ) : null}
341
 
342
- {activeInnerTab === 'dados' ? (
343
- <div className="two-col">
344
- <div className="pane">
345
- <h4>Dados</h4>
346
- <DataTable table={dados} maxHeight={520} />
347
- </div>
348
- <div className="pane">
349
- <h4>Estatísticas</h4>
350
- <DataTable table={estatisticas} maxHeight={520} />
351
- </div>
352
- </div>
353
  ) : null}
354
 
355
  {activeInnerTab === 'transformacoes' ? (
356
  <>
357
  <div dangerouslySetInnerHTML={{ __html: escalasHtml }} />
 
358
  <DataTable table={dadosTransformados} />
359
  </>
360
  ) : null}
 
8
 
9
  const INNER_TABS = [
10
  { key: 'mapa', label: 'Mapa' },
11
+ { key: 'dados_mercado', label: 'Dados de Mercado' },
12
+ { key: 'metricas', label: 'Métricas' },
13
  { key: 'transformacoes', label: 'Transformações' },
14
  { key: 'resumo', label: 'Resumo' },
15
  { key: 'coeficientes', label: 'Coeficientes' },
 
340
  </>
341
  ) : null}
342
 
343
+ {activeInnerTab === 'dados_mercado' ? (
344
+ <DataTable table={dados} maxHeight={620} />
345
+ ) : null}
346
+
347
+ {activeInnerTab === 'metricas' ? (
348
+ <DataTable table={estatisticas} maxHeight={620} />
 
 
 
 
 
349
  ) : null}
350
 
351
  {activeInnerTab === 'transformacoes' ? (
352
  <>
353
  <div dangerouslySetInnerHTML={{ __html: escalasHtml }} />
354
+ <h4 className="visualizacao-table-title">Dados com variáveis transformadas</h4>
355
  <DataTable table={dadosTransformados} />
356
  </>
357
  ) : null}
frontend/src/styles.css CHANGED
@@ -214,6 +214,13 @@ textarea {
214
  padding: 14px;
215
  }
216
 
 
 
 
 
 
 
 
217
  .tab-content {
218
  display: flex;
219
  flex-direction: column;
@@ -1972,6 +1979,12 @@ button.btn-upload-select {
1972
  gap: 12px;
1973
  }
1974
 
 
 
 
 
 
 
1975
  .plot-card {
1976
  border: 1px solid #dbe5ef;
1977
  border-radius: 12px;
@@ -1980,15 +1993,44 @@ button.btn-upload-select {
1980
  padding: 8px;
1981
  }
1982
 
1983
- .plot-card h4 {
1984
  margin: 4px 4px 8px;
1985
- color: #34495d;
 
 
 
 
 
 
 
 
1986
  font-family: 'Sora', sans-serif;
1987
- font-size: 0.9rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1988
  }
1989
 
1990
  .plot-card.plot-stretch {
1991
- min-height: 430px;
1992
  }
1993
 
1994
  .plot-full-width {
@@ -2259,6 +2301,15 @@ button.btn-upload-select {
2259
  margin-bottom: 16px;
2260
  }
2261
 
 
 
 
 
 
 
 
 
 
2262
  .transformacao-origem-info {
2263
  margin-top: 6px;
2264
  margin-bottom: 4px;
@@ -2290,6 +2341,48 @@ button.btn-upload-select {
2290
  align-items: center;
2291
  }
2292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2293
  .geo-correcoes {
2294
  display: grid;
2295
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
@@ -2763,12 +2856,16 @@ button.btn-upload-select {
2763
  grid-template-columns: 1fr;
2764
  }
2765
 
 
 
 
 
2766
  .plot-card {
2767
  min-height: 340px;
2768
  }
2769
 
2770
  .plot-card.plot-stretch {
2771
- min-height: 360px;
2772
  }
2773
 
2774
  .plot-card.plot-correlation-card {
 
214
  padding: 14px;
215
  }
216
 
217
+ .visualizacao-table-title {
218
+ margin: 10px 0 8px;
219
+ color: #3a4f64;
220
+ font-family: 'Sora', sans-serif;
221
+ font-size: 0.88rem;
222
+ }
223
+
224
  .tab-content {
225
  display: flex;
226
  flex-direction: column;
 
1979
  gap: 12px;
1980
  }
1981
 
1982
+ .plot-grid-scatter {
1983
+ display: grid;
1984
+ grid-template-columns: repeat(var(--plot-cols, 3), minmax(0, 1fr));
1985
+ gap: 12px;
1986
+ }
1987
+
1988
  .plot-card {
1989
  border: 1px solid #dbe5ef;
1990
  border-radius: 12px;
 
1993
  padding: 8px;
1994
  }
1995
 
1996
+ .plot-card-head {
1997
  margin: 4px 4px 8px;
1998
+ min-height: 42px;
1999
+ display: grid;
2000
+ align-content: start;
2001
+ gap: 2px;
2002
+ }
2003
+
2004
+ .plot-card-title {
2005
+ margin: 0;
2006
+ color: #2f465c;
2007
  font-family: 'Sora', sans-serif;
2008
+ font-size: 0.93rem;
2009
+ font-weight: 700;
2010
+ line-height: 1.2;
2011
+ }
2012
+
2013
+ .plot-card-subtitle {
2014
+ color: #5f7387;
2015
+ font-size: 0.82rem;
2016
+ font-family: 'JetBrains Mono', monospace;
2017
+ line-height: 1.15;
2018
+ }
2019
+
2020
+ .plot-lazy-placeholder {
2021
+ min-height: 320px;
2022
+ display: flex;
2023
+ align-items: center;
2024
+ justify-content: center;
2025
+ color: #6a7f94;
2026
+ font-size: 0.86rem;
2027
+ border: 1px dashed #dbe5ef;
2028
+ border-radius: 10px;
2029
+ background: linear-gradient(180deg, #fbfdff 0%, #f5f9fd 100%);
2030
  }
2031
 
2032
  .plot-card.plot-stretch {
2033
+ min-height: 380px;
2034
  }
2035
 
2036
  .plot-full-width {
 
2301
  margin-bottom: 16px;
2302
  }
2303
 
2304
+ .row-fit-transformacoes {
2305
+ margin-top: 14px;
2306
+ margin-bottom: 16px;
2307
+ }
2308
+
2309
+ .row-fit-transformacoes .btn-fit-model {
2310
+ margin: 0;
2311
+ }
2312
+
2313
  .transformacao-origem-info {
2314
  margin-top: 6px;
2315
  margin-bottom: 4px;
 
2341
  align-items: center;
2342
  }
2343
 
2344
+ .pending-apply-note {
2345
+ display: inline-flex;
2346
+ align-items: center;
2347
+ min-height: 30px;
2348
+ padding: 4px 9px;
2349
+ border: 1px solid #f2cb8f;
2350
+ border-radius: 999px;
2351
+ background: #fff6e8;
2352
+ color: #8a5a15;
2353
+ font-size: 0.76rem;
2354
+ font-weight: 700;
2355
+ line-height: 1.25;
2356
+ }
2357
+
2358
+ .download-actions-bar {
2359
+ display: flex;
2360
+ flex-wrap: wrap;
2361
+ gap: 7px;
2362
+ margin: 8px 0 10px;
2363
+ }
2364
+
2365
+ .download-actions-label {
2366
+ display: inline-flex;
2367
+ align-items: center;
2368
+ font-size: 0.76rem;
2369
+ font-weight: 700;
2370
+ color: #5a7086;
2371
+ padding-right: 2px;
2372
+ }
2373
+
2374
+ button.btn-download-subtle {
2375
+ --btn-bg-start: #f5f8fb;
2376
+ --btn-bg-end: #edf2f7;
2377
+ --btn-border: #c9d7e4;
2378
+ --btn-shadow-soft: rgba(53, 74, 95, 0.08);
2379
+ --btn-shadow-strong: rgba(53, 74, 95, 0.12);
2380
+ color: #3f566d;
2381
+ font-size: 0.76rem;
2382
+ padding: 5px 10px;
2383
+ border-radius: 9px;
2384
+ }
2385
+
2386
  .geo-correcoes {
2387
  display: grid;
2388
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
 
2856
  grid-template-columns: 1fr;
2857
  }
2858
 
2859
+ .plot-grid-scatter {
2860
+ grid-template-columns: 1fr;
2861
+ }
2862
+
2863
  .plot-card {
2864
  min-height: 340px;
2865
  }
2866
 
2867
  .plot-card.plot-stretch {
2868
+ min-height: 330px;
2869
  }
2870
 
2871
  .plot-card.plot-correlation-card {