Guilherme Silberfarb Costa commited on
Commit
0d8b6ec
·
1 Parent(s): a577d9a

alteracoes para grandes quantidades de pontos

Browse files
backend/app/api/elaboracao.py CHANGED
@@ -86,6 +86,14 @@ class DispersaoPayload(SessionPayload):
86
  eixo_y_coluna: str | None = None
87
 
88
 
 
 
 
 
 
 
 
 
89
  class TransformPreviewPayload(SessionPayload):
90
  transformacao_y: str = "(x)"
91
  transformacoes_x: dict[str, str] = Field(default_factory=dict)
@@ -288,6 +296,18 @@ def model_dispersao(payload: DispersaoPayload) -> dict[str, Any]:
288
  )
289
 
290
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  @router.post("/transform-preview")
292
  def transform_preview(payload: TransformPreviewPayload) -> dict[str, Any]:
293
  session = session_store.get(payload.session_id)
 
86
  eixo_y_coluna: str | None = None
87
 
88
 
89
+ class DispersaoInterativoPayload(SessionPayload):
90
+ alvo: str = "secao10"
91
+
92
+
93
+ class DiagnosticoInterativoPayload(SessionPayload):
94
+ grafico: str = "obs_calc"
95
+
96
+
97
  class TransformPreviewPayload(SessionPayload):
98
  transformacao_y: str = "(x)"
99
  transformacoes_x: dict[str, str] = Field(default_factory=dict)
 
296
  )
297
 
298
 
299
+ @router.post("/dispersao-interativo")
300
+ def dispersao_interativo(payload: DispersaoInterativoPayload) -> dict[str, Any]:
301
+ session = session_store.get(payload.session_id)
302
+ return elaboracao_service.obter_grafico_dispersao_interativo(session, payload.alvo)
303
+
304
+
305
+ @router.post("/diagnostico-interativo")
306
+ def diagnostico_interativo(payload: DiagnosticoInterativoPayload) -> dict[str, Any]:
307
+ session = session_store.get(payload.session_id)
308
+ return elaboracao_service.obter_grafico_diagnostico_interativo(session, payload.grafico)
309
+
310
+
311
  @router.post("/transform-preview")
312
  def transform_preview(payload: TransformPreviewPayload) -> dict[str, Any]:
313
  session = session_store.get(payload.session_id)
backend/app/models/session.py CHANGED
@@ -51,6 +51,7 @@ class SessionState:
51
  pacote_visualizacao: dict[str, Any] | None = None
52
  dados_visualizacao: pd.DataFrame | None = None
53
  avaliacoes_visualizacao: list[dict[str, Any]] = field(default_factory=list)
 
54
 
55
  elaborador: dict[str, Any] | None = None
56
 
@@ -62,6 +63,7 @@ class SessionState:
62
  self.avaliacoes_elaboracao = []
63
  self.transformacao_y = "(x)"
64
  self.transformacoes_x = {}
 
65
 
66
  def reset_visualizacao(self) -> None:
67
  self.pacote_visualizacao = None
 
51
  pacote_visualizacao: dict[str, Any] | None = None
52
  dados_visualizacao: pd.DataFrame | None = None
53
  avaliacoes_visualizacao: list[dict[str, Any]] = field(default_factory=list)
54
+ graficos_dispersao_cache: dict[str, dict[str, Any]] = field(default_factory=dict)
55
 
56
  elaborador: dict[str, Any] | None = None
57
 
 
63
  self.avaliacoes_elaboracao = []
64
  self.transformacao_y = "(x)"
65
  self.transformacoes_x = {}
66
+ self.graficos_dispersao_cache = {}
67
 
68
  def reset_visualizacao(self) -> None:
69
  self.pacote_visualizacao = None
backend/app/services/elaboracao_service.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  import json
4
  import os
5
  import re
@@ -10,6 +11,8 @@ from typing import Any
10
 
11
  import numpy as np
12
  import pandas as pd
 
 
13
  from fastapi import HTTPException
14
 
15
  from app.core.elaboracao import charts, geocodificacao
@@ -48,6 +51,7 @@ from app.services.serializers import dataframe_to_payload, figure_to_payload, sa
48
 
49
  _AVALIADORES_PATH = Path(__file__).resolve().parent.parent / "core" / "elaboracao" / "avaliadores.json"
50
  _AVALIADORES_CACHE: list[dict[str, Any]] | None = None
 
51
 
52
 
53
  def _is_rh_col(coluna: str) -> bool:
@@ -143,6 +147,161 @@ def list_avaliadores() -> list[dict[str, Any]]:
143
  return _AVALIADORES_CACHE
144
 
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  def _clean_int_list(values: list[Any] | None) -> list[int]:
147
  if not values:
148
  return []
@@ -799,6 +958,12 @@ def apply_selection(
799
  fig_dispersao = charts.criar_graficos_dispersao(df_filtrado[colunas_x_validas], df_filtrado[coluna_y])
800
  except Exception:
801
  fig_dispersao = None
 
 
 
 
 
 
802
 
803
  busca_payload = search_transformacoes(session, grau_min_coef=grau_min_coef, grau_min_f=grau_min_f)
804
 
@@ -830,7 +995,11 @@ def apply_selection(
830
  return {
831
  "estatisticas": dataframe_to_payload(estatisticas, decimals=4),
832
  "micronumerosidade_html": micro_html,
833
- "grafico_dispersao": figure_to_payload(fig_dispersao),
 
 
 
 
834
  "busca": busca_payload,
835
  "resumo_outliers": resumo_outliers,
836
  "outliers_html": outliers_html,
@@ -1023,6 +1192,13 @@ def fit_model(
1023
  )
1024
  except Exception:
1025
  fig_dispersao_transf = None
 
 
 
 
 
 
 
1026
 
1027
  fig_corr = None
1028
  try:
@@ -1033,6 +1209,26 @@ def fit_model(
1033
  fig_corr = None
1034
 
1035
  graficos = charts.criar_painel_diagnostico(resultado)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1036
 
1037
  tabela_metricas = resultado["tabela_obs_calc"].copy()
1038
  tabela_metricas_estado = tabela_metricas.set_index("Índice")
@@ -1090,11 +1286,22 @@ def fit_model(
1090
  "equacoes": sanitize_value(equacoes),
1091
  "tabela_coef": dataframe_to_payload(resultado["tabela_coef"], decimals=4),
1092
  "tabela_obs_calc": dataframe_to_payload(resultado["tabela_obs_calc"], decimals=4),
1093
- "grafico_dispersao_modelo": figure_to_payload(fig_dispersao_transf),
1094
- "grafico_obs_calc": figure_to_payload(graficos.get("obs_calc")),
1095
- "grafico_residuos": figure_to_payload(graficos.get("residuos")),
1096
- "grafico_histograma": figure_to_payload(graficos.get("histograma")),
1097
- "grafico_cook": figure_to_payload(graficos.get("cook")),
 
 
 
 
 
 
 
 
 
 
 
1098
  "grafico_correlacao": figure_to_payload(fig_corr),
1099
  "tabela_metricas": dataframe_to_payload(tabela_metricas, decimals=4),
1100
  "tabela_outliers_excluidos": tabela_outliers_excluidos,
@@ -1214,9 +1421,19 @@ def gerar_grafico_dispersao_modelo(
1214
  fig = charts.criar_graficos_dispersao(x_base, y_base)
1215
  except Exception:
1216
  fig = None
 
 
 
 
 
 
1217
 
1218
  return {
1219
- "grafico": figure_to_payload(fig),
 
 
 
 
1220
  "eixo_x_aplicado": "nao_transformado" if eixo_x_norm in {"nao_transformado", "não_transformado"} else "transformado",
1221
  "eixo_y_tipo_aplicado": eixo_y_norm,
1222
  "eixo_y_residuo_aplicado": eixo_residuo_norm if eixo_y_norm == "residuo" else None,
@@ -1225,6 +1442,43 @@ def gerar_grafico_dispersao_modelo(
1225
  }
1226
 
1227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1228
  def apply_outlier_filters(session: SessionState, filtros: list[dict[str, Any]]) -> dict[str, Any]:
1229
  metricas = session.tabela_metricas_estado
1230
  if metricas is None:
 
1
  from __future__ import annotations
2
 
3
+ import base64
4
  import json
5
  import os
6
  import re
 
11
 
12
  import numpy as np
13
  import pandas as pd
14
+ import plotly.graph_objects as go
15
+ import plotly.io as pio
16
  from fastapi import HTTPException
17
 
18
  from app.core.elaboracao import charts, geocodificacao
 
51
 
52
  _AVALIADORES_PATH = Path(__file__).resolve().parent.parent / "core" / "elaboracao" / "avaliadores.json"
53
  _AVALIADORES_CACHE: list[dict[str, Any]] | None = None
54
+ LIMIAR_DISPERSAO_PNG = 1000
55
 
56
 
57
  def _is_rh_col(coluna: str) -> bool:
 
147
  return _AVALIADORES_CACHE
148
 
149
 
150
+ def _usar_png_dispersao(total_pontos: int) -> bool:
151
+ return int(total_pontos) > LIMIAR_DISPERSAO_PNG
152
+
153
+
154
+ def _contar_paineis_dispersao(fig: Any) -> int:
155
+ try:
156
+ data = list(getattr(fig, "data", []) or [])
157
+ eixos: set[str] = set()
158
+ for trace in data:
159
+ eixo = str(getattr(trace, "xaxis", "") or "").strip().lower()
160
+ if not eixo:
161
+ eixo = "x"
162
+ eixos.add(eixo)
163
+ return max(1, len(eixos))
164
+ except Exception:
165
+ return 1
166
+
167
+
168
+ def _estilizar_figura_dispersao_png(fig: Any) -> Any:
169
+ try:
170
+ fig_out = go.Figure(fig)
171
+ except Exception:
172
+ return fig
173
+
174
+ for trace in fig_out.data or []:
175
+ mode = str(getattr(trace, "mode", "") or "")
176
+ if "markers" in mode:
177
+ marker_atual = getattr(trace, "marker", None)
178
+ marker = dict(marker_atual.to_plotly_json() if hasattr(marker_atual, "to_plotly_json") else (marker_atual or {}))
179
+ marker["color"] = "#FF8C00"
180
+ marker["size"] = float(marker.get("size") or 8)
181
+ marker["opacity"] = float(marker.get("opacity") if marker.get("opacity") is not None else 0.84)
182
+ linha = dict(marker.get("line") or {})
183
+ linha["color"] = linha.get("color") or "#1f2933"
184
+ linha["width"] = float(linha.get("width") or 1)
185
+ marker["line"] = linha
186
+ trace.marker = marker
187
+
188
+ if "lines" in mode:
189
+ line_atual = getattr(trace, "line", None)
190
+ line = dict(line_atual.to_plotly_json() if hasattr(line_atual, "to_plotly_json") else (line_atual or {}))
191
+ line["color"] = line.get("color") or "#dc3545"
192
+ line["width"] = float(line.get("width") or 2)
193
+ trace.line = line
194
+
195
+ trace.showlegend = False
196
+
197
+ fig_out.update_layout(
198
+ showlegend=False,
199
+ plot_bgcolor="white",
200
+ paper_bgcolor="white",
201
+ margin=dict(t=78, r=20, b=48, l=54),
202
+ )
203
+ fig_out.update_xaxes(showgrid=True, gridcolor="#d7dde3", showline=True, linecolor="#1f2933", zeroline=False)
204
+ fig_out.update_yaxes(showgrid=True, gridcolor="#d7dde3", showline=True, linecolor="#1f2933", zeroline=False)
205
+ return fig_out
206
+
207
+
208
+ def _renderizar_png_grafico(fig: Any, *, estilo: str | None = None) -> dict[str, Any] | None:
209
+ if fig is None:
210
+ return None
211
+ try:
212
+ figura_export = _estilizar_figura_dispersao_png(fig) if str(estilo or "") == "dispersao" else fig
213
+ largura_layout = int(getattr(figura_export.layout, "width", 0) or 0)
214
+ altura_layout = int(getattr(figura_export.layout, "height", 0) or 0)
215
+
216
+ if str(estilo or "") == "dispersao":
217
+ paineis = _contar_paineis_dispersao(figura_export)
218
+ n_cols = min(3, max(1, paineis))
219
+ altura = altura_layout if altura_layout > 0 else 900
220
+ largura = largura_layout if largura_layout > 0 else (460 * n_cols)
221
+ largura = max(860, min(largura, 2200))
222
+ altura = max(520, min(altura, 1800))
223
+ else:
224
+ altura = altura_layout if altura_layout > 0 else 420
225
+ largura = largura_layout if largura_layout > 0 else int(round(altura * 1.75))
226
+ largura = max(640, min(largura, 2000))
227
+ altura = max(360, min(altura, 1600))
228
+
229
+ imagem = pio.to_image(figura_export, format="png", width=largura, height=altura, scale=1)
230
+ return {
231
+ "mime_type": "image/png",
232
+ "image_base64": base64.b64encode(imagem).decode("ascii"),
233
+ "width": largura,
234
+ "height": altura,
235
+ }
236
+ except Exception:
237
+ return None
238
+
239
+
240
+ def _montar_payload_dispersao(
241
+ session: SessionState,
242
+ *,
243
+ cache_key: str,
244
+ fig: Any,
245
+ total_pontos: int,
246
+ estilo_png: str | None = "dispersao",
247
+ ) -> dict[str, Any]:
248
+ key = str(cache_key or "").strip().lower()
249
+ if not key:
250
+ raise ValueError("cache_key invalido")
251
+
252
+ if fig is None:
253
+ session.graficos_dispersao_cache.pop(key, None)
254
+ return {
255
+ "modo": "interativo",
256
+ "grafico": None,
257
+ "grafico_png": None,
258
+ "total_pontos": int(total_pontos),
259
+ "limiar_png": LIMIAR_DISPERSAO_PNG,
260
+ }
261
+
262
+ payload_interativo = figure_to_payload(fig)
263
+ if not payload_interativo:
264
+ session.graficos_dispersao_cache.pop(key, None)
265
+ return {
266
+ "modo": "interativo",
267
+ "grafico": None,
268
+ "grafico_png": None,
269
+ "total_pontos": int(total_pontos),
270
+ "limiar_png": LIMIAR_DISPERSAO_PNG,
271
+ }
272
+
273
+ usar_png = _usar_png_dispersao(int(total_pontos))
274
+ if not usar_png:
275
+ session.graficos_dispersao_cache.pop(key, None)
276
+ return {
277
+ "modo": "interativo",
278
+ "grafico": payload_interativo,
279
+ "grafico_png": None,
280
+ "total_pontos": int(total_pontos),
281
+ "limiar_png": LIMIAR_DISPERSAO_PNG,
282
+ }
283
+
284
+ payload_png = _renderizar_png_grafico(fig, estilo=estilo_png)
285
+ if not payload_png:
286
+ session.graficos_dispersao_cache.pop(key, None)
287
+ return {
288
+ "modo": "interativo",
289
+ "grafico": payload_interativo,
290
+ "grafico_png": None,
291
+ "total_pontos": int(total_pontos),
292
+ "limiar_png": LIMIAR_DISPERSAO_PNG,
293
+ }
294
+
295
+ session.graficos_dispersao_cache[key] = payload_interativo
296
+ return {
297
+ "modo": "png",
298
+ "grafico": None,
299
+ "grafico_png": payload_png,
300
+ "total_pontos": int(total_pontos),
301
+ "limiar_png": LIMIAR_DISPERSAO_PNG,
302
+ }
303
+
304
+
305
  def _clean_int_list(values: list[Any] | None) -> list[int]:
306
  if not values:
307
  return []
 
958
  fig_dispersao = charts.criar_graficos_dispersao(df_filtrado[colunas_x_validas], df_filtrado[coluna_y])
959
  except Exception:
960
  fig_dispersao = None
961
+ dispersao_payload = _montar_payload_dispersao(
962
+ session,
963
+ cache_key="secao10",
964
+ fig=fig_dispersao,
965
+ total_pontos=len(df_filtrado.index),
966
+ )
967
 
968
  busca_payload = search_transformacoes(session, grau_min_coef=grau_min_coef, grau_min_f=grau_min_f)
969
 
 
995
  return {
996
  "estatisticas": dataframe_to_payload(estatisticas, decimals=4),
997
  "micronumerosidade_html": micro_html,
998
+ "grafico_dispersao": dispersao_payload.get("grafico"),
999
+ "grafico_dispersao_modo": dispersao_payload.get("modo"),
1000
+ "grafico_dispersao_png": dispersao_payload.get("grafico_png"),
1001
+ "grafico_dispersao_total_pontos": dispersao_payload.get("total_pontos"),
1002
+ "grafico_dispersao_limiar_png": dispersao_payload.get("limiar_png"),
1003
  "busca": busca_payload,
1004
  "resumo_outliers": resumo_outliers,
1005
  "outliers_html": outliers_html,
 
1192
  )
1193
  except Exception:
1194
  fig_dispersao_transf = None
1195
+ total_pontos_modelo = int(getattr(resultado.get("X_transformado"), "shape", [0])[0] or 0)
1196
+ dispersao_modelo_payload = _montar_payload_dispersao(
1197
+ session,
1198
+ cache_key="secao13",
1199
+ fig=fig_dispersao_transf,
1200
+ total_pontos=total_pontos_modelo,
1201
+ )
1202
 
1203
  fig_corr = None
1204
  try:
 
1209
  fig_corr = None
1210
 
1211
  graficos = charts.criar_painel_diagnostico(resultado)
1212
+ usar_png_diagnosticos = _usar_png_dispersao(total_pontos_modelo)
1213
+ obs_calc_png = _renderizar_png_grafico(graficos.get("obs_calc")) if usar_png_diagnosticos else None
1214
+ residuos_png = _renderizar_png_grafico(graficos.get("residuos")) if usar_png_diagnosticos else None
1215
+ histograma_png = _renderizar_png_grafico(graficos.get("histograma")) if usar_png_diagnosticos else None
1216
+ cook_png = _renderizar_png_grafico(graficos.get("cook")) if usar_png_diagnosticos else None
1217
+ diagnosticos_cache_map = {
1218
+ "secao15_obs_calc": graficos.get("obs_calc"),
1219
+ "secao15_residuos": graficos.get("residuos"),
1220
+ "secao15_histograma": graficos.get("histograma"),
1221
+ "secao15_cook": graficos.get("cook"),
1222
+ }
1223
+ for cache_key, fig_diag in diagnosticos_cache_map.items():
1224
+ if usar_png_diagnosticos and fig_diag is not None:
1225
+ payload_diag = figure_to_payload(fig_diag)
1226
+ if payload_diag:
1227
+ session.graficos_dispersao_cache[cache_key] = payload_diag
1228
+ else:
1229
+ session.graficos_dispersao_cache.pop(cache_key, None)
1230
+ else:
1231
+ session.graficos_dispersao_cache.pop(cache_key, None)
1232
 
1233
  tabela_metricas = resultado["tabela_obs_calc"].copy()
1234
  tabela_metricas_estado = tabela_metricas.set_index("Índice")
 
1286
  "equacoes": sanitize_value(equacoes),
1287
  "tabela_coef": dataframe_to_payload(resultado["tabela_coef"], decimals=4),
1288
  "tabela_obs_calc": dataframe_to_payload(resultado["tabela_obs_calc"], decimals=4),
1289
+ "grafico_dispersao_modelo": dispersao_modelo_payload.get("grafico"),
1290
+ "grafico_dispersao_modelo_modo": dispersao_modelo_payload.get("modo"),
1291
+ "grafico_dispersao_modelo_png": dispersao_modelo_payload.get("grafico_png"),
1292
+ "grafico_dispersao_modelo_total_pontos": dispersao_modelo_payload.get("total_pontos"),
1293
+ "grafico_dispersao_modelo_limiar_png": dispersao_modelo_payload.get("limiar_png"),
1294
+ "graficos_diagnostico_modo": "png" if usar_png_diagnosticos else "interativo",
1295
+ "graficos_diagnostico_total_pontos": total_pontos_modelo,
1296
+ "graficos_diagnostico_limiar_png": LIMIAR_DISPERSAO_PNG,
1297
+ "grafico_obs_calc": None if usar_png_diagnosticos else figure_to_payload(graficos.get("obs_calc")),
1298
+ "grafico_residuos": None if usar_png_diagnosticos else figure_to_payload(graficos.get("residuos")),
1299
+ "grafico_histograma": None if usar_png_diagnosticos else figure_to_payload(graficos.get("histograma")),
1300
+ "grafico_cook": None if usar_png_diagnosticos else figure_to_payload(graficos.get("cook")),
1301
+ "grafico_obs_calc_png": obs_calc_png,
1302
+ "grafico_residuos_png": residuos_png,
1303
+ "grafico_histograma_png": histograma_png,
1304
+ "grafico_cook_png": cook_png,
1305
  "grafico_correlacao": figure_to_payload(fig_corr),
1306
  "tabela_metricas": dataframe_to_payload(tabela_metricas, decimals=4),
1307
  "tabela_outliers_excluidos": tabela_outliers_excluidos,
 
1421
  fig = charts.criar_graficos_dispersao(x_base, y_base)
1422
  except Exception:
1423
  fig = None
1424
+ dispersao_payload = _montar_payload_dispersao(
1425
+ session,
1426
+ cache_key="secao13",
1427
+ fig=fig,
1428
+ total_pontos=int(getattr(x_base, "shape", [0])[0] or 0),
1429
+ )
1430
 
1431
  return {
1432
+ "grafico": dispersao_payload.get("grafico"),
1433
+ "modo": dispersao_payload.get("modo"),
1434
+ "grafico_png": dispersao_payload.get("grafico_png"),
1435
+ "total_pontos": dispersao_payload.get("total_pontos"),
1436
+ "limiar_png": dispersao_payload.get("limiar_png"),
1437
  "eixo_x_aplicado": "nao_transformado" if eixo_x_norm in {"nao_transformado", "não_transformado"} else "transformado",
1438
  "eixo_y_tipo_aplicado": eixo_y_norm,
1439
  "eixo_y_residuo_aplicado": eixo_residuo_norm if eixo_y_norm == "residuo" else None,
 
1442
  }
1443
 
1444
 
1445
+ def obter_grafico_dispersao_interativo(session: SessionState, alvo: str) -> dict[str, Any]:
1446
+ key = str(alvo or "").strip().lower()
1447
+ if key not in {"secao10", "secao13"}:
1448
+ raise HTTPException(status_code=400, detail="Alvo de grafico invalido")
1449
+
1450
+ grafico = session.graficos_dispersao_cache.get(key)
1451
+ if not grafico:
1452
+ raise HTTPException(status_code=404, detail="Grafico interativo indisponivel para este alvo")
1453
+
1454
+ return {
1455
+ "alvo": key,
1456
+ "grafico": sanitize_value(grafico),
1457
+ }
1458
+
1459
+
1460
+ def obter_grafico_diagnostico_interativo(session: SessionState, grafico: str) -> dict[str, Any]:
1461
+ alvo = str(grafico or "").strip().lower()
1462
+ mapa_alvos = {
1463
+ "obs_calc": "secao15_obs_calc",
1464
+ "residuos": "secao15_residuos",
1465
+ "histograma": "secao15_histograma",
1466
+ "cook": "secao15_cook",
1467
+ }
1468
+ key = mapa_alvos.get(alvo)
1469
+ if not key:
1470
+ raise HTTPException(status_code=400, detail="Grafico diagnostico invalido")
1471
+
1472
+ payload = session.graficos_dispersao_cache.get(key)
1473
+ if not payload:
1474
+ raise HTTPException(status_code=404, detail="Grafico diagnostico interativo indisponivel")
1475
+
1476
+ return {
1477
+ "grafico": alvo,
1478
+ "payload": sanitize_value(payload),
1479
+ }
1480
+
1481
+
1482
  def apply_outlier_filters(session: SessionState, filtros: list[dict[str, Any]]) -> dict[str, Any]:
1483
  metricas = session.tabela_metricas_estado
1484
  if metricas is None:
backend/requirements.txt CHANGED
@@ -7,6 +7,7 @@ numpy
7
  statsmodels
8
  scipy
9
  plotly
 
10
  folium
11
  branca
12
  joblib
 
7
  statsmodels
8
  scipy
9
  plotly
10
+ kaleido
11
  folium
12
  branca
13
  joblib
frontend/src/api.js CHANGED
@@ -161,6 +161,14 @@ export const api = {
161
  session_id: sessionId,
162
  ...(payload || {}),
163
  }),
 
 
 
 
 
 
 
 
164
  previewTransformElab: (sessionId, transformacaoY, transformacoesX) => postJson('/api/elaboracao/transform-preview', {
165
  session_id: sessionId,
166
  transformacao_y: transformacaoY,
 
161
  session_id: sessionId,
162
  ...(payload || {}),
163
  }),
164
+ getDispersaoInterativo: (sessionId, alvo) => postJson('/api/elaboracao/dispersao-interativo', {
165
+ session_id: sessionId,
166
+ alvo,
167
+ }),
168
+ getDiagnosticoInterativo: (sessionId, grafico) => postJson('/api/elaboracao/diagnostico-interativo', {
169
+ session_id: sessionId,
170
+ grafico,
171
+ }),
172
  previewTransformElab: (sessionId, transformacaoY, transformacoesX) => postJson('/api/elaboracao/transform-preview', {
173
  session_id: sessionId,
174
  transformacao_y: transformacaoY,
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -371,7 +371,8 @@ function stripPanelLayoutAxes(layout) {
371
  return out
372
  }
373
 
374
- function stylePanelTrace(trace) {
 
375
  const mode = String(trace?.mode || '')
376
  const traceName = String(trace?.name || '').toLowerCase()
377
  const hasMarkers = mode.includes('markers')
@@ -379,6 +380,9 @@ function stylePanelTrace(trace) {
379
  const next = { ...trace, showlegend: false }
380
 
381
  if (hasMarkers) {
 
 
 
382
  next.marker = {
383
  ...(trace?.marker || {}),
384
  color: '#FF8C00',
@@ -446,7 +450,7 @@ function buildScatterPanels(figure, options = {}) {
446
  ...item.trace,
447
  xaxis: 'x',
448
  yaxis: 'y',
449
- }))
450
 
451
  const panelFigure = {
452
  data: panelTraces,
@@ -637,6 +641,27 @@ function obterLabelGrau(listaGraus, valor) {
637
  return item?.label || 'Sem enquadramento'
638
  }
639
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  export default function ElaboracaoTab({ sessionId }) {
641
  const [loading, setLoading] = useState(false)
642
  const [downloadingAssets, setDownloadingAssets] = useState(false)
@@ -720,6 +745,12 @@ export default function ElaboracaoTab({ sessionId }) {
720
  const [dispersaoEixoYTipo, setDispersaoEixoYTipo] = useState('y_transformado')
721
  const [dispersaoEixoYResiduo, setDispersaoEixoYResiduo] = useState('residuo_pad')
722
  const [dispersaoEixoYColuna, setDispersaoEixoYColuna] = useState('')
 
 
 
 
 
 
723
 
724
  const [filtros, setFiltros] = useState(defaultFiltros())
725
  const [outliersTexto, setOutliersTexto] = useState('')
@@ -970,6 +1001,19 @@ export default function ElaboracaoTab({ sessionId }) {
970
  }
971
  return ''
972
  }, [origemTransformacoes])
 
 
 
 
 
 
 
 
 
 
 
 
 
973
  const graficosSecao9 = useMemo(
974
  () => buildScatterPanels(selection?.grafico_dispersao, {
975
  singleLabel: 'Dispersão',
@@ -978,6 +1022,25 @@ export default function ElaboracaoTab({ sessionId }) {
978
  }),
979
  [selection?.grafico_dispersao, colunaY],
980
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
981
  const colunasGraficosSecao9 = useMemo(
982
  () => Math.max(1, Math.min(3, graficosSecao9.length || 1)),
983
  [graficosSecao9.length],
@@ -1014,10 +1077,60 @@ export default function ElaboracaoTab({ sessionId }) {
1014
  }),
1015
  [fit?.grafico_dispersao_modelo, yLabelSecao13],
1016
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1017
  const colunasGraficosSecao12 = useMemo(
1018
  () => Math.max(1, Math.min(3, graficosSecao12.length || 1)),
1019
  [graficosSecao12.length],
1020
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1021
  const temAvaliacoes = useMemo(
1022
  () => Array.isArray(baseChoices) && baseChoices.length > 0,
1023
  [baseChoices],
@@ -1130,6 +1243,33 @@ export default function ElaboracaoTab({ sessionId }) {
1130
  }
1131
  }, [colunasOriginaisDispersao, dispersaoEixoYColuna])
1132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1133
  useEffect(() => {
1134
  if (!sessionId || tipoFonteDados === 'dai') return undefined
1135
 
@@ -1409,6 +1549,10 @@ export default function ElaboracaoTab({ sessionId }) {
1409
  setSelection(null)
1410
  setSection6EditOpen(true)
1411
  setFit(null)
 
 
 
 
1412
  setFiltros(defaultFiltros())
1413
  setOutliersTexto('')
1414
  setReincluirTexto('')
@@ -1444,6 +1588,8 @@ export default function ElaboracaoTab({ sessionId }) {
1444
 
1445
  function applySelectionResponse(resp) {
1446
  setSelection(resp)
 
 
1447
  setSection6EditOpen(false)
1448
  setSection11LocksOpen(false)
1449
  setSection10ManualOpen(false)
@@ -1493,6 +1639,10 @@ export default function ElaboracaoTab({ sessionId }) {
1493
 
1494
  function applyFitResponse(resp, origemMeta = null) {
1495
  setFit(resp)
 
 
 
 
1496
  setDispersaoEixoX('transformado')
1497
  setDispersaoEixoYTipo('y_transformado')
1498
  setDispersaoEixoYResiduo('residuo_pad')
@@ -1585,6 +1735,10 @@ export default function ElaboracaoTab({ sessionId }) {
1585
  setGrauCoef(0)
1586
  setGrauF(0)
1587
  setFit(null)
 
 
 
 
1588
  setSelectionAppliedSnapshot(buildSelectionSnapshot())
1589
  setColunasX([])
1590
  setDicotomicas([])
@@ -1819,6 +1973,10 @@ export default function ElaboracaoTab({ sessionId }) {
1819
  setSelectionAppliedSnapshot(buildSelectionSnapshot({ coluna_y: proximaColunaY }))
1820
  setSelection(null)
1821
  setFit(null)
 
 
 
 
1822
  setTransformacaoY('(x)')
1823
  setTransformacoesX({})
1824
  setTransformacaoYFixaBusca('Livre')
@@ -1980,6 +2138,8 @@ export default function ElaboracaoTab({ sessionId }) {
1980
  setSelectionAppliedSnapshot(buildSelectionSnapshot(payload))
1981
  setSection6EditOpen(false)
1982
  setFit(null)
 
 
1983
  setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
1984
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
1985
  setCamposAvaliacao([])
@@ -2082,7 +2242,16 @@ export default function ElaboracaoTab({ sessionId }) {
2082
  eixo_y_residuo: eixoYTipo === 'residuo' ? eixoYResiduo : null,
2083
  eixo_y_coluna: eixoYTipo === 'coluna_original' ? eixoYColuna : null,
2084
  })
2085
- setFit((prev) => ({ ...prev, grafico_dispersao_modelo: resp.grafico }))
 
 
 
 
 
 
 
 
 
2086
  })
2087
  }
2088
 
@@ -2204,6 +2373,8 @@ export default function ElaboracaoTab({ sessionId }) {
2204
  setReincluirTexto('')
2205
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
2206
  setFit(null)
 
 
2207
  setTransformacoesAplicadas(null)
2208
  setOrigemTransformacoes(null)
2209
  setSection10ManualOpen(true)
@@ -2230,6 +2401,10 @@ export default function ElaboracaoTab({ sessionId }) {
2230
  setSelection(null)
2231
  setSection6EditOpen(true)
2232
  setFit(null)
 
 
 
 
2233
  setTransformacoesAplicadas(null)
2234
  setOrigemTransformacoes(null)
2235
  setCamposAvaliacao([])
@@ -2567,6 +2742,97 @@ export default function ElaboracaoTab({ sessionId }) {
2567
  }
2568
  }
2569
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2570
  function toggleSelection(setter, value) {
2571
  setter((prev) => {
2572
  if (prev.includes(value)) return prev.filter((item) => item !== value)
@@ -3457,56 +3723,135 @@ export default function ElaboracaoTab({ sessionId }) {
3457
  </SectionBlock>
3458
 
3459
  <SectionBlock step="10" title="Gráficos de Dispersão das Variáveis Independentes" subtitle="Leitura visual entre X e Y no conjunto filtrado.">
3460
- <div className="download-actions-bar">
3461
- {graficosSecao9.length > 1 ? <span className="download-actions-label">Fazer download:</span> : null}
3462
- {graficosSecao9.map((item, idx) => {
3463
- const fileBase = `secao10_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`
3464
- return (
 
 
 
3465
  <button
3466
- key={`s9-dl-${item.id}`}
3467
  type="button"
3468
  className="btn-download-subtle"
3469
- title={item.legenda}
3470
- onClick={() => onDownloadFigurePng(item.figure, fileBase, { forceHideLegend: true })}
3471
- disabled={loading || downloadingAssets || !item.figure}
3472
  >
3473
- {graficosSecao9.length > 1 ? item.label : 'Fazer download'}
3474
  </button>
3475
- )
3476
- })}
3477
- {graficosSecao9.length > 1 ? (
3478
- <button
3479
- type="button"
3480
- className="btn-download-subtle"
3481
- onClick={() => onDownloadFiguresPngBatch(
3482
- graficosSecao9.map((item, idx) => ({
3483
- figure: item.figure,
3484
- fileNameBase: `secao10_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`,
3485
- forceHideLegend: true,
3486
- })),
3487
- )}
3488
- disabled={loading || downloadingAssets || graficosSecao9.length === 0}
3489
- >
3490
- Todos
3491
- </button>
3492
- ) : null}
3493
- </div>
3494
- {graficosSecao9.length > 0 ? (
3495
- <div className="plot-grid-scatter" style={{ '--plot-cols': colunasGraficosSecao9 }}>
3496
- {graficosSecao9.map((item) => (
3497
- <PlotFigure
3498
- key={`s9-plot-${item.id}`}
3499
- figure={item.figure}
3500
- title={item.title}
3501
- subtitle={item.subtitle}
3502
- forceHideLegend
3503
- className="plot-stretch"
3504
- lazy
3505
  />
3506
- ))}
3507
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3508
  ) : (
3509
- <div className="empty-box">Grafico indisponivel.</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3510
  )}
3511
  </SectionBlock>
3512
 
@@ -3740,6 +4085,11 @@ export default function ElaboracaoTab({ sessionId }) {
3740
  {fit ? (
3741
  <>
3742
  <SectionBlock step="13" title="Visualizar Mapa dos Dados de Mercado" subtitle="Escolha livre dos eixos para análise gráfica do modelo.">
 
 
 
 
 
3743
  <div className="row dispersao-config-row">
3744
  <div className="dispersao-config-field">
3745
  <label>Eixo X</label>
@@ -3810,56 +4160,148 @@ export default function ElaboracaoTab({ sessionId }) {
3810
  </div>
3811
  ) : null}
3812
  </div>
3813
- <div className="download-actions-bar">
3814
- {graficosSecao12.length > 1 ? <span className="download-actions-label">Fazer download:</span> : null}
3815
- {graficosSecao12.map((item, idx) => {
3816
- const fileBase = `secao13_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`
3817
- return (
3818
  <button
3819
- key={`s12-dl-${item.id}`}
3820
  type="button"
3821
  className="btn-download-subtle"
3822
- title={item.legenda}
3823
- onClick={() => onDownloadFigurePng(item.figure, fileBase, { forceHideLegend: true })}
3824
- disabled={loading || downloadingAssets || !item.figure}
3825
  >
3826
- {graficosSecao12.length > 1 ? item.label : 'Fazer download'}
3827
  </button>
3828
- )
3829
- })}
3830
- {graficosSecao12.length > 1 ? (
3831
- <button
3832
- type="button"
3833
- className="btn-download-subtle"
3834
- onClick={() => onDownloadFiguresPngBatch(
3835
- graficosSecao12.map((item, idx) => ({
3836
- figure: item.figure,
3837
- fileNameBase: `secao13_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`,
3838
- forceHideLegend: true,
3839
- })),
3840
- )}
3841
- disabled={loading || downloadingAssets || graficosSecao12.length === 0}
3842
- >
3843
- Todos
3844
- </button>
3845
- ) : null}
3846
- </div>
3847
- {graficosSecao12.length > 0 ? (
3848
- <div className="plot-grid-scatter" style={{ '--plot-cols': colunasGraficosSecao12 }}>
3849
- {graficosSecao12.map((item) => (
3850
- <PlotFigure
3851
- key={`s12-plot-${item.id}`}
3852
- figure={item.figure}
3853
- title={item.title}
3854
- subtitle={item.subtitle}
3855
- forceHideLegend
3856
- className="plot-stretch"
3857
- lazy
3858
  />
3859
- ))}
3860
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3861
  ) : (
3862
- <div className="empty-box">Grafico indisponivel.</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3863
  )}
3864
  </SectionBlock>
3865
 
@@ -3916,37 +4358,58 @@ export default function ElaboracaoTab({ sessionId }) {
3916
  </SectionBlock>
3917
 
3918
  <SectionBlock step="15" title="Gráficos de Diagnóstico do Modelo" subtitle="Obs x calc, resíduos, histograma, Cook e correlação.">
 
 
 
 
 
3919
  <div className="download-actions-bar">
3920
  <span className="download-actions-label">Fazer download:</span>
3921
  <button
3922
  type="button"
3923
  className="btn-download-subtle"
3924
- onClick={() => onDownloadFigurePng(fit.grafico_obs_calc, 'secao15_obs_calc')}
3925
- disabled={loading || downloadingAssets || !fit.grafico_obs_calc}
 
 
 
 
3926
  >
3927
  Obs x calc
3928
  </button>
3929
  <button
3930
  type="button"
3931
  className="btn-download-subtle"
3932
- onClick={() => onDownloadFigurePng(fit.grafico_residuos, 'secao15_residuos')}
3933
- disabled={loading || downloadingAssets || !fit.grafico_residuos}
 
 
 
 
3934
  >
3935
  Resíduos
3936
  </button>
3937
  <button
3938
  type="button"
3939
  className="btn-download-subtle"
3940
- onClick={() => onDownloadFigurePng(fit.grafico_histograma, 'secao15_histograma')}
3941
- disabled={loading || downloadingAssets || !fit.grafico_histograma}
 
 
 
 
3942
  >
3943
  Histograma
3944
  </button>
3945
  <button
3946
  type="button"
3947
  className="btn-download-subtle"
3948
- onClick={() => onDownloadFigurePng(fit.grafico_cook, 'secao15_cook', { forceHideLegend: true })}
3949
- disabled={loading || downloadingAssets || !fit.grafico_cook}
 
 
 
 
3950
  >
3951
  Cook
3952
  </button>
@@ -3961,27 +4424,102 @@ export default function ElaboracaoTab({ sessionId }) {
3961
  <button
3962
  type="button"
3963
  className="btn-download-subtle"
3964
- onClick={() => onDownloadFiguresPngBatch([
3965
- { figure: fit.grafico_obs_calc, fileNameBase: 'secao15_obs_calc' },
3966
- { figure: fit.grafico_residuos, fileNameBase: 'secao15_residuos' },
3967
- { figure: fit.grafico_histograma, fileNameBase: 'secao15_histograma' },
3968
- { figure: fit.grafico_cook, fileNameBase: 'secao15_cook', forceHideLegend: true },
3969
- { figure: fit.grafico_correlacao, fileNameBase: 'secao15_correlacao' },
3970
- ])}
3971
- disabled={loading || downloadingAssets || (!fit.grafico_obs_calc && !fit.grafico_residuos && !fit.grafico_histograma && !fit.grafico_cook && !fit.grafico_correlacao)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3972
  >
3973
  Todos
3974
  </button>
3975
  </div>
3976
- <div className="plot-grid-2-fixed">
3977
- <PlotFigure figure={fit.grafico_obs_calc} title="Obs x Calc" />
3978
- <PlotFigure figure={fit.grafico_residuos} title="Resíduos" />
3979
- <PlotFigure figure={fit.grafico_histograma} title="Histograma" />
3980
- <PlotFigure figure={fit.grafico_cook} title="Cook" forceHideLegend />
3981
- </div>
 
 
 
 
 
 
 
 
 
3982
  <div className="plot-full-width">
3983
  <PlotFigure figure={fit.grafico_correlacao} title="Matriz de correlação" className="plot-correlation-card" />
3984
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3985
  </SectionBlock>
3986
 
3987
  <SectionBlock step="16" title="Analisar Resíduos" subtitle="Métricas para identificação de observações influentes.">
 
371
  return out
372
  }
373
 
374
+ function stylePanelTrace(trace, options = {}) {
375
+ const useScatterGlMarkers = Boolean(options.useScatterGlMarkers)
376
  const mode = String(trace?.mode || '')
377
  const traceName = String(trace?.name || '').toLowerCase()
378
  const hasMarkers = mode.includes('markers')
 
380
  const next = { ...trace, showlegend: false }
381
 
382
  if (hasMarkers) {
383
+ if (useScatterGlMarkers) {
384
+ next.type = 'scattergl'
385
+ }
386
  next.marker = {
387
  ...(trace?.marker || {}),
388
  color: '#FF8C00',
 
450
  ...item.trace,
451
  xaxis: 'x',
452
  yaxis: 'y',
453
+ }, { useScatterGlMarkers: Boolean(options.useScatterGlMarkers) }))
454
 
455
  const panelFigure = {
456
  data: panelTraces,
 
641
  return item?.label || 'Sem enquadramento'
642
  }
643
 
644
+ function DiagnosticPngCard({ title, pngPayload, alt }) {
645
+ if (!pngPayload?.image_base64) {
646
+ return (
647
+ <div className="empty-box">Grafico indisponivel.</div>
648
+ )
649
+ }
650
+ const mime = String(pngPayload.mime_type || 'image/png').trim() || 'image/png'
651
+ return (
652
+ <div className="plot-card plot-png-card">
653
+ <div className="plot-card-head">
654
+ <h4 className="plot-card-title">{title}</h4>
655
+ </div>
656
+ <img
657
+ src={`data:${mime};base64,${pngPayload.image_base64}`}
658
+ alt={alt || title}
659
+ className="plot-png-image"
660
+ />
661
+ </div>
662
+ )
663
+ }
664
+
665
  export default function ElaboracaoTab({ sessionId }) {
666
  const [loading, setLoading] = useState(false)
667
  const [downloadingAssets, setDownloadingAssets] = useState(false)
 
745
  const [dispersaoEixoYTipo, setDispersaoEixoYTipo] = useState('y_transformado')
746
  const [dispersaoEixoYResiduo, setDispersaoEixoYResiduo] = useState('residuo_pad')
747
  const [dispersaoEixoYColuna, setDispersaoEixoYColuna] = useState('')
748
+ const [secao10InterativoFigura, setSecao10InterativoFigura] = useState(null)
749
+ const [secao10InterativoSelecionado, setSecao10InterativoSelecionado] = useState('none')
750
+ const [secao13InterativoFigura, setSecao13InterativoFigura] = useState(null)
751
+ const [secao13InterativoSelecionado, setSecao13InterativoSelecionado] = useState('none')
752
+ const [secao15InterativoFigura, setSecao15InterativoFigura] = useState(null)
753
+ const [secao15InterativoSelecionado, setSecao15InterativoSelecionado] = useState('none')
754
 
755
  const [filtros, setFiltros] = useState(defaultFiltros())
756
  const [outliersTexto, setOutliersTexto] = useState('')
 
1001
  }
1002
  return ''
1003
  }, [origemTransformacoes])
1004
+ const secao10PngPayload = useMemo(() => {
1005
+ if (String(selection?.grafico_dispersao_modo || '') !== 'png') return null
1006
+ const payload = selection?.grafico_dispersao_png
1007
+ if (!payload || typeof payload !== 'object') return null
1008
+ const imageBase64 = String(payload.image_base64 || '').trim()
1009
+ if (!imageBase64) return null
1010
+ return {
1011
+ imageBase64,
1012
+ mimeType: String(payload.mime_type || 'image/png').trim() || 'image/png',
1013
+ totalPontos: Number(payload.total_pontos ?? selection?.grafico_dispersao_total_pontos ?? 0) || 0,
1014
+ limiar: Number(selection?.grafico_dispersao_limiar_png ?? 1000) || 1000,
1015
+ }
1016
+ }, [selection])
1017
  const graficosSecao9 = useMemo(
1018
  () => buildScatterPanels(selection?.grafico_dispersao, {
1019
  singleLabel: 'Dispersão',
 
1022
  }),
1023
  [selection?.grafico_dispersao, colunaY],
1024
  )
1025
+ const graficosSecao9Interativo = useMemo(
1026
+ () => buildScatterPanels(secao10InterativoFigura, {
1027
+ singleLabel: 'Dispersão',
1028
+ height: 360,
1029
+ yLabel: colunaY || 'Y',
1030
+ useScatterGlMarkers: true,
1031
+ }),
1032
+ [secao10InterativoFigura, colunaY],
1033
+ )
1034
+ const secao10InterativoOpcoes = useMemo(() => {
1035
+ const labels = graficosSecao9Interativo.length > 0
1036
+ ? graficosSecao9Interativo.map((item) => String(item.label || '').trim()).filter(Boolean)
1037
+ : (selection?.contexto?.colunas_x || colunasX).map((item) => String(item || '').trim()).filter(Boolean)
1038
+ return Array.from(new Set(labels))
1039
+ }, [graficosSecao9Interativo, selection?.contexto?.colunas_x, colunasX])
1040
+ const secao10InterativoAtual = useMemo(() => {
1041
+ if (secao10InterativoSelecionado === 'none' || graficosSecao9Interativo.length === 0) return null
1042
+ return graficosSecao9Interativo.find((item) => String(item.label || '') === secao10InterativoSelecionado) || graficosSecao9Interativo[0]
1043
+ }, [graficosSecao9Interativo, secao10InterativoSelecionado])
1044
  const colunasGraficosSecao9 = useMemo(
1045
  () => Math.max(1, Math.min(3, graficosSecao9.length || 1)),
1046
  [graficosSecao9.length],
 
1077
  }),
1078
  [fit?.grafico_dispersao_modelo, yLabelSecao13],
1079
  )
1080
+ const secao13PngPayload = useMemo(() => {
1081
+ if (String(fit?.grafico_dispersao_modelo_modo || '') !== 'png') return null
1082
+ const payload = fit?.grafico_dispersao_modelo_png
1083
+ if (!payload || typeof payload !== 'object') return null
1084
+ const imageBase64 = String(payload.image_base64 || '').trim()
1085
+ if (!imageBase64) return null
1086
+ return {
1087
+ imageBase64,
1088
+ mimeType: String(payload.mime_type || 'image/png').trim() || 'image/png',
1089
+ totalPontos: Number(payload.total_pontos ?? fit?.grafico_dispersao_modelo_total_pontos ?? 0) || 0,
1090
+ limiar: Number(fit?.grafico_dispersao_modelo_limiar_png ?? 1000) || 1000,
1091
+ }
1092
+ }, [fit])
1093
+ const graficosSecao12Interativo = useMemo(
1094
+ () => buildScatterPanels(secao13InterativoFigura, {
1095
+ singleLabel: 'Dispersão do modelo',
1096
+ height: 360,
1097
+ yLabel: yLabelSecao13,
1098
+ useScatterGlMarkers: true,
1099
+ }),
1100
+ [secao13InterativoFigura, yLabelSecao13],
1101
+ )
1102
+ const secao13InterativoOpcoes = useMemo(() => {
1103
+ const labels = graficosSecao12Interativo.length > 0
1104
+ ? graficosSecao12Interativo.map((item) => String(item.label || '').trim()).filter(Boolean)
1105
+ : (fit?.contexto?.colunas_x || colunasX).map((item) => String(item || '').trim()).filter(Boolean)
1106
+ return Array.from(new Set(labels))
1107
+ }, [graficosSecao12Interativo, fit?.contexto?.colunas_x, colunasX])
1108
+ const secao13InterativoAtual = useMemo(() => {
1109
+ if (secao13InterativoSelecionado === 'none' || graficosSecao12Interativo.length === 0) return null
1110
+ return graficosSecao12Interativo.find((item) => String(item.label || '') === secao13InterativoSelecionado) || graficosSecao12Interativo[0]
1111
+ }, [graficosSecao12Interativo, secao13InterativoSelecionado])
1112
  const colunasGraficosSecao12 = useMemo(
1113
  () => Math.max(1, Math.min(3, graficosSecao12.length || 1)),
1114
  [graficosSecao12.length],
1115
  )
1116
+ const secao15DiagnosticoPng = useMemo(
1117
+ () => String(fit?.graficos_diagnostico_modo || '') === 'png',
1118
+ [fit?.graficos_diagnostico_modo],
1119
+ )
1120
+ const secao15InterativoOpcoes = useMemo(
1121
+ () => [
1122
+ { value: 'none', label: 'Sem gráfico interativo' },
1123
+ { value: 'obs_calc', label: 'Obs x Calc' },
1124
+ { value: 'residuos', label: 'Resíduos' },
1125
+ { value: 'histograma', label: 'Histograma' },
1126
+ { value: 'cook', label: 'Cook' },
1127
+ ],
1128
+ [],
1129
+ )
1130
+ const secao15InterativoLabel = useMemo(() => {
1131
+ const item = secao15InterativoOpcoes.find((opt) => opt.value === secao15InterativoSelecionado)
1132
+ return item?.label || 'Gráfico interativo'
1133
+ }, [secao15InterativoOpcoes, secao15InterativoSelecionado])
1134
  const temAvaliacoes = useMemo(
1135
  () => Array.isArray(baseChoices) && baseChoices.length > 0,
1136
  [baseChoices],
 
1243
  }
1244
  }, [colunasOriginaisDispersao, dispersaoEixoYColuna])
1245
 
1246
+ useEffect(() => {
1247
+ if (graficosSecao9Interativo.length === 0) {
1248
+ if (secao10InterativoSelecionado !== 'none') setSecao10InterativoSelecionado('none')
1249
+ return
1250
+ }
1251
+ if (secao10InterativoSelecionado !== 'none' && !graficosSecao9Interativo.some((item) => String(item.label || '') === secao10InterativoSelecionado)) {
1252
+ setSecao10InterativoSelecionado(String(graficosSecao9Interativo[0].label || 'none'))
1253
+ }
1254
+ }, [graficosSecao9Interativo, secao10InterativoSelecionado])
1255
+
1256
+ useEffect(() => {
1257
+ if (graficosSecao12Interativo.length === 0) {
1258
+ if (secao13InterativoSelecionado !== 'none') setSecao13InterativoSelecionado('none')
1259
+ return
1260
+ }
1261
+ if (secao13InterativoSelecionado !== 'none' && !graficosSecao12Interativo.some((item) => String(item.label || '') === secao13InterativoSelecionado)) {
1262
+ setSecao13InterativoSelecionado(String(graficosSecao12Interativo[0].label || 'none'))
1263
+ }
1264
+ }, [graficosSecao12Interativo, secao13InterativoSelecionado])
1265
+
1266
+ useEffect(() => {
1267
+ if (!fit || !secao15DiagnosticoPng) {
1268
+ if (secao15InterativoSelecionado !== 'none') setSecao15InterativoSelecionado('none')
1269
+ if (secao15InterativoFigura) setSecao15InterativoFigura(null)
1270
+ }
1271
+ }, [fit, secao15DiagnosticoPng, secao15InterativoSelecionado, secao15InterativoFigura])
1272
+
1273
  useEffect(() => {
1274
  if (!sessionId || tipoFonteDados === 'dai') return undefined
1275
 
 
1549
  setSelection(null)
1550
  setSection6EditOpen(true)
1551
  setFit(null)
1552
+ setSecao10InterativoFigura(null)
1553
+ setSecao10InterativoSelecionado('none')
1554
+ setSecao13InterativoFigura(null)
1555
+ setSecao13InterativoSelecionado('none')
1556
  setFiltros(defaultFiltros())
1557
  setOutliersTexto('')
1558
  setReincluirTexto('')
 
1588
 
1589
  function applySelectionResponse(resp) {
1590
  setSelection(resp)
1591
+ setSecao10InterativoFigura(null)
1592
+ setSecao10InterativoSelecionado('none')
1593
  setSection6EditOpen(false)
1594
  setSection11LocksOpen(false)
1595
  setSection10ManualOpen(false)
 
1639
 
1640
  function applyFitResponse(resp, origemMeta = null) {
1641
  setFit(resp)
1642
+ setSecao13InterativoFigura(null)
1643
+ setSecao13InterativoSelecionado('none')
1644
+ setSecao15InterativoFigura(null)
1645
+ setSecao15InterativoSelecionado('none')
1646
  setDispersaoEixoX('transformado')
1647
  setDispersaoEixoYTipo('y_transformado')
1648
  setDispersaoEixoYResiduo('residuo_pad')
 
1735
  setGrauCoef(0)
1736
  setGrauF(0)
1737
  setFit(null)
1738
+ setSecao10InterativoFigura(null)
1739
+ setSecao10InterativoSelecionado('none')
1740
+ setSecao13InterativoFigura(null)
1741
+ setSecao13InterativoSelecionado('none')
1742
  setSelectionAppliedSnapshot(buildSelectionSnapshot())
1743
  setColunasX([])
1744
  setDicotomicas([])
 
1973
  setSelectionAppliedSnapshot(buildSelectionSnapshot({ coluna_y: proximaColunaY }))
1974
  setSelection(null)
1975
  setFit(null)
1976
+ setSecao10InterativoFigura(null)
1977
+ setSecao10InterativoSelecionado('none')
1978
+ setSecao13InterativoFigura(null)
1979
+ setSecao13InterativoSelecionado('none')
1980
  setTransformacaoY('(x)')
1981
  setTransformacoesX({})
1982
  setTransformacaoYFixaBusca('Livre')
 
2138
  setSelectionAppliedSnapshot(buildSelectionSnapshot(payload))
2139
  setSection6EditOpen(false)
2140
  setFit(null)
2141
+ setSecao13InterativoFigura(null)
2142
+ setSecao13InterativoSelecionado('none')
2143
  setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
2144
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
2145
  setCamposAvaliacao([])
 
2242
  eixo_y_residuo: eixoYTipo === 'residuo' ? eixoYResiduo : null,
2243
  eixo_y_coluna: eixoYTipo === 'coluna_original' ? eixoYColuna : null,
2244
  })
2245
+ setFit((prev) => ({
2246
+ ...prev,
2247
+ grafico_dispersao_modelo: resp.grafico,
2248
+ grafico_dispersao_modelo_modo: resp.modo || (resp.grafico ? 'interativo' : ''),
2249
+ grafico_dispersao_modelo_png: resp.grafico_png || null,
2250
+ grafico_dispersao_modelo_total_pontos: resp.total_pontos,
2251
+ grafico_dispersao_modelo_limiar_png: resp.limiar_png,
2252
+ }))
2253
+ setSecao13InterativoFigura(null)
2254
+ setSecao13InterativoSelecionado('none')
2255
  })
2256
  }
2257
 
 
2373
  setReincluirTexto('')
2374
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
2375
  setFit(null)
2376
+ setSecao13InterativoFigura(null)
2377
+ setSecao13InterativoSelecionado('none')
2378
  setTransformacoesAplicadas(null)
2379
  setOrigemTransformacoes(null)
2380
  setSection10ManualOpen(true)
 
2401
  setSelection(null)
2402
  setSection6EditOpen(true)
2403
  setFit(null)
2404
+ setSecao10InterativoFigura(null)
2405
+ setSecao10InterativoSelecionado('none')
2406
+ setSecao13InterativoFigura(null)
2407
+ setSecao13InterativoSelecionado('none')
2408
  setTransformacoesAplicadas(null)
2409
  setOrigemTransformacoes(null)
2410
  setCamposAvaliacao([])
 
2742
  }
2743
  }
2744
 
2745
+ async function onDownloadPngBase64(imageBase64, mimeType, fileNameBase) {
2746
+ const payload = String(imageBase64 || '').trim()
2747
+ if (!payload) {
2748
+ setError('Imagem indisponível para download.')
2749
+ return
2750
+ }
2751
+ setDownloadingAssets(true)
2752
+ setError('')
2753
+ try {
2754
+ const safeMime = String(mimeType || 'image/png').trim() || 'image/png'
2755
+ const dataUrl = `data:${safeMime};base64,${payload}`
2756
+ const response = await fetch(dataUrl)
2757
+ const blob = await response.blob()
2758
+ downloadBlob(blob, `${sanitizeFileName(fileNameBase, 'grafico')}.png`)
2759
+ } catch (err) {
2760
+ setError(err.message || 'Falha ao baixar imagem do gráfico.')
2761
+ } finally {
2762
+ setDownloadingAssets(false)
2763
+ }
2764
+ }
2765
+
2766
+ async function onDownloadPngBase64Batch(items) {
2767
+ const validItems = (items || []).filter((item) => String(item?.imageBase64 || '').trim())
2768
+ if (validItems.length === 0) {
2769
+ setError('Não há imagens disponíveis para download.')
2770
+ return
2771
+ }
2772
+ setDownloadingAssets(true)
2773
+ setError('')
2774
+ try {
2775
+ for (let i = 0; i < validItems.length; i += 1) {
2776
+ const item = validItems[i]
2777
+ const safeMime = String(item.mimeType || 'image/png').trim() || 'image/png'
2778
+ const dataUrl = `data:${safeMime};base64,${String(item.imageBase64).trim()}`
2779
+ const response = await fetch(dataUrl)
2780
+ const blob = await response.blob()
2781
+ downloadBlob(blob, `${sanitizeFileName(item.fileNameBase, `grafico_${i + 1}`)}.png`)
2782
+ if (i < validItems.length - 1) {
2783
+ await sleep(180)
2784
+ }
2785
+ }
2786
+ } catch (err) {
2787
+ setError(err.message || 'Falha ao baixar imagens.')
2788
+ } finally {
2789
+ setDownloadingAssets(false)
2790
+ }
2791
+ }
2792
+
2793
+ async function onChangeSecao10InterativoSelecionado(value) {
2794
+ const nextValue = String(value || 'none').trim() || 'none'
2795
+ setSecao10InterativoSelecionado(nextValue)
2796
+ if (nextValue === 'none') {
2797
+ return
2798
+ }
2799
+ if (secao10InterativoFigura || !sessionId) return
2800
+
2801
+ await withBusy(async () => {
2802
+ const resp = await api.getDispersaoInterativo(sessionId, 'secao10')
2803
+ setSecao10InterativoFigura(resp?.grafico || null)
2804
+ })
2805
+ }
2806
+
2807
+ async function onChangeSecao13InterativoSelecionado(value) {
2808
+ const nextValue = String(value || 'none').trim() || 'none'
2809
+ setSecao13InterativoSelecionado(nextValue)
2810
+ if (nextValue === 'none') {
2811
+ return
2812
+ }
2813
+ if (secao13InterativoFigura || !sessionId) return
2814
+
2815
+ await withBusy(async () => {
2816
+ const resp = await api.getDispersaoInterativo(sessionId, 'secao13')
2817
+ setSecao13InterativoFigura(resp?.grafico || null)
2818
+ })
2819
+ }
2820
+
2821
+ async function onChangeSecao15InterativoSelecionado(value) {
2822
+ const nextValue = String(value || 'none').trim() || 'none'
2823
+ setSecao15InterativoSelecionado(nextValue)
2824
+ if (nextValue === 'none') {
2825
+ setSecao15InterativoFigura(null)
2826
+ return
2827
+ }
2828
+ if (!sessionId) return
2829
+
2830
+ await withBusy(async () => {
2831
+ const resp = await api.getDiagnosticoInterativo(sessionId, nextValue)
2832
+ setSecao15InterativoFigura(resp?.payload || null)
2833
+ })
2834
+ }
2835
+
2836
  function toggleSelection(setter, value) {
2837
  setter((prev) => {
2838
  if (prev.includes(value)) return prev.filter((item) => item !== value)
 
3723
  </SectionBlock>
3724
 
3725
  <SectionBlock step="10" title="Gráficos de Dispersão das Variáveis Independentes" subtitle="Leitura visual entre X e Y no conjunto filtrado.">
3726
+ {secao10PngPayload ? (
3727
+ <div className="section-disclaimer-warning">
3728
+ Modo PNG automático para mais de {secao10PngPayload.limiar} pontos. Ao final da seção, podem ser gerados individualmente os gráficos interativos.
3729
+ </div>
3730
+ ) : null}
3731
+ {secao10PngPayload ? (
3732
+ <>
3733
+ <div className="download-actions-bar">
3734
  <button
 
3735
  type="button"
3736
  className="btn-download-subtle"
3737
+ onClick={() => onDownloadPngBase64(secao10PngPayload.imageBase64, secao10PngPayload.mimeType, 'secao10_dispersao_png')}
3738
+ disabled={loading || downloadingAssets}
 
3739
  >
3740
+ Fazer download
3741
  </button>
3742
+ </div>
3743
+ <div className="scatter-png-card">
3744
+ <img
3745
+ src={`data:${secao10PngPayload.mimeType};base64,${secao10PngPayload.imageBase64}`}
3746
+ alt="Gráficos de dispersão em PNG"
3747
+ className="scatter-png-image"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3748
  />
3749
+ </div>
3750
+ <div className="scatter-interactive-control">
3751
+ <label>Gráfico interativo (opcional)</label>
3752
+ <select
3753
+ value={secao10InterativoSelecionado}
3754
+ onChange={(e) => {
3755
+ void onChangeSecao10InterativoSelecionado(e.target.value)
3756
+ }}
3757
+ disabled={loading}
3758
+ >
3759
+ <option value="none">Sem gráfico</option>
3760
+ {secao10InterativoOpcoes.map((item) => (
3761
+ <option key={`s10-int-opt-${item}`} value={item}>{item}</option>
3762
+ ))}
3763
+ </select>
3764
+ </div>
3765
+ {secao10InterativoSelecionado !== 'none' ? (
3766
+ <>
3767
+ <div className="download-actions-bar">
3768
+ <button
3769
+ type="button"
3770
+ className="btn-download-subtle"
3771
+ title={secao10InterativoAtual?.legenda || ''}
3772
+ onClick={() => {
3773
+ if (!secao10InterativoAtual) return
3774
+ void onDownloadFigurePng(
3775
+ secao10InterativoAtual.figure,
3776
+ `secao10_${sanitizeFileName(secao10InterativoAtual.label, 'dispersao_interativo')}`,
3777
+ { forceHideLegend: true },
3778
+ )
3779
+ }}
3780
+ disabled={loading || downloadingAssets || !secao10InterativoAtual?.figure}
3781
+ >
3782
+ Fazer download
3783
+ </button>
3784
+ </div>
3785
+ {secao10InterativoAtual?.figure ? (
3786
+ <PlotFigure
3787
+ key={`s9-interativo-plot-${secao10InterativoAtual.id}`}
3788
+ figure={secao10InterativoAtual.figure}
3789
+ title={secao10InterativoAtual.title}
3790
+ subtitle={secao10InterativoAtual.subtitle}
3791
+ forceHideLegend
3792
+ className="plot-stretch"
3793
+ lazy
3794
+ />
3795
+ ) : (
3796
+ <div className="empty-box">Grafico indisponivel.</div>
3797
+ )}
3798
+ </>
3799
+ ) : null}
3800
+ </>
3801
  ) : (
3802
+ <>
3803
+ <div className="download-actions-bar">
3804
+ {graficosSecao9.length > 1 ? <span className="download-actions-label">Fazer download:</span> : null}
3805
+ {graficosSecao9.map((item, idx) => {
3806
+ const fileBase = `secao10_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`
3807
+ return (
3808
+ <button
3809
+ key={`s9-dl-${item.id}`}
3810
+ type="button"
3811
+ className="btn-download-subtle"
3812
+ title={item.legenda}
3813
+ onClick={() => onDownloadFigurePng(item.figure, fileBase, { forceHideLegend: true })}
3814
+ disabled={loading || downloadingAssets || !item.figure}
3815
+ >
3816
+ {graficosSecao9.length > 1 ? item.label : 'Fazer download'}
3817
+ </button>
3818
+ )
3819
+ })}
3820
+ {graficosSecao9.length > 1 ? (
3821
+ <button
3822
+ type="button"
3823
+ className="btn-download-subtle"
3824
+ onClick={() => onDownloadFiguresPngBatch(
3825
+ graficosSecao9.map((item, idx) => ({
3826
+ figure: item.figure,
3827
+ fileNameBase: `secao10_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`,
3828
+ forceHideLegend: true,
3829
+ })),
3830
+ )}
3831
+ disabled={loading || downloadingAssets || graficosSecao9.length === 0}
3832
+ >
3833
+ Todos
3834
+ </button>
3835
+ ) : null}
3836
+ </div>
3837
+ {graficosSecao9.length > 0 ? (
3838
+ <div className="plot-grid-scatter" style={{ '--plot-cols': colunasGraficosSecao9 }}>
3839
+ {graficosSecao9.map((item) => (
3840
+ <PlotFigure
3841
+ key={`s9-plot-${item.id}`}
3842
+ figure={item.figure}
3843
+ title={item.title}
3844
+ subtitle={item.subtitle}
3845
+ forceHideLegend
3846
+ className="plot-stretch"
3847
+ lazy
3848
+ />
3849
+ ))}
3850
+ </div>
3851
+ ) : (
3852
+ <div className="empty-box">Grafico indisponivel.</div>
3853
+ )}
3854
+ </>
3855
  )}
3856
  </SectionBlock>
3857
 
 
4085
  {fit ? (
4086
  <>
4087
  <SectionBlock step="13" title="Visualizar Mapa dos Dados de Mercado" subtitle="Escolha livre dos eixos para análise gráfica do modelo.">
4088
+ {secao13PngPayload ? (
4089
+ <div className="section-disclaimer-warning">
4090
+ Modo PNG automático para mais de {secao13PngPayload.limiar} pontos. Ao final da seção, podem ser gerados individualmente os gráficos interativos.
4091
+ </div>
4092
+ ) : null}
4093
  <div className="row dispersao-config-row">
4094
  <div className="dispersao-config-field">
4095
  <label>Eixo X</label>
 
4160
  </div>
4161
  ) : null}
4162
  </div>
4163
+ {secao13PngPayload ? (
4164
+ <>
4165
+ <div className="download-actions-bar">
 
 
4166
  <button
 
4167
  type="button"
4168
  className="btn-download-subtle"
4169
+ onClick={() => onDownloadPngBase64(secao13PngPayload.imageBase64, secao13PngPayload.mimeType, 'secao13_dispersao_png')}
4170
+ disabled={loading || downloadingAssets}
 
4171
  >
4172
+ Fazer download
4173
  </button>
4174
+ </div>
4175
+ <div className="scatter-png-card">
4176
+ <img
4177
+ src={`data:${secao13PngPayload.mimeType};base64,${secao13PngPayload.imageBase64}`}
4178
+ alt="Dispersão do modelo em PNG"
4179
+ className="scatter-png-image"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4180
  />
4181
+ </div>
4182
+ <div className="scatter-interactive-control">
4183
+ <label>Renderização interativa</label>
4184
+ <select
4185
+ value={secao13InterativoSelecionado === 'none' ? 'off' : 'on'}
4186
+ onChange={(e) => {
4187
+ const next = String(e.target.value || 'off')
4188
+ if (next === 'off') {
4189
+ void onChangeSecao13InterativoSelecionado('none')
4190
+ return
4191
+ }
4192
+ const firstOption = secao13InterativoOpcoes[0] || 'none'
4193
+ void onChangeSecao13InterativoSelecionado(firstOption)
4194
+ }}
4195
+ disabled={loading}
4196
+ >
4197
+ <option value="off">Sem gráfico interativo</option>
4198
+ <option value="on">Com gráfico interativo</option>
4199
+ </select>
4200
+ </div>
4201
+ {secao13InterativoSelecionado !== 'none' ? (
4202
+ <>
4203
+ <div className="scatter-interactive-control">
4204
+ <label>Variável do gráfico interativo</label>
4205
+ <select
4206
+ value={secao13InterativoSelecionado}
4207
+ onChange={(e) => {
4208
+ void onChangeSecao13InterativoSelecionado(e.target.value)
4209
+ }}
4210
+ disabled={loading}
4211
+ >
4212
+ {secao13InterativoOpcoes.map((item) => (
4213
+ <option key={`s13-int-opt-bottom-${item}`} value={item}>{item}</option>
4214
+ ))}
4215
+ </select>
4216
+ </div>
4217
+ <div className="download-actions-bar">
4218
+ <button
4219
+ type="button"
4220
+ className="btn-download-subtle"
4221
+ title={secao13InterativoAtual?.legenda || ''}
4222
+ onClick={() => {
4223
+ if (!secao13InterativoAtual) return
4224
+ void onDownloadFigurePng(
4225
+ secao13InterativoAtual.figure,
4226
+ `secao13_${sanitizeFileName(secao13InterativoAtual.label, 'dispersao_interativo')}`,
4227
+ { forceHideLegend: true },
4228
+ )
4229
+ }}
4230
+ disabled={loading || downloadingAssets || !secao13InterativoAtual?.figure}
4231
+ >
4232
+ Fazer download
4233
+ </button>
4234
+ </div>
4235
+ {secao13InterativoAtual?.figure ? (
4236
+ <PlotFigure
4237
+ key={`s12-interativo-plot-${secao13InterativoAtual.id}`}
4238
+ figure={secao13InterativoAtual.figure}
4239
+ title={secao13InterativoAtual.title}
4240
+ subtitle={secao13InterativoAtual.subtitle}
4241
+ forceHideLegend
4242
+ className="plot-stretch"
4243
+ lazy
4244
+ />
4245
+ ) : (
4246
+ <div className="empty-box">Grafico indisponivel.</div>
4247
+ )}
4248
+ </>
4249
+ ) : null}
4250
+ </>
4251
  ) : (
4252
+ <>
4253
+ <div className="download-actions-bar">
4254
+ {graficosSecao12.length > 1 ? <span className="download-actions-label">Fazer download:</span> : null}
4255
+ {graficosSecao12.map((item, idx) => {
4256
+ const fileBase = `secao13_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`
4257
+ return (
4258
+ <button
4259
+ key={`s12-dl-${item.id}`}
4260
+ type="button"
4261
+ className="btn-download-subtle"
4262
+ title={item.legenda}
4263
+ onClick={() => onDownloadFigurePng(item.figure, fileBase, { forceHideLegend: true })}
4264
+ disabled={loading || downloadingAssets || !item.figure}
4265
+ >
4266
+ {graficosSecao12.length > 1 ? item.label : 'Fazer download'}
4267
+ </button>
4268
+ )
4269
+ })}
4270
+ {graficosSecao12.length > 1 ? (
4271
+ <button
4272
+ type="button"
4273
+ className="btn-download-subtle"
4274
+ onClick={() => onDownloadFiguresPngBatch(
4275
+ graficosSecao12.map((item, idx) => ({
4276
+ figure: item.figure,
4277
+ fileNameBase: `secao13_${sanitizeFileName(item.label, `dispersao_${idx + 1}`)}`,
4278
+ forceHideLegend: true,
4279
+ })),
4280
+ )}
4281
+ disabled={loading || downloadingAssets || graficosSecao12.length === 0}
4282
+ >
4283
+ Todos
4284
+ </button>
4285
+ ) : null}
4286
+ </div>
4287
+ {graficosSecao12.length > 0 ? (
4288
+ <div className="plot-grid-scatter" style={{ '--plot-cols': colunasGraficosSecao12 }}>
4289
+ {graficosSecao12.map((item) => (
4290
+ <PlotFigure
4291
+ key={`s12-plot-${item.id}`}
4292
+ figure={item.figure}
4293
+ title={item.title}
4294
+ subtitle={item.subtitle}
4295
+ forceHideLegend
4296
+ className="plot-stretch"
4297
+ lazy
4298
+ />
4299
+ ))}
4300
+ </div>
4301
+ ) : (
4302
+ <div className="empty-box">Grafico indisponivel.</div>
4303
+ )}
4304
+ </>
4305
  )}
4306
  </SectionBlock>
4307
 
 
4358
  </SectionBlock>
4359
 
4360
  <SectionBlock step="15" title="Gráficos de Diagnóstico do Modelo" subtitle="Obs x calc, resíduos, histograma, Cook e correlação.">
4361
+ {secao15DiagnosticoPng ? (
4362
+ <div className="section-disclaimer-warning">
4363
+ Modo PNG automático para mais de {fit.graficos_diagnostico_limiar_png || 1000} pontos. Ao final da seção, podem ser gerados individualmente os gráficos interativos.
4364
+ </div>
4365
+ ) : null}
4366
  <div className="download-actions-bar">
4367
  <span className="download-actions-label">Fazer download:</span>
4368
  <button
4369
  type="button"
4370
  className="btn-download-subtle"
4371
+ onClick={() => (
4372
+ secao15DiagnosticoPng
4373
+ ? onDownloadPngBase64(fit.grafico_obs_calc_png?.image_base64, fit.grafico_obs_calc_png?.mime_type, 'secao15_obs_calc')
4374
+ : onDownloadFigurePng(fit.grafico_obs_calc, 'secao15_obs_calc')
4375
+ )}
4376
+ disabled={loading || downloadingAssets || (secao15DiagnosticoPng ? !fit.grafico_obs_calc_png?.image_base64 : !fit.grafico_obs_calc)}
4377
  >
4378
  Obs x calc
4379
  </button>
4380
  <button
4381
  type="button"
4382
  className="btn-download-subtle"
4383
+ onClick={() => (
4384
+ secao15DiagnosticoPng
4385
+ ? onDownloadPngBase64(fit.grafico_residuos_png?.image_base64, fit.grafico_residuos_png?.mime_type, 'secao15_residuos')
4386
+ : onDownloadFigurePng(fit.grafico_residuos, 'secao15_residuos')
4387
+ )}
4388
+ disabled={loading || downloadingAssets || (secao15DiagnosticoPng ? !fit.grafico_residuos_png?.image_base64 : !fit.grafico_residuos)}
4389
  >
4390
  Resíduos
4391
  </button>
4392
  <button
4393
  type="button"
4394
  className="btn-download-subtle"
4395
+ onClick={() => (
4396
+ secao15DiagnosticoPng
4397
+ ? onDownloadPngBase64(fit.grafico_histograma_png?.image_base64, fit.grafico_histograma_png?.mime_type, 'secao15_histograma')
4398
+ : onDownloadFigurePng(fit.grafico_histograma, 'secao15_histograma')
4399
+ )}
4400
+ disabled={loading || downloadingAssets || (secao15DiagnosticoPng ? !fit.grafico_histograma_png?.image_base64 : !fit.grafico_histograma)}
4401
  >
4402
  Histograma
4403
  </button>
4404
  <button
4405
  type="button"
4406
  className="btn-download-subtle"
4407
+ onClick={() => (
4408
+ secao15DiagnosticoPng
4409
+ ? onDownloadPngBase64(fit.grafico_cook_png?.image_base64, fit.grafico_cook_png?.mime_type, 'secao15_cook')
4410
+ : onDownloadFigurePng(fit.grafico_cook, 'secao15_cook', { forceHideLegend: true })
4411
+ )}
4412
+ disabled={loading || downloadingAssets || (secao15DiagnosticoPng ? !fit.grafico_cook_png?.image_base64 : !fit.grafico_cook)}
4413
  >
4414
  Cook
4415
  </button>
 
4424
  <button
4425
  type="button"
4426
  className="btn-download-subtle"
4427
+ onClick={() => {
4428
+ if (secao15DiagnosticoPng) {
4429
+ void onDownloadPngBase64Batch([
4430
+ { imageBase64: fit.grafico_obs_calc_png?.image_base64, mimeType: fit.grafico_obs_calc_png?.mime_type, fileNameBase: 'secao15_obs_calc' },
4431
+ { imageBase64: fit.grafico_residuos_png?.image_base64, mimeType: fit.grafico_residuos_png?.mime_type, fileNameBase: 'secao15_residuos' },
4432
+ { imageBase64: fit.grafico_histograma_png?.image_base64, mimeType: fit.grafico_histograma_png?.mime_type, fileNameBase: 'secao15_histograma' },
4433
+ { imageBase64: fit.grafico_cook_png?.image_base64, mimeType: fit.grafico_cook_png?.mime_type, fileNameBase: 'secao15_cook' },
4434
+ ])
4435
+ return
4436
+ }
4437
+ void onDownloadFiguresPngBatch([
4438
+ { figure: fit.grafico_obs_calc, fileNameBase: 'secao15_obs_calc' },
4439
+ { figure: fit.grafico_residuos, fileNameBase: 'secao15_residuos' },
4440
+ { figure: fit.grafico_histograma, fileNameBase: 'secao15_histograma' },
4441
+ { figure: fit.grafico_cook, fileNameBase: 'secao15_cook', forceHideLegend: true },
4442
+ { figure: fit.grafico_correlacao, fileNameBase: 'secao15_correlacao' },
4443
+ ])
4444
+ }}
4445
+ disabled={loading || downloadingAssets || (
4446
+ secao15DiagnosticoPng
4447
+ ? (!fit.grafico_obs_calc_png?.image_base64 && !fit.grafico_residuos_png?.image_base64 && !fit.grafico_histograma_png?.image_base64 && !fit.grafico_cook_png?.image_base64)
4448
+ : (!fit.grafico_obs_calc && !fit.grafico_residuos && !fit.grafico_histograma && !fit.grafico_cook && !fit.grafico_correlacao)
4449
+ )}
4450
  >
4451
  Todos
4452
  </button>
4453
  </div>
4454
+ {secao15DiagnosticoPng ? (
4455
+ <div className="plot-grid-2-fixed">
4456
+ <DiagnosticPngCard title="Obs x Calc" pngPayload={fit.grafico_obs_calc_png} alt="Obs x Calc em PNG" />
4457
+ <DiagnosticPngCard title="Resíduos" pngPayload={fit.grafico_residuos_png} alt="Resíduos em PNG" />
4458
+ <DiagnosticPngCard title="Histograma" pngPayload={fit.grafico_histograma_png} alt="Histograma em PNG" />
4459
+ <DiagnosticPngCard title="Cook" pngPayload={fit.grafico_cook_png} alt="Cook em PNG" />
4460
+ </div>
4461
+ ) : (
4462
+ <div className="plot-grid-2-fixed">
4463
+ <PlotFigure figure={fit.grafico_obs_calc} title="Obs x Calc" />
4464
+ <PlotFigure figure={fit.grafico_residuos} title="Resíduos" />
4465
+ <PlotFigure figure={fit.grafico_histograma} title="Histograma" />
4466
+ <PlotFigure figure={fit.grafico_cook} title="Cook" forceHideLegend />
4467
+ </div>
4468
+ )}
4469
  <div className="plot-full-width">
4470
  <PlotFigure figure={fit.grafico_correlacao} title="Matriz de correlação" className="plot-correlation-card" />
4471
  </div>
4472
+ {secao15DiagnosticoPng ? (
4473
+ <>
4474
+ <div className="scatter-interactive-control">
4475
+ <label>Gráfico interativo (opcional)</label>
4476
+ <select
4477
+ value={secao15InterativoSelecionado}
4478
+ onChange={(e) => {
4479
+ void onChangeSecao15InterativoSelecionado(e.target.value)
4480
+ }}
4481
+ disabled={loading}
4482
+ >
4483
+ {secao15InterativoOpcoes.map((item) => (
4484
+ <option key={`s15-int-opt-${item.value}`} value={item.value}>{item.label}</option>
4485
+ ))}
4486
+ </select>
4487
+ </div>
4488
+ {secao15InterativoSelecionado !== 'none' ? (
4489
+ <>
4490
+ <div className="download-actions-bar">
4491
+ <button
4492
+ type="button"
4493
+ className="btn-download-subtle"
4494
+ onClick={() => {
4495
+ if (!secao15InterativoFigura) return
4496
+ void onDownloadFigurePng(
4497
+ secao15InterativoFigura,
4498
+ `secao15_${sanitizeFileName(secao15InterativoLabel, 'diagnostico_interativo')}`,
4499
+ { forceHideLegend: secao15InterativoSelecionado === 'cook' },
4500
+ )
4501
+ }}
4502
+ disabled={loading || downloadingAssets || !secao15InterativoFigura}
4503
+ >
4504
+ Fazer download
4505
+ </button>
4506
+ </div>
4507
+ {secao15InterativoFigura ? (
4508
+ <PlotFigure
4509
+ key={`s15-interativo-${secao15InterativoSelecionado}`}
4510
+ figure={secao15InterativoFigura}
4511
+ title={secao15InterativoLabel}
4512
+ forceHideLegend={secao15InterativoSelecionado === 'cook'}
4513
+ className="plot-stretch"
4514
+ lazy
4515
+ />
4516
+ ) : (
4517
+ <div className="empty-box">Grafico indisponivel.</div>
4518
+ )}
4519
+ </>
4520
+ ) : null}
4521
+ </>
4522
+ ) : null}
4523
  </SectionBlock>
4524
 
4525
  <SectionBlock step="16" title="Analisar Resíduos" subtitle="Métricas para identificação de observações influentes.">
frontend/src/styles.css CHANGED
@@ -2969,6 +2969,66 @@ button.btn-upload-select {
2969
  gap: 12px;
2970
  }
2971
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2972
  .plot-card {
2973
  border: 1px solid #dbe5ef;
2974
  border-radius: 12px;
 
2969
  gap: 12px;
2970
  }
2971
 
2972
+ .section-disclaimer-warning {
2973
+ margin: 0 0 10px;
2974
+ padding: 9px 12px;
2975
+ border-radius: 10px;
2976
+ border: 1px solid #efcf75;
2977
+ background: linear-gradient(180deg, #fff8dd 0%, #ffefb8 100%);
2978
+ color: #6f4d00;
2979
+ font-size: 0.86rem;
2980
+ font-weight: 700;
2981
+ }
2982
+
2983
+ .scatter-png-card {
2984
+ border: 1px solid #dbe5ef;
2985
+ border-radius: 12px;
2986
+ background: #fff;
2987
+ padding: 10px;
2988
+ }
2989
+
2990
+ .scatter-png-image {
2991
+ display: block;
2992
+ width: 100%;
2993
+ height: auto;
2994
+ border-radius: 8px;
2995
+ }
2996
+
2997
+ .scatter-interactive-control {
2998
+ display: flex;
2999
+ align-items: center;
3000
+ gap: 10px;
3001
+ flex-wrap: wrap;
3002
+ margin: 12px 0;
3003
+ }
3004
+
3005
+ .scatter-interactive-control label {
3006
+ font-weight: 700;
3007
+ color: #334b62;
3008
+ }
3009
+
3010
+ .scatter-interactive-control select {
3011
+ min-width: 220px;
3012
+ max-width: 340px;
3013
+ }
3014
+
3015
+ .scatter-interactive-hint {
3016
+ color: #5f7387;
3017
+ font-size: 0.84rem;
3018
+ }
3019
+
3020
+ .plot-png-card {
3021
+ padding: 8px;
3022
+ }
3023
+
3024
+ .plot-png-image {
3025
+ display: block;
3026
+ width: 100%;
3027
+ height: auto;
3028
+ border-radius: 8px;
3029
+ border: 1px solid #dbe5ef;
3030
+ }
3031
+
3032
  .plot-card {
3033
  border: 1px solid #dbe5ef;
3034
  border-radius: 12px;