Guilherme Silberfarb Costa commited on
Commit
3e507b3
·
1 Parent(s): 1c85047

alteracoes generalizadas

Browse files
backend/app/api/elaboracao.py CHANGED
@@ -36,6 +36,11 @@ class MapCoordsPayload(SessionPayload):
36
  col_lon: str
37
 
38
 
 
 
 
 
 
39
  class GeocodePayload(SessionPayload):
40
  col_cdlog: str
41
  col_num: str
@@ -562,6 +567,12 @@ def map_update(payload: UpdateMapaPayload) -> dict[str, Any]:
562
  return elaboracao_service.atualizar_mapa(session, payload.variavel_mapa, payload.modo_mapa)
563
 
564
 
 
 
 
 
 
 
565
  @router.post("/residuos/map/update")
566
  def residuos_map_update(payload: UpdateMapaPayload) -> dict[str, Any]:
567
  session = session_store.get(payload.session_id)
 
36
  col_lon: str
37
 
38
 
39
+ class MapaPopupPayload(SessionPayload):
40
+ row_id: int
41
+ source: str | None = None
42
+
43
+
44
  class GeocodePayload(SessionPayload):
45
  col_cdlog: str
46
  col_num: str
 
567
  return elaboracao_service.atualizar_mapa(session, payload.variavel_mapa, payload.modo_mapa)
568
 
569
 
570
+ @router.post("/map/popup")
571
+ def map_popup(payload: MapaPopupPayload) -> dict[str, Any]:
572
+ session = session_store.get(payload.session_id)
573
+ return elaboracao_service.carregar_popup_ponto_mapa(session, payload.row_id, payload.source)
574
+
575
+
576
  @router.post("/residuos/map/update")
577
  def residuos_map_update(payload: UpdateMapaPayload) -> dict[str, Any]:
578
  session = session_store.get(payload.session_id)
backend/app/api/pesquisa.py CHANGED
@@ -55,6 +55,7 @@ class MapaModelosPayload(BaseModel):
55
  avaliando_lon: Any = None
56
  avaliandos: list[AvaliandoGeoPayload] | None = None
57
  modo_exibicao: Any = "pontos"
 
58
  criterio_espacial: Any = None
59
  trabalhos_tecnicos_modelos_modo: Any = None
60
  trabalhos_tecnicos_proximidade_modo: Any = None
@@ -217,6 +218,7 @@ def pesquisar_mapa_modelos(payload: MapaModelosPayload) -> dict:
217
  avaliando_lon=payload.avaliando_lon,
218
  avaliandos=[item.model_dump() for item in (payload.avaliandos or [])],
219
  modo_exibicao=payload.modo_exibicao,
 
220
  criterio_espacial=payload.criterio_espacial,
221
  trabalhos_tecnicos_modelos_modo=modelos_modo,
222
  trabalhos_tecnicos_proximidade_modo=proximidade_modo,
 
55
  avaliando_lon: Any = None
56
  avaliandos: list[AvaliandoGeoPayload] | None = None
57
  modo_exibicao: Any = "pontos"
58
+ agrupar_pontos_mercado: Any = False
59
  criterio_espacial: Any = None
60
  trabalhos_tecnicos_modelos_modo: Any = None
61
  trabalhos_tecnicos_proximidade_modo: Any = None
 
218
  avaliando_lon=payload.avaliando_lon,
219
  avaliandos=[item.model_dump() for item in (payload.avaliandos or [])],
220
  modo_exibicao=payload.modo_exibicao,
221
+ agrupar_pontos_mercado=payload.agrupar_pontos_mercado,
222
  criterio_espacial=payload.criterio_espacial,
223
  trabalhos_tecnicos_modelos_modo=modelos_modo,
224
  trabalhos_tecnicos_proximidade_modo=proximidade_modo,
backend/app/core/elaboracao/charts.py CHANGED
@@ -17,6 +17,7 @@ import branca.colormap as cm
17
  from branca.element import Element
18
  from html import escape
19
  from typing import Any
 
20
  from app.core.map_layers import (
21
  add_bairros_layer,
22
  add_indice_marker,
@@ -622,72 +623,6 @@ def _mascara_dentro_poligono(x_grid: np.ndarray, y_grid: np.ndarray, poligono: n
622
  return inside
623
 
624
 
625
- def _aplicar_jitter_sobrepostos(
626
- df_mapa: pd.DataFrame,
627
- lat_col: str,
628
- lon_col: str,
629
- lat_plot_col: str,
630
- lon_plot_col: str,
631
- ) -> pd.DataFrame:
632
- """
633
- Aplica jitter visual apenas em pontos com coordenadas idênticas.
634
- Mantém as coordenadas originais intactas para cálculos e filtros.
635
- """
636
- df_plot = df_mapa.copy()
637
- df_plot[lat_plot_col] = pd.to_numeric(df_plot[lat_col], errors="coerce")
638
- df_plot[lon_plot_col] = pd.to_numeric(df_plot[lon_col], errors="coerce")
639
-
640
- if len(df_plot) <= 1:
641
- return df_plot
642
-
643
- chave_lat = df_plot[lat_col].round(7)
644
- chave_lon = df_plot[lon_col].round(7)
645
- grupos = df_plot.groupby([chave_lat, chave_lon], sort=False)
646
-
647
- passo_metros = 4.0
648
- max_raio_metros = 22.0
649
- metros_por_grau_lat = 111_320.0
650
-
651
- lat_plot_pos = int(df_plot.columns.get_loc(lat_plot_col))
652
- lon_plot_pos = int(df_plot.columns.get_loc(lon_plot_col))
653
-
654
- for _, idx_labels in grupos.indices.items():
655
- posicoes = np.asarray(idx_labels, dtype=int)
656
- if posicoes.size <= 1:
657
- continue
658
- base_lat = float(df_plot.iat[int(posicoes[0]), lat_plot_pos])
659
- base_lon = float(df_plot.iat[int(posicoes[0]), lon_plot_pos])
660
- if not np.isfinite(base_lat) or not np.isfinite(base_lon):
661
- continue
662
-
663
- seed_val = int((abs(base_lat) * 1_000_000.0) + (abs(base_lon) * 1_000_000.0) * 3.0) % 360
664
- angulo_base = math.radians(seed_val)
665
- cos_lat = max(abs(math.cos(math.radians(base_lat))), 1e-6)
666
- metros_por_grau_lon = metros_por_grau_lat * cos_lat
667
-
668
- for pos, pos_idx in enumerate(posicoes):
669
- if pos == 0:
670
- continue
671
-
672
- pos_ring = pos - 1
673
- ring = 1
674
- while pos_ring >= (6 * ring):
675
- pos_ring -= 6 * ring
676
- ring += 1
677
-
678
- slots_ring = max(6 * ring, 1)
679
- angulo = angulo_base + (2.0 * math.pi * (pos_ring / slots_ring))
680
- raio_m = min(ring * passo_metros, max_raio_metros)
681
-
682
- delta_lat = (raio_m * math.sin(angulo)) / metros_por_grau_lat
683
- delta_lon = (raio_m * math.cos(angulo)) / metros_por_grau_lon
684
-
685
- df_plot.iat[int(pos_idx), lat_plot_pos] = base_lat + delta_lat
686
- df_plot.iat[int(pos_idx), lon_plot_pos] = base_lon + delta_lon
687
-
688
- return df_plot
689
-
690
-
691
  def _montar_popup_registro_em_colunas(
692
  idx: Any,
693
  row: pd.Series,
@@ -1100,8 +1035,8 @@ def criar_mapa(
1100
  )
1101
 
1102
  # Camadas base
1103
- folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(m)
1104
- folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(m)
1105
  add_bairros_layer(m, show=True)
1106
 
1107
  modo_normalizado = str(modo or "pontos").strip().lower()
 
17
  from branca.element import Element
18
  from html import escape
19
  from typing import Any
20
+ from app.core.map_jitter import aplicar_jitter_dados_mercado as _aplicar_jitter_sobrepostos
21
  from app.core.map_layers import (
22
  add_bairros_layer,
23
  add_indice_marker,
 
623
  return inside
624
 
625
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
  def _montar_popup_registro_em_colunas(
627
  idx: Any,
628
  row: pd.Series,
 
1035
  )
1036
 
1037
  # Camadas base
1038
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=True).add_to(m)
1039
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=False).add_to(m)
1040
  add_bairros_layer(m, show=True)
1041
 
1042
  modo_normalizado = str(modo or "pontos").strip().lower()
backend/app/core/elaboracao/formatadores.py CHANGED
@@ -7,6 +7,7 @@ Sem dependência de Gradio.
7
  """
8
 
9
  import os
 
10
  from html import escape
11
  import numpy as np
12
  import pandas as pd
@@ -23,11 +24,44 @@ TITULO = """
23
  ---
24
  """
25
 
 
 
 
 
 
 
26
 
27
  # ============================================================
28
  # FUNÇÕES DE FORMATAÇÃO
29
  # ============================================================
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  def arredondar_df(df, decimais=4):
32
  """Arredonda apenas colunas numéricas de um DataFrame e adiciona coluna Índice."""
33
  if df is None:
@@ -143,7 +177,8 @@ def formatar_diagnosticos_html(diagnosticos):
143
 
144
  # Teste de Normalidade (Curva Normal)
145
  html += '<div class="section-title-orange">Teste de Normalidade (Comparação com a Curva Normal)</div>'
146
- html += f'''<div class="field-row"><span class="field-row-label">Percentuais Atingidos</span><span class="field-row-value">{diagnosticos["perc_resid"]}</span></div>'''
 
147
  html += '<div class="interpretation-label">Interpretação</div>'
148
  html += '<div class="interpretation-item">• Ideal 68% → aceitável entre 64% e 75%</div>'
149
  html += '<div class="interpretation-item">• Ideal 90% → aceitável entre 88% e 95%</div>'
 
7
  """
8
 
9
  import os
10
+ import re
11
  from html import escape
12
  import numpy as np
13
  import pandas as pd
 
24
  ---
25
  """
26
 
27
+ CURVA_NORMAL_PERCENTUAL_FAIXAS = (
28
+ (64, 75),
29
+ (88, 95),
30
+ (95, 100),
31
+ )
32
+
33
 
34
  # ============================================================
35
  # FUNÇÕES DE FORMATAÇÃO
36
  # ============================================================
37
 
38
+ def _formatar_percentuais_curva_normal_html(perc_resid):
39
+ texto = str(perc_resid or "").strip()
40
+ if not texto:
41
+ return "-"
42
+
43
+ partes = [parte.strip() for parte in texto.split(",") if parte.strip()]
44
+ if not partes:
45
+ return escape(texto)
46
+
47
+ formatados = []
48
+ for idx, parte in enumerate(partes):
49
+ parte_html = escape(parte)
50
+ faixa = CURVA_NORMAL_PERCENTUAL_FAIXAS[idx] if idx < len(CURVA_NORMAL_PERCENTUAL_FAIXAS) else None
51
+ match = re.search(r"-?\d+(?:[.,]\d+)?", parte)
52
+ valor = None
53
+ if match:
54
+ try:
55
+ valor = float(match.group(0).replace(",", "."))
56
+ except Exception:
57
+ valor = None
58
+ fora_da_faixa = faixa is not None and valor is not None and (valor < faixa[0] or valor > faixa[1])
59
+ if fora_da_faixa:
60
+ parte_html = f'<span style="color:#b42318;font-weight:800;">{parte_html}</span>'
61
+ formatados.append(parte_html)
62
+
63
+ return ", ".join(formatados)
64
+
65
  def arredondar_df(df, decimais=4):
66
  """Arredonda apenas colunas numéricas de um DataFrame e adiciona coluna Índice."""
67
  if df is None:
 
177
 
178
  # Teste de Normalidade (Curva Normal)
179
  html += '<div class="section-title-orange">Teste de Normalidade (Comparação com a Curva Normal)</div>'
180
+ perc_resid_html = _formatar_percentuais_curva_normal_html(diagnosticos.get("perc_resid"))
181
+ html += f'''<div class="field-row"><span class="field-row-label">Percentuais Atingidos</span><span class="field-row-value">{perc_resid_html}</span></div>'''
182
  html += '<div class="interpretation-label">Interpretação</div>'
183
  html += '<div class="interpretation-item">• Ideal 68% → aceitável entre 64% e 75%</div>'
184
  html += '<div class="interpretation-item">• Ideal 90% → aceitável entre 88% e 95%</div>'
backend/app/core/map_jitter.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+
9
+ JITTER_MERCADO_PASSO_METROS = 5.0
10
+ JITTER_MERCADO_MAX_RAIO_METROS = 28.0
11
+
12
+
13
+ def aplicar_jitter_dados_mercado(
14
+ df_mapa: pd.DataFrame,
15
+ lat_col: str,
16
+ lon_col: str,
17
+ lat_plot_col: str = "__mesa_lat_plot__",
18
+ lon_plot_col: str = "__mesa_lon_plot__",
19
+ *,
20
+ precisao: int = 7,
21
+ passo_metros: float = JITTER_MERCADO_PASSO_METROS,
22
+ max_raio_metros: float = JITTER_MERCADO_MAX_RAIO_METROS,
23
+ ) -> pd.DataFrame:
24
+ """
25
+ Aplica jitter visual determinístico apenas em pontos de mercado sobrepostos.
26
+
27
+ As coordenadas originais permanecem nas colunas de entrada; as coordenadas
28
+ deslocadas são gravadas em lat_plot_col/lon_plot_col.
29
+ """
30
+ df_plot = df_mapa.copy()
31
+ df_plot[lat_plot_col] = pd.to_numeric(df_plot[lat_col], errors="coerce")
32
+ df_plot[lon_plot_col] = pd.to_numeric(df_plot[lon_col], errors="coerce")
33
+
34
+ if len(df_plot) <= 1:
35
+ return df_plot
36
+
37
+ chave_lat = df_plot[lat_col].round(precisao)
38
+ chave_lon = df_plot[lon_col].round(precisao)
39
+ grupos = df_plot.groupby([chave_lat, chave_lon], sort=False)
40
+
41
+ metros_por_grau_lat = 111_320.0
42
+ lat_plot_pos = int(df_plot.columns.get_loc(lat_plot_col))
43
+ lon_plot_pos = int(df_plot.columns.get_loc(lon_plot_col))
44
+
45
+ for _, idx_labels in grupos.indices.items():
46
+ posicoes = np.asarray(idx_labels, dtype=int)
47
+ if posicoes.size <= 1:
48
+ continue
49
+
50
+ base_lat = float(df_plot.iat[int(posicoes[0]), lat_plot_pos])
51
+ base_lon = float(df_plot.iat[int(posicoes[0]), lon_plot_pos])
52
+ if not np.isfinite(base_lat) or not np.isfinite(base_lon):
53
+ continue
54
+
55
+ seed_val = int((abs(base_lat) * 1_000_000.0) + (abs(base_lon) * 1_000_000.0) * 3.0) % 360
56
+ angulo_base = math.radians(seed_val)
57
+ cos_lat = max(abs(math.cos(math.radians(base_lat))), 1e-6)
58
+ metros_por_grau_lon = metros_por_grau_lat * cos_lat
59
+
60
+ for pos, pos_idx in enumerate(posicoes):
61
+ if pos == 0:
62
+ continue
63
+
64
+ pos_ring = pos - 1
65
+ ring = 1
66
+ while pos_ring >= (6 * ring):
67
+ pos_ring -= 6 * ring
68
+ ring += 1
69
+
70
+ slots_ring = max(6 * ring, 1)
71
+ angulo = angulo_base + (2.0 * math.pi * (pos_ring / slots_ring))
72
+ raio_m = min(ring * passo_metros, max_raio_metros)
73
+ delta_lat = (raio_m * math.sin(angulo)) / metros_por_grau_lat
74
+ delta_lon = (raio_m * math.cos(angulo)) / metros_por_grau_lon
75
+
76
+ df_plot.iat[int(pos_idx), lat_plot_pos] = base_lat + delta_lat
77
+ df_plot.iat[int(pos_idx), lon_plot_pos] = base_lon + delta_lon
78
+
79
+ return df_plot
backend/app/core/map_layers.py CHANGED
@@ -292,6 +292,201 @@ def apply_marker_payload_jitter(
292
  return payloads
293
 
294
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  def build_trabalhos_tecnicos_marker_payloads(
296
  trabalhos: list[dict[str, Any]] | None,
297
  *,
@@ -299,6 +494,7 @@ def build_trabalhos_tecnicos_marker_payloads(
299
  marker_style: str = "estrela",
300
  ignore_bounds: bool = True,
301
  apply_jitter: bool = True,
 
302
  ) -> list[dict[str, Any]]:
303
  payloads: list[dict[str, Any]] = []
304
  for item in trabalhos or []:
@@ -337,24 +533,13 @@ def build_trabalhos_tecnicos_marker_payloads(
337
  )
338
  distancia_min_label = str(item.get("distancia_label_min") or "").strip()
339
 
340
- if trabalho_id:
341
- payload_json = json.dumps(
342
- {"type": "mesa:open-trabalho-tecnico", "trabalhoId": trabalho_id, "origem": origem},
343
- ensure_ascii=True,
344
- )
345
- trabalho_nome_html = (
346
- "<a href='#' "
347
- "style='color:#7d5a00;text-decoration:underline;font-weight:700;' "
348
- f"onclick='try{{(window.parent || window).postMessage({payload_json}, \"*\");}}catch(_error){{}} return false;'>"
349
- f"{escape(trabalho_nome)}"
350
- "</a>"
351
- )
352
- else:
353
- trabalho_nome_html = (
354
- "<span style='font-weight:700; color:#7d5a00;'>"
355
- f"{escape(trabalho_nome)}"
356
- "</span>"
357
- )
358
 
359
  detalhes_html = (
360
  "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.55; min-width:260px;'>"
@@ -375,58 +560,34 @@ def build_trabalhos_tecnicos_marker_payloads(
375
  + "</div>"
376
  )
377
 
378
- if str(marker_style or "").strip().lower() != "ponto":
379
- marker_html = (
380
- "<div style='display:flex;align-items:center;justify-content:center;"
381
- "width:14px;height:14px;'>"
382
- "<svg width='14' height='14' viewBox='0 0 24 24' aria-hidden='true'>"
383
- "<polygon points='12,1.8 15.2,8.2 22.2,9.2 17.1,14.1 18.3,21.1 "
384
- "12,17.8 5.7,21.1 6.9,14.1 1.8,9.2 8.8,8.2' "
385
- "fill='#c62828' stroke='#000000' stroke-width='1.4' stroke-linejoin='round'/>"
386
- "</svg></div>"
387
- )
388
- payloads.append(
389
- {
390
- "lat": lat,
391
- "lon": lon,
392
- "trabalho_id": trabalho_id,
393
- "trabalho_nome": trabalho_nome,
394
- "tooltip_html": detalhes_html,
395
- "popup_html": detalhes_html,
396
- "source_overlay_ids": modelos_origem_ids,
397
- "marker_html": marker_html,
398
- "marker_style": "estrela",
399
- "ignore_bounds": bool(ignore_bounds),
400
- "icon_size": [14, 14],
401
- "icon_anchor": [7, 7],
402
- "class_name": "mesa-trabalho-tecnico-marker",
403
- }
404
- )
405
- continue
406
-
407
- marker_html = (
408
- "<div style='display:flex;align-items:center;justify-content:center;"
409
- "width:8px;height:8px;border-radius:999px;background:#1f6fb2;"
410
- "border:1px solid #ffffff;box-shadow:0 0 0 1px rgba(20,42,66,0.20);'></div>"
411
- )
412
  payloads.append(
413
  {
414
  "lat": lat,
415
  "lon": lon,
416
  "trabalho_id": trabalho_id,
417
  "trabalho_nome": trabalho_nome,
 
 
 
 
 
418
  "tooltip_html": detalhes_html,
419
  "popup_html": detalhes_html,
420
  "source_overlay_ids": modelos_origem_ids,
421
  "marker_html": marker_html,
422
- "marker_style": "ponto",
423
  "ignore_bounds": bool(ignore_bounds),
424
- "icon_size": [8, 8],
425
- "icon_anchor": [4, 4],
426
- "class_name": "mesa-trabalho-tecnico-marker mesa-trabalho-tecnico-marker-dot",
427
  }
428
  )
429
 
 
 
 
430
  if apply_jitter:
431
  apply_marker_payload_jitter(payloads)
432
 
@@ -440,8 +601,8 @@ def add_marker_payloads(
440
  for item in payloads or []:
441
  marcador = folium.Marker(
442
  location=[float(item["lat"]), float(item["lon"])],
443
- tooltip=folium.Tooltip(str(item.get("tooltip_html") or ""), sticky=True),
444
- popup=folium.Popup(str(item.get("popup_html") or ""), max_width=360),
445
  icon=folium.DivIcon(
446
  html=str(item.get("marker_html") or ""),
447
  icon_size=tuple(item.get("icon_size") or [14, 14]),
@@ -461,6 +622,7 @@ def add_trabalhos_tecnicos_markers(
461
  marker_style: str = "estrela",
462
  ignore_bounds: bool = True,
463
  apply_jitter: bool = True,
 
464
  ) -> None:
465
  payloads = build_trabalhos_tecnicos_marker_payloads(
466
  trabalhos,
@@ -468,6 +630,7 @@ def add_trabalhos_tecnicos_markers(
468
  marker_style=marker_style,
469
  ignore_bounds=ignore_bounds,
470
  apply_jitter=apply_jitter,
 
471
  )
472
  add_marker_payloads(camada, payloads)
473
 
 
292
  return payloads
293
 
294
 
295
+ def _marker_payload_coord_key(item: dict[str, Any], lat_key: str = "lat", lon_key: str = "lon") -> tuple[float, float] | None:
296
+ try:
297
+ lat = float(item.get(lat_key))
298
+ lon = float(item.get(lon_key))
299
+ except Exception:
300
+ return None
301
+ if not math.isfinite(lat) or not math.isfinite(lon):
302
+ return None
303
+ return (round(lat, 7), round(lon, 7))
304
+
305
+
306
+ def _dedupe_ordered(values: list[Any]) -> list[str]:
307
+ resultado: list[str] = []
308
+ vistos: set[str] = set()
309
+ for value in values:
310
+ texto = str(value or "").strip()
311
+ if not texto or texto in vistos:
312
+ continue
313
+ vistos.add(texto)
314
+ resultado.append(texto)
315
+ return resultado
316
+
317
+
318
+ def _trabalho_tecnico_group_sort_key(item: dict[str, Any]) -> tuple[str, str, str]:
319
+ return (
320
+ str(item.get("trabalho_nome") or "").strip().casefold(),
321
+ str(item.get("endereco_texto") or "").strip().casefold(),
322
+ str(item.get("trabalho_id") or "").strip().casefold(),
323
+ )
324
+
325
+
326
+ def _trabalho_tecnico_group_marker_html(total: int, marker_style: str) -> str:
327
+ total_label = "99+" if total > 99 else str(total)
328
+ return (
329
+ "<div style='display:flex;align-items:center;justify-content:center;"
330
+ "width:24px;height:24px;box-sizing:border-box;border-radius:999px;"
331
+ "background:#c62828;border:2px solid #ffffff;color:#fff;"
332
+ "box-shadow:0 0 0 1px rgba(22,38,52,0.22),0 4px 12px rgba(21,35,50,0.28);"
333
+ "font-family:\"Segoe UI\",Arial,sans-serif;font-size:11px;font-weight:800;line-height:1;'>"
334
+ "<span style='display:block;min-width:14px;padding:1px 2px;text-align:center;"
335
+ "pointer-events:none;text-shadow:0 1px 2px rgba(0,0,0,0.45);'>"
336
+ f"{escape(total_label)}"
337
+ "</span></div>"
338
+ )
339
+
340
+
341
+ def _trabalho_tecnico_single_marker_html() -> str:
342
+ return (
343
+ "<div style='display:flex;align-items:center;justify-content:center;"
344
+ "width:14px;height:14px;box-sizing:border-box;border-radius:999px;background:#c62828;"
345
+ "border:2px solid #ffffff;box-shadow:0 0 0 1px rgba(22,38,52,0.22),0 3px 9px rgba(21,35,50,0.24);'>"
346
+ "</div>"
347
+ )
348
+
349
+
350
+ def _trabalho_tecnico_link_html(item: dict[str, Any]) -> str:
351
+ trabalho_id = str(item.get("trabalho_id") or "").strip()
352
+ trabalho_nome = str(item.get("trabalho_nome") or trabalho_id or "Trabalho tecnico").strip()
353
+ origem = str(item.get("origem") or "pesquisa_mapa").strip() or "pesquisa_mapa"
354
+ if not trabalho_id:
355
+ return f"<span style='font-weight:700; color:#7d5a00;'>{escape(trabalho_nome)}</span>"
356
+ payload_json = json.dumps(
357
+ {"type": "mesa:open-trabalho-tecnico", "trabalhoId": trabalho_id, "origem": origem},
358
+ ensure_ascii=True,
359
+ )
360
+ return (
361
+ "<a href='#' "
362
+ "style='color:#7d5a00;text-decoration:underline;font-weight:700;' "
363
+ f"onclick='try{{(window.parent || window).postMessage({payload_json}, \"*\");}}catch(_error){{}} return false;'>"
364
+ f"{escape(trabalho_nome)}"
365
+ "</a>"
366
+ )
367
+
368
+
369
+ def _trabalho_tecnico_group_tooltip_html(itens: list[dict[str, Any]]) -> str:
370
+ total = len(itens)
371
+ preview = itens[:8]
372
+ linhas = []
373
+ for item in preview:
374
+ nome = str(item.get("trabalho_nome") or item.get("trabalho_id") or "Trabalho tecnico").strip()
375
+ endereco = str(item.get("endereco_texto") or "").strip()
376
+ linhas.append(
377
+ "<li style='margin:2px 0;'>"
378
+ f"<strong>{escape(nome)}</strong>"
379
+ + (f"<br><span style='color:#666;'>{escape(endereco)}</span>" if endereco else "")
380
+ + "</li>"
381
+ )
382
+ restante = total - len(preview)
383
+ if restante > 0:
384
+ linhas.append(f"<li style='margin:2px 0;color:#666;'>+ {restante} trabalho(s)</li>")
385
+ return (
386
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.45; min-width:260px;'>"
387
+ f"<div style='margin-bottom:6px;'><b>{total} trabalhos técnicos neste local</b></div>"
388
+ "<ul style='margin:0;padding-left:16px;'>"
389
+ + "".join(linhas)
390
+ + "</ul></div>"
391
+ )
392
+
393
+
394
+ def _trabalho_tecnico_group_popup_html(itens: list[dict[str, Any]]) -> str:
395
+ total = len(itens)
396
+ linhas = []
397
+ for item in itens:
398
+ endereco = str(item.get("endereco_texto") or "Endereco nao informado").strip()
399
+ tipo_label = str(item.get("tipo_label") or "").strip()
400
+ distancia = str(item.get("distancia_min_label") or "").strip()
401
+ modelos = str(item.get("modelos_texto") or "").strip()
402
+ linhas.append(
403
+ "<div style='padding:9px 0;border-top:1px solid #e6edf3;'>"
404
+ f"<div style='margin-bottom:4px;'>{_trabalho_tecnico_link_html(item)}</div>"
405
+ f"<div><span style='color:#666;'>Endereço:</span> {escape(endereco)}</div>"
406
+ + (f"<div><span style='color:#666;'>Tipo:</span> {escape(tipo_label)}</div>" if tipo_label else "")
407
+ + (f"<div><span style='color:#666;'>Menor distância:</span> {escape(distancia)}</div>" if distancia else "")
408
+ + (f"<div><span style='color:#666;'>Modelos:</span> {escape(modelos)}</div>" if modelos else "")
409
+ + "</div>"
410
+ )
411
+ lista_style = (
412
+ "max-height:260px;overflow-y:auto;padding-right:6px;"
413
+ if total >= 3
414
+ else ""
415
+ )
416
+ return (
417
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.5; min-width:320px; max-width:430px;'>"
418
+ f"<div style='margin-bottom:8px;'><b>{total} trabalhos técnicos neste local</b></div>"
419
+ f"<div style='{lista_style}'>"
420
+ + "".join(linhas)
421
+ + "</div>"
422
+ + "</div>"
423
+ )
424
+
425
+
426
+ def _agrupar_trabalhos_tecnicos_marker_payloads(
427
+ payloads: list[dict[str, Any]],
428
+ *,
429
+ marker_style: str = "estrela",
430
+ ) -> list[dict[str, Any]]:
431
+ if len(payloads) <= 1:
432
+ return payloads
433
+
434
+ grupos: dict[tuple[float, float], list[dict[str, Any]]] = {}
435
+ ordem: list[tuple[float, float]] = []
436
+ for item in payloads:
437
+ chave = _marker_payload_coord_key(item)
438
+ if chave is None:
439
+ continue
440
+ if chave not in grupos:
441
+ ordem.append(chave)
442
+ grupos.setdefault(chave, []).append(item)
443
+
444
+ if not grupos:
445
+ return payloads
446
+
447
+ resultado: list[dict[str, Any]] = []
448
+ agrupados = {chave for chave, itens in grupos.items() if len(itens) > 1}
449
+ for item in payloads:
450
+ chave = _marker_payload_coord_key(item)
451
+ if chave not in agrupados:
452
+ resultado.append(item)
453
+
454
+ for chave in ordem:
455
+ itens = grupos.get(chave) or []
456
+ if len(itens) <= 1:
457
+ continue
458
+ itens_ordenados = sorted(itens, key=_trabalho_tecnico_group_sort_key)
459
+ base = itens_ordenados[0]
460
+ source_overlay_ids = _dedupe_ordered(
461
+ [
462
+ source_id
463
+ for item in itens_ordenados
464
+ for source_id in (item.get("source_overlay_ids") or [])
465
+ ]
466
+ )
467
+ resultado.append(
468
+ {
469
+ "lat": float(base["lat"]),
470
+ "lon": float(base["lon"]),
471
+ "trabalho_id": "",
472
+ "trabalho_nome": f"{len(itens_ordenados)} trabalhos técnicos",
473
+ "tooltip_html": _trabalho_tecnico_group_tooltip_html(itens_ordenados),
474
+ "tooltip_sticky": False,
475
+ "popup_html": _trabalho_tecnico_group_popup_html(itens_ordenados),
476
+ "popup_max_width": 460,
477
+ "source_overlay_ids": source_overlay_ids,
478
+ "marker_html": _trabalho_tecnico_group_marker_html(len(itens_ordenados), marker_style),
479
+ "marker_style": f"{str(marker_style or 'estrela').strip().lower()}-grupo",
480
+ "ignore_bounds": all(bool(item.get("ignore_bounds")) for item in itens_ordenados),
481
+ "icon_size": [24, 24],
482
+ "icon_anchor": [12, 12],
483
+ "class_name": "mesa-trabalho-tecnico-marker mesa-trabalho-tecnico-group-marker",
484
+ }
485
+ )
486
+
487
+ return resultado
488
+
489
+
490
  def build_trabalhos_tecnicos_marker_payloads(
491
  trabalhos: list[dict[str, Any]] | None,
492
  *,
 
494
  marker_style: str = "estrela",
495
  ignore_bounds: bool = True,
496
  apply_jitter: bool = True,
497
+ group_overlaps: bool = True,
498
  ) -> list[dict[str, Any]]:
499
  payloads: list[dict[str, Any]] = []
500
  for item in trabalhos or []:
 
533
  )
534
  distancia_min_label = str(item.get("distancia_label_min") or "").strip()
535
 
536
+ trabalho_nome_html = _trabalho_tecnico_link_html(
537
+ {
538
+ "trabalho_id": trabalho_id,
539
+ "trabalho_nome": trabalho_nome,
540
+ "origem": origem,
541
+ }
542
+ )
 
 
 
 
 
 
 
 
 
 
 
543
 
544
  detalhes_html = (
545
  "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.55; min-width:260px;'>"
 
560
  + "</div>"
561
  )
562
 
563
+ marker_html = _trabalho_tecnico_single_marker_html()
564
+ marker_style_norm = str(marker_style or "ponto").strip().lower() or "ponto"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  payloads.append(
566
  {
567
  "lat": lat,
568
  "lon": lon,
569
  "trabalho_id": trabalho_id,
570
  "trabalho_nome": trabalho_nome,
571
+ "origem": origem,
572
+ "tipo_label": tipo_label,
573
+ "endereco_texto": endereco_texto,
574
+ "distancia_min_label": distancia_min_label,
575
+ "modelos_texto": modelos_texto,
576
  "tooltip_html": detalhes_html,
577
  "popup_html": detalhes_html,
578
  "source_overlay_ids": modelos_origem_ids,
579
  "marker_html": marker_html,
580
+ "marker_style": marker_style_norm,
581
  "ignore_bounds": bool(ignore_bounds),
582
+ "icon_size": [14, 14],
583
+ "icon_anchor": [7, 7],
584
+ "class_name": "mesa-trabalho-tecnico-marker mesa-trabalho-tecnico-marker-circle",
585
  }
586
  )
587
 
588
+ if group_overlaps:
589
+ return _agrupar_trabalhos_tecnicos_marker_payloads(payloads, marker_style=marker_style)
590
+
591
  if apply_jitter:
592
  apply_marker_payload_jitter(payloads)
593
 
 
601
  for item in payloads or []:
602
  marcador = folium.Marker(
603
  location=[float(item["lat"]), float(item["lon"])],
604
+ tooltip=folium.Tooltip(str(item.get("tooltip_html") or ""), sticky=item.get("tooltip_sticky") is not False),
605
+ popup=folium.Popup(str(item.get("popup_html") or ""), max_width=int(item.get("popup_max_width") or 360)),
606
  icon=folium.DivIcon(
607
  html=str(item.get("marker_html") or ""),
608
  icon_size=tuple(item.get("icon_size") or [14, 14]),
 
622
  marker_style: str = "estrela",
623
  ignore_bounds: bool = True,
624
  apply_jitter: bool = True,
625
+ group_overlaps: bool = True,
626
  ) -> None:
627
  payloads = build_trabalhos_tecnicos_marker_payloads(
628
  trabalhos,
 
630
  marker_style=marker_style,
631
  ignore_bounds=ignore_bounds,
632
  apply_jitter=apply_jitter,
633
+ group_overlaps=group_overlaps,
634
  )
635
  add_marker_payloads(camada, payloads)
636
 
backend/app/core/visualizacao/app.py CHANGED
@@ -15,6 +15,7 @@ from folium import plugins
15
  from scipy import stats
16
  from statsmodels.stats.outliers_influence import OLSInfluence
17
 
 
18
  from app.core.map_layers import (
19
  add_bairros_layer,
20
  add_indice_marker,
@@ -163,7 +164,7 @@ def _criar_histograma_residuos(residuos):
163
  return None
164
 
165
 
166
- def _criar_grafico_cook(modelos_sm):
167
  try:
168
  if modelos_sm is None:
169
  return None
@@ -171,15 +172,16 @@ def _criar_grafico_cook(modelos_sm):
171
  influence = OLSInfluence(modelos_sm)
172
  cooks_d = influence.cooks_distance[0]
173
  n = len(cooks_d)
174
- indices = np.arange(1, n + 1)
 
175
  limite = 4 / n
176
 
177
  fig = go.Figure()
178
- for idx, valor in zip(indices, cooks_d):
179
  cor = COR_LINHA if valor > limite else COR_PRINCIPAL
180
  fig.add_trace(
181
  go.Scatter(
182
- x=[idx, idx],
183
  y=[0, valor],
184
  mode="lines",
185
  line=dict(color=cor, width=1.5),
@@ -191,12 +193,13 @@ def _criar_grafico_cook(modelos_sm):
191
  cores_pontos = [COR_LINHA if v > limite else COR_PRINCIPAL for v in cooks_d]
192
  fig.add_trace(
193
  go.Scatter(
194
- x=indices,
195
  y=cooks_d,
196
  mode="markers",
197
  marker=dict(color=cores_pontos, size=8, line=dict(color="black", width=1)),
198
  name="Distância de Cook",
199
- hovertemplate="Obs: %{x}<br>Cook: %{y:.4f}<extra></extra>",
 
200
  )
201
  )
202
  fig.add_hline(
@@ -208,12 +211,12 @@ def _criar_grafico_cook(modelos_sm):
208
  )
209
  fig.update_layout(
210
  title=dict(text="Distância de Cook", x=0.5),
211
- xaxis_title="Observação",
212
  yaxis_title="Distância de Cook",
213
  plot_bgcolor="white",
214
  margin=dict(l=60, r=40, t=60, b=60),
215
  )
216
- fig.update_xaxes(showgrid=True, gridcolor="lightgray", showline=True, linecolor="black")
217
  fig.update_yaxes(showgrid=True, gridcolor="lightgray", showline=True, linecolor="black")
218
  return fig
219
  except Exception as exc:
@@ -371,7 +374,7 @@ def gerar_todos_graficos(pacote):
371
  if residuos is not None:
372
  graficos["hist"] = _criar_histograma_residuos(residuos)
373
  if modelos_sm is not None:
374
- graficos["cook"] = _criar_grafico_cook(modelos_sm)
375
  graficos["corr"] = _criar_grafico_correlacao(modelos_sm, nome_y=nome_y)
376
 
377
  return graficos
@@ -524,59 +527,6 @@ def formatar_escalas_html(escalas_raw):
524
  )
525
 
526
 
527
- def _aplicar_jitter_sobrepostos(df_mapa, lat_col, lon_col, lat_plot_col, lon_plot_col):
528
- df_plot = df_mapa.copy()
529
- df_plot[lat_plot_col] = pd.to_numeric(df_plot[lat_col], errors="coerce")
530
- df_plot[lon_plot_col] = pd.to_numeric(df_plot[lon_col], errors="coerce")
531
-
532
- if len(df_plot) <= 1:
533
- return df_plot
534
-
535
- chave_lat = df_plot[lat_col].round(7)
536
- chave_lon = df_plot[lon_col].round(7)
537
- grupos = df_plot.groupby([chave_lat, chave_lon], sort=False)
538
-
539
- passo_metros = 4.0
540
- max_raio_metros = 22.0
541
- metros_por_grau_lat = 111_320.0
542
- lat_plot_pos = int(df_plot.columns.get_loc(lat_plot_col))
543
- lon_plot_pos = int(df_plot.columns.get_loc(lon_plot_col))
544
-
545
- for _, idx_labels in grupos.indices.items():
546
- posicoes = np.asarray(idx_labels, dtype=int)
547
- if posicoes.size <= 1:
548
- continue
549
- base_lat = float(df_plot.iat[int(posicoes[0]), lat_plot_pos])
550
- base_lon = float(df_plot.iat[int(posicoes[0]), lon_plot_pos])
551
- if not np.isfinite(base_lat) or not np.isfinite(base_lon):
552
- continue
553
-
554
- seed_val = int((abs(base_lat) * 1_000_000.0) + (abs(base_lon) * 1_000_000.0) * 3.0) % 360
555
- angulo_base = math.radians(seed_val)
556
- cos_lat = max(abs(math.cos(math.radians(base_lat))), 1e-6)
557
- metros_por_grau_lon = metros_por_grau_lat * cos_lat
558
-
559
- for pos, pos_idx in enumerate(posicoes):
560
- if pos == 0:
561
- continue
562
- pos_ring = pos - 1
563
- ring = 1
564
- while pos_ring >= (6 * ring):
565
- pos_ring -= 6 * ring
566
- ring += 1
567
-
568
- slots_ring = max(6 * ring, 1)
569
- angulo = angulo_base + (2.0 * math.pi * (pos_ring / slots_ring))
570
- raio_m = min(ring * passo_metros, max_raio_metros)
571
- delta_lat = (raio_m * math.sin(angulo)) / metros_por_grau_lat
572
- delta_lon = (raio_m * math.cos(angulo)) / metros_por_grau_lon
573
-
574
- df_plot.iat[int(pos_idx), lat_plot_pos] = base_lat + delta_lat
575
- df_plot.iat[int(pos_idx), lon_plot_pos] = base_lon + delta_lon
576
-
577
- return df_plot
578
-
579
-
580
  def _montar_popup_registro_paginado(itens, popup_uid, max_itens_pagina=8):
581
  def _limitar_texto(valor: str, limite: int) -> str:
582
  txt = str(valor)
@@ -787,8 +737,8 @@ def criar_mapa(
787
  prefer_canvas=True,
788
  control_scale=True,
789
  )
790
- folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(mapa)
791
- folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
792
  add_bairros_layer(mapa, show=True)
793
 
794
  if tamanho_col and tamanho_col != "Visualização Padrão" and not cor_col:
 
15
  from scipy import stats
16
  from statsmodels.stats.outliers_influence import OLSInfluence
17
 
18
+ from app.core.map_jitter import aplicar_jitter_dados_mercado as _aplicar_jitter_sobrepostos
19
  from app.core.map_layers import (
20
  add_bairros_layer,
21
  add_indice_marker,
 
164
  return None
165
 
166
 
167
+ def _criar_grafico_cook(modelos_sm, indices=None):
168
  try:
169
  if modelos_sm is None:
170
  return None
 
172
  influence = OLSInfluence(modelos_sm)
173
  cooks_d = influence.cooks_distance[0]
174
  n = len(cooks_d)
175
+ indices_reais = np.array(indices) if indices is not None and len(indices) == n else np.arange(1, n + 1)
176
+ posicoes = np.arange(1, n + 1)
177
  limite = 4 / n
178
 
179
  fig = go.Figure()
180
+ for posicao, valor in zip(posicoes, cooks_d):
181
  cor = COR_LINHA if valor > limite else COR_PRINCIPAL
182
  fig.add_trace(
183
  go.Scatter(
184
+ x=[posicao, posicao],
185
  y=[0, valor],
186
  mode="lines",
187
  line=dict(color=cor, width=1.5),
 
193
  cores_pontos = [COR_LINHA if v > limite else COR_PRINCIPAL for v in cooks_d]
194
  fig.add_trace(
195
  go.Scatter(
196
+ x=posicoes,
197
  y=cooks_d,
198
  mode="markers",
199
  marker=dict(color=cores_pontos, size=8, line=dict(color="black", width=1)),
200
  name="Distância de Cook",
201
+ customdata=indices_reais,
202
+ hovertemplate="<b>Índice:</b> %{customdata}<br><b>Cook:</b> %{y:.4f}<extra></extra>",
203
  )
204
  )
205
  fig.add_hline(
 
211
  )
212
  fig.update_layout(
213
  title=dict(text="Distância de Cook", x=0.5),
214
+ xaxis_title="Observação (ver índice no hover)",
215
  yaxis_title="Distância de Cook",
216
  plot_bgcolor="white",
217
  margin=dict(l=60, r=40, t=60, b=60),
218
  )
219
+ fig.update_xaxes(showgrid=True, gridcolor="lightgray", showline=True, linecolor="black", showticklabels=False)
220
  fig.update_yaxes(showgrid=True, gridcolor="lightgray", showline=True, linecolor="black")
221
  return fig
222
  except Exception as exc:
 
374
  if residuos is not None:
375
  graficos["hist"] = _criar_histograma_residuos(residuos)
376
  if modelos_sm is not None:
377
+ graficos["cook"] = _criar_grafico_cook(modelos_sm, indices)
378
  graficos["corr"] = _criar_grafico_correlacao(modelos_sm, nome_y=nome_y)
379
 
380
  return graficos
 
527
  )
528
 
529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  def _montar_popup_registro_paginado(itens, popup_uid, max_itens_pagina=8):
531
  def _limitar_texto(valor: str, limite: int) -> str:
532
  txt = str(valor)
 
737
  prefer_canvas=True,
738
  control_scale=True,
739
  )
740
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=True).add_to(mapa)
741
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=False).add_to(mapa)
742
  add_bairros_layer(mapa, show=True)
743
 
744
  if tamanho_col and tamanho_col != "Visualização Padrão" and not cor_col:
backend/app/core/visualizacao/map_payload.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  from typing import Any
4
 
5
  import branca.colormap as cm
@@ -10,18 +11,27 @@ from scipy.interpolate import griddata
10
  from app.core.elaboracao.charts import (
11
  _contorno_convexo_lng_lat,
12
  _mascara_dentro_poligono,
13
- _montar_popup_registro_em_colunas,
14
  _normalizar_stops_cor,
15
  )
16
  from app.core.map_layers import build_trabalhos_tecnicos_marker_payloads
17
- from app.core.visualizacao.app import COR_PRINCIPAL, _aplicar_jitter_sobrepostos, formatar_monetario
18
 
19
 
20
  _LAT_ALIASES = {"lat", "latitude", "siat_latitude"}
21
  _LON_ALIASES = {"lon", "longitude", "long", "siat_longitude"}
22
  _TILE_LAYERS = [
23
- {"id": "osm", "label": "OpenStreetMap", "url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"},
24
- {"id": "positron", "label": "Positron", "url": "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"},
 
 
 
 
 
 
 
 
 
 
25
  ]
26
 
27
 
@@ -59,6 +69,42 @@ def _formatar_tooltip_valor(coluna: str | None, valor: Any) -> str:
59
  return str(valor)
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def _resolver_bounds(df_mapa: pd.DataFrame, lat_key: str, lon_key: str) -> list[list[float]]:
63
  df_bounds = df_mapa
64
  if len(df_mapa) >= 8:
@@ -217,6 +263,7 @@ def build_elaboracao_map_payload(
217
  cor_tick_values: list[float] | None = None,
218
  cor_tick_labels: list[str] | None = None,
219
  bairros_geojson_url: str = "/api/visualizacao/map/bairros.geojson",
 
220
  ) -> dict[str, Any] | None:
221
  modo_normalizado = str(modo or "pontos").strip().lower()
222
 
@@ -237,7 +284,10 @@ def build_elaboracao_map_payload(
237
  if lat_real is None or lon_real is None:
238
  return None
239
 
 
240
  df_mapa = df.copy()
 
 
241
  df_mapa[lat_real] = pd.to_numeric(df_mapa[lat_real], errors="coerce")
242
  df_mapa[lon_real] = pd.to_numeric(df_mapa[lon_real], errors="coerce")
243
  df_mapa = df_mapa.dropna(subset=[lat_real, lon_real])
@@ -327,13 +377,9 @@ def build_elaboracao_map_payload(
327
  lat_plot_col = "__mesa_lat_plot__"
328
  lon_plot_col = "__mesa_lon_plot__"
329
  if not modo_calor and not modo_superficie:
330
- df_plot_pontos = _aplicar_jitter_sobrepostos(
331
- df_mapa,
332
- lat_col=str(lat_real),
333
- lon_col=str(lon_real),
334
- lat_plot_col=lat_plot_col,
335
- lon_plot_col=lon_plot_col,
336
- )
337
  else:
338
  df_plot_pontos = df_mapa.copy()
339
  df_plot_pontos[lat_plot_col] = df_plot_pontos[lat_real]
@@ -538,72 +584,112 @@ def build_elaboracao_map_payload(
538
  }
539
  )
540
  else:
541
- popup_cols: list[str]
542
- if len(df_plot_pontos) <= 1200:
543
- popup_cols = [str(c) for c in df_plot_pontos.columns]
544
- elif tamanho_col and tamanho_col in df_plot_pontos.columns:
545
- popup_cols = [str(tamanho_col)]
546
- else:
547
- popup_cols = []
548
-
549
  market_points: list[dict[str, Any]] = []
550
  indices_markers: list[dict[str, Any]] = []
551
- for marker_ordem, (idx, row) in enumerate(df_plot_pontos.iterrows()):
552
- idx_display = int(idx) if isinstance(idx, (int, np.integer)) else marker_ordem + 1
553
- popup_html, popup_width = _montar_popup_registro_em_colunas(
554
- idx_display,
555
- row,
556
- popup_cols,
557
- max_itens_coluna=8,
558
- popup_uid=f"mesa-pop-elab-{marker_ordem}",
559
- )
560
- tooltip_html = (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
  "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px; line-height:1.7; padding:2px 4px;'>"
562
  f"<b>Índice {idx_display}</b>"
563
- )
564
- if tamanho_col and tamanho_col in df_plot_pontos.columns:
565
- valor_tooltip = row[tamanho_col]
566
- valor_texto = (
567
- f"{float(valor_tooltip):.2f}"
568
- if isinstance(valor_tooltip, (int, float, np.integer, np.floating)) and np.isfinite(float(valor_tooltip))
569
- else str(valor_tooltip)
570
  )
571
- tooltip_html += (
572
- f"<br><span style='color:#555;'>{str(tamanho_col)}:</span> "
573
- f"<b>{valor_texto}</b>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
  )
575
- tooltip_html += "</div>"
576
-
577
- cor = COR_PRINCIPAL
578
- if colormap and cor_col_resolvida and cor_col_resolvida in df_mapa.columns:
579
- valor_cor = pd.to_numeric(pd.Series([row.get(cor_col_resolvida)]), errors="coerce").iloc[0]
580
- if pd.notna(valor_cor):
581
- cor = str(colormap(float(valor_cor)))
582
-
583
- if idx == indice_destacado:
584
- raio = raio_max + 4.0
585
- elif tamanho_func and tamanho_col and tamanho_col in row.index and pd.notna(row[tamanho_col]):
586
- raio = float(tamanho_func(float(row[tamanho_col])))
587
  else:
588
- raio = 4.0
589
- stroke_weight = 3.0 if idx == indice_destacado else 1.0
590
- fill_opacity = 0.8 if idx == indice_destacado else 0.6
591
-
592
- market_points.append(
593
- {
594
- "lat": float(row[lat_plot_col]),
595
- "lon": float(row[lon_plot_col]),
596
- "indice": idx_display,
597
- "color": cor,
598
- "base_radius": float(max(1.0, raio)),
599
- "stroke_color": "#000000",
600
- "stroke_weight": float(stroke_weight),
601
- "fill_opacity": float(fill_opacity),
602
- "tooltip_html": tooltip_html,
603
- "popup_html": popup_html,
604
- "popup_max_width": int(popup_width),
605
- }
606
- )
607
 
608
  if mostrar_indices:
609
  indices_markers.append(
@@ -615,10 +701,10 @@ def build_elaboracao_map_payload(
615
  + 'border: 1px solid rgba(28, 45, 66, 0.45);border-radius: 10px;padding: 1px 6px;font-size: 11px;'
616
  + 'font-weight: 700;line-height: 1.2;color: #1f2f44;white-space: nowrap;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);'
617
  + 'pointer-events: none;">'
618
- + f"{idx_display}"
619
  + "</div>"
620
  ),
621
- "icon_size": [72, 24],
622
  "icon_anchor": [0, 0],
623
  "class_name": "mesa-indice-label",
624
  "interactive": False,
@@ -749,13 +835,9 @@ def build_visualizacao_map_payload(
749
  show_indices = False
750
  lat_plot_key = "__mesa_lat_plot__"
751
  lon_plot_key = "__mesa_lon_plot__"
752
- df_plot_pontos = _aplicar_jitter_sobrepostos(
753
- df_mapa,
754
- lat_col=lat_key,
755
- lon_col=lon_key,
756
- lat_plot_col=lat_plot_key,
757
- lon_plot_col=lon_plot_key,
758
- )
759
 
760
  tooltip_col = None
761
  tooltip_key = None
@@ -774,35 +856,96 @@ def build_visualizacao_map_payload(
774
  raio_padrao = 4.0 if total_pontos_plot <= 2500 else 3.0
775
 
776
  market_points: list[dict[str, Any]] = []
777
- for idx, row in df_plot_pontos.iterrows():
778
- cor = colormap(row[cor_key]) if colormap and cor_key and pd.notna(row[cor_key]) else COR_PRINCIPAL
779
- if tamanho_func and tamanho_key and pd.notna(row[tamanho_key]):
780
- raio = float(tamanho_func(row[tamanho_key]))
781
- else:
782
- raio = raio_padrao
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
783
 
784
- idx_display = int(row["index"]) if "index" in row.index else int(idx)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
785
  tooltip_payload = {
786
  "title": f"Índice {idx_display}",
787
  "label": str(tooltip_col or ""),
788
- "value": (
789
- _formatar_tooltip_valor(str(tooltip_col or ""), row[tooltip_key])
790
- if tooltip_col and tooltip_key and tooltip_key in row.index
791
- else ""
792
- ),
793
  }
794
  row_id_raw = row["__mesa_row_id__"] if "__mesa_row_id__" in row.index else None
795
- market_points.append(
796
- {
797
- "lat": float(row[lat_plot_key]),
798
- "lon": float(row[lon_plot_key]),
799
- "indice": idx_display,
800
- "row_id": int(row_id_raw) if row_id_raw is not None and pd.notna(row_id_raw) else None,
801
- "color": str(cor),
802
- "base_radius": float(max(1.0, raio)),
803
- "tooltip": tooltip_payload,
804
- }
805
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
806
 
807
  return {
808
  "type": "mesa_leaflet_payload",
 
1
  from __future__ import annotations
2
 
3
+ from html import escape
4
  from typing import Any
5
 
6
  import branca.colormap as cm
 
11
  from app.core.elaboracao.charts import (
12
  _contorno_convexo_lng_lat,
13
  _mascara_dentro_poligono,
 
14
  _normalizar_stops_cor,
15
  )
16
  from app.core.map_layers import build_trabalhos_tecnicos_marker_payloads
17
+ from app.core.visualizacao.app import COR_PRINCIPAL, formatar_monetario
18
 
19
 
20
  _LAT_ALIASES = {"lat", "latitude", "siat_latitude"}
21
  _LON_ALIASES = {"lon", "longitude", "long", "siat_longitude"}
22
  _TILE_LAYERS = [
23
+ {
24
+ "id": "positron",
25
+ "label": "Positron",
26
+ "url": "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
27
+ "attribution": "&copy; OpenStreetMap contributors &copy; CARTO",
28
+ },
29
+ {
30
+ "id": "osm",
31
+ "label": "OpenStreetMap",
32
+ "url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
33
+ "attribution": "&copy; OpenStreetMap contributors",
34
+ },
35
  ]
36
 
37
 
 
69
  return str(valor)
70
 
71
 
72
+ def _formatar_valor_resumo_mapa(coluna: str | None, valor: Any) -> str:
73
+ return _formatar_tooltip_valor(coluna, valor)
74
+
75
+
76
+ def _formatar_indices_tooltip(indices: list[Any]) -> str:
77
+ return ", ".join(str(item) for item in indices)
78
+
79
+
80
+ def _formatar_indices_badge(indices: list[Any], limite: int = 28) -> str:
81
+ texto = ", ".join(str(item) for item in indices)
82
+ if len(texto) <= limite:
83
+ return texto
84
+ return texto[: max(0, limite - 1)].rstrip(" ,") + "…"
85
+
86
+
87
+ def _tooltip_html_grupo_mercado(indices: list[Any], label: str | None = None, valores: list[str] | None = None) -> str:
88
+ total = len(indices)
89
+ indices_txt = escape(_formatar_indices_tooltip(indices))
90
+ titulo = f"{total} dados neste local" if total != 1 else "1 dado neste local"
91
+ html = (
92
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px; line-height:1.7; padding:2px 4px;'>"
93
+ f"<b>{escape(titulo)}</b>"
94
+ f"<br><span style='color:#555;'>Índices:</span> <b>{indices_txt}</b>"
95
+ )
96
+ valores_limpos = [str(item) for item in valores or [] if str(item).strip()]
97
+ if label and valores_limpos:
98
+ unicos = list(dict.fromkeys(valores_limpos))
99
+ if len(unicos) == 1:
100
+ resumo = unicos[0]
101
+ else:
102
+ resumo = "valores diferentes"
103
+ html += f"<br><span style='color:#555;'>{escape(str(label))}:</span> <b>{escape(str(resumo))}</b>"
104
+ html += "</div>"
105
+ return html
106
+
107
+
108
  def _resolver_bounds(df_mapa: pd.DataFrame, lat_key: str, lon_key: str) -> list[list[float]]:
109
  df_bounds = df_mapa
110
  if len(df_mapa) >= 8:
 
263
  cor_tick_values: list[float] | None = None,
264
  cor_tick_labels: list[str] | None = None,
265
  bairros_geojson_url: str = "/api/visualizacao/map/bairros.geojson",
266
+ popup_source: str | None = "mercado",
267
  ) -> dict[str, Any] | None:
268
  modo_normalizado = str(modo or "pontos").strip().lower()
269
 
 
284
  if lat_real is None or lon_real is None:
285
  return None
286
 
287
+ row_id_col = "__mesa_row_id__"
288
  df_mapa = df.copy()
289
+ if popup_source and row_id_col not in df_mapa.columns:
290
+ df_mapa[row_id_col] = np.arange(len(df_mapa), dtype=int)
291
  df_mapa[lat_real] = pd.to_numeric(df_mapa[lat_real], errors="coerce")
292
  df_mapa[lon_real] = pd.to_numeric(df_mapa[lon_real], errors="coerce")
293
  df_mapa = df_mapa.dropna(subset=[lat_real, lon_real])
 
377
  lat_plot_col = "__mesa_lat_plot__"
378
  lon_plot_col = "__mesa_lon_plot__"
379
  if not modo_calor and not modo_superficie:
380
+ df_plot_pontos = df_mapa.copy()
381
+ df_plot_pontos[lat_plot_col] = df_plot_pontos[lat_real]
382
+ df_plot_pontos[lon_plot_col] = df_plot_pontos[lon_real]
 
 
 
 
383
  else:
384
  df_plot_pontos = df_mapa.copy()
385
  df_plot_pontos[lat_plot_col] = df_plot_pontos[lat_real]
 
584
  }
585
  )
586
  else:
 
 
 
 
 
 
 
 
587
  market_points: list[dict[str, Any]] = []
588
  indices_markers: list[dict[str, Any]] = []
589
+ grupos_coord = df_plot_pontos.groupby(
590
+ [df_plot_pontos[lat_real].round(7), df_plot_pontos[lon_real].round(7)],
591
+ sort=False,
592
+ ).indices
593
+ for marker_ordem, posicoes_raw in enumerate(grupos_coord.values()):
594
+ posicoes = list(posicoes_raw)
595
+ rows_grupo = [df_plot_pontos.iloc[int(pos)] for pos in posicoes]
596
+ row = rows_grupo[0]
597
+ idx = row.name
598
+ registros_grupo: list[dict[str, Any]] = []
599
+ indices_display: list[Any] = []
600
+ valores_tooltip: list[str] = []
601
+ cores_grupo: list[str] = []
602
+ raios_grupo: list[float] = []
603
+ destaque_grupo = False
604
+
605
+ for pos in posicoes:
606
+ row_item = df_plot_pontos.iloc[int(pos)]
607
+ idx_item = row_item.name
608
+ idx_display_item = int(idx_item) if isinstance(idx_item, (int, np.integer)) else int(pos) + 1
609
+ indices_display.append(idx_display_item)
610
+ valor_texto = ""
611
+ if tamanho_col and tamanho_col in df_plot_pontos.columns:
612
+ valor_texto = _formatar_valor_resumo_mapa(str(tamanho_col), row_item[tamanho_col])
613
+ valores_tooltip.append(valor_texto)
614
+
615
+ cor_item = COR_PRINCIPAL
616
+ if colormap and cor_col_resolvida and cor_col_resolvida in df_mapa.columns:
617
+ valor_cor = pd.to_numeric(pd.Series([row_item.get(cor_col_resolvida)]), errors="coerce").iloc[0]
618
+ if pd.notna(valor_cor):
619
+ cor_item = str(colormap(float(valor_cor)))
620
+ cores_grupo.append(cor_item)
621
+
622
+ if idx_item == indice_destacado:
623
+ raio_item = raio_max + 4.0
624
+ destaque_grupo = True
625
+ elif tamanho_func and tamanho_col and tamanho_col in row_item.index and pd.notna(row_item[tamanho_col]):
626
+ raio_item = float(tamanho_func(float(row_item[tamanho_col])))
627
+ else:
628
+ raio_item = 4.0
629
+ raios_grupo.append(float(max(1.0, raio_item)))
630
+
631
+ row_id_raw_item = row_item[row_id_col] if popup_source and row_id_col in row_item.index else None
632
+ popup_request_item = None
633
+ if row_id_raw_item is not None and pd.notna(row_id_raw_item):
634
+ popup_request_item = {
635
+ "kind": "elaboracao_row",
636
+ "row_id": int(row_id_raw_item),
637
+ "source": str(popup_source),
638
+ }
639
+ registros_grupo.append(
640
+ {
641
+ "indice": idx_display_item,
642
+ "label": f"Índice {idx_display_item}",
643
+ "value_label": str(tamanho_col or ""),
644
+ "value": valor_texto,
645
+ "popup_request": popup_request_item,
646
+ }
647
+ )
648
+
649
+ cor = cores_grupo[0] if cores_grupo else COR_PRINCIPAL
650
+ raio = max(raios_grupo) if raios_grupo else 4.0
651
+ stroke_weight = 3.0 if destaque_grupo else 1.0
652
+ fill_opacity = 0.8 if destaque_grupo else 0.6
653
+ is_grouped = len(rows_grupo) > 1
654
+ idx_display = indices_display[0] if indices_display else marker_ordem + 1
655
+ tooltip_html = _tooltip_html_grupo_mercado(
656
+ indices_display,
657
+ label=str(tamanho_col or "") if tamanho_col else None,
658
+ valores=valores_tooltip,
659
+ ) if is_grouped else (
660
  "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px; line-height:1.7; padding:2px 4px;'>"
661
  f"<b>Índice {idx_display}</b>"
662
+ + (
663
+ f"<br><span style='color:#555;'>{escape(str(tamanho_col))}:</span> <b>{escape(valores_tooltip[0])}</b>"
664
+ if tamanho_col and valores_tooltip
665
+ else ""
 
 
 
666
  )
667
+ + "</div>"
668
+ )
669
+
670
+ point_payload = {
671
+ "lat": float(row[lat_plot_col]),
672
+ "lon": float(row[lon_plot_col]),
673
+ "indice": _formatar_indices_badge(indices_display) if is_grouped else idx_display,
674
+ "color": cor,
675
+ "base_radius": float(max(1.0, raio)),
676
+ "stroke_color": "#243746" if is_grouped else "#000000",
677
+ "stroke_weight": float(max(stroke_weight, 2.0) if is_grouped else stroke_weight),
678
+ "fill_opacity": float(0.74 if is_grouped else fill_opacity),
679
+ "tooltip_html": tooltip_html,
680
+ }
681
+ if is_grouped:
682
+ point_payload.update(
683
+ {
684
+ "grouped": True,
685
+ "count": len(rows_grupo),
686
+ "group_title": f"{len(rows_grupo)} dados neste local",
687
+ "group_items": registros_grupo,
688
+ }
689
  )
 
 
 
 
 
 
 
 
 
 
 
 
690
  else:
691
+ point_payload["popup_request"] = registros_grupo[0].get("popup_request") if registros_grupo else None
692
+ market_points.append(point_payload)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
 
694
  if mostrar_indices:
695
  indices_markers.append(
 
701
  + 'border: 1px solid rgba(28, 45, 66, 0.45);border-radius: 10px;padding: 1px 6px;font-size: 11px;'
702
  + 'font-weight: 700;line-height: 1.2;color: #1f2f44;white-space: nowrap;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);'
703
  + 'pointer-events: none;">'
704
+ + escape(_formatar_indices_badge(indices_display))
705
  + "</div>"
706
  ),
707
+ "icon_size": [96 if is_grouped else 72, 24],
708
  "icon_anchor": [0, 0],
709
  "class_name": "mesa-indice-label",
710
  "interactive": False,
 
835
  show_indices = False
836
  lat_plot_key = "__mesa_lat_plot__"
837
  lon_plot_key = "__mesa_lon_plot__"
838
+ df_plot_pontos = df_mapa.copy()
839
+ df_plot_pontos[lat_plot_key] = df_plot_pontos[lat_key]
840
+ df_plot_pontos[lon_plot_key] = df_plot_pontos[lon_key]
 
 
 
 
841
 
842
  tooltip_col = None
843
  tooltip_key = None
 
856
  raio_padrao = 4.0 if total_pontos_plot <= 2500 else 3.0
857
 
858
  market_points: list[dict[str, Any]] = []
859
+ grupos_coord = df_plot_pontos.groupby(
860
+ [df_plot_pontos[lat_key].round(7), df_plot_pontos[lon_key].round(7)],
861
+ sort=False,
862
+ ).indices
863
+ for marker_ordem, posicoes_raw in enumerate(grupos_coord.values()):
864
+ posicoes = list(posicoes_raw)
865
+ rows_grupo = [df_plot_pontos.iloc[int(pos)] for pos in posicoes]
866
+ row = rows_grupo[0]
867
+ indices_display: list[Any] = []
868
+ registros_grupo: list[dict[str, Any]] = []
869
+ valores_tooltip: list[str] = []
870
+ cores_grupo: list[str] = []
871
+ raios_grupo: list[float] = []
872
+
873
+ for pos in posicoes:
874
+ row_item = df_plot_pontos.iloc[int(pos)]
875
+ idx_item = row_item.name
876
+ idx_display_item = int(row_item["index"]) if "index" in row_item.index else int(idx_item)
877
+ indices_display.append(idx_display_item)
878
+
879
+ valor_texto = (
880
+ _formatar_tooltip_valor(str(tooltip_col or ""), row_item[tooltip_key])
881
+ if tooltip_col and tooltip_key and tooltip_key in row_item.index
882
+ else ""
883
+ )
884
+ if valor_texto:
885
+ valores_tooltip.append(valor_texto)
886
 
887
+ cor_item = colormap(row_item[cor_key]) if colormap and cor_key and pd.notna(row_item[cor_key]) else COR_PRINCIPAL
888
+ cores_grupo.append(str(cor_item))
889
+ if tamanho_func and tamanho_key and pd.notna(row_item[tamanho_key]):
890
+ raio_item = float(tamanho_func(row_item[tamanho_key]))
891
+ else:
892
+ raio_item = raio_padrao
893
+ raios_grupo.append(float(max(1.0, raio_item)))
894
+
895
+ row_id_raw_item = row_item["__mesa_row_id__"] if "__mesa_row_id__" in row_item.index else None
896
+ row_id_item = int(row_id_raw_item) if row_id_raw_item is not None and pd.notna(row_id_raw_item) else None
897
+ registros_grupo.append(
898
+ {
899
+ "indice": idx_display_item,
900
+ "label": f"Índice {idx_display_item}",
901
+ "value_label": str(tooltip_col or ""),
902
+ "value": valor_texto,
903
+ "popup_request": (
904
+ {"kind": "visualizacao_row", "row_id": row_id_item}
905
+ if row_id_item is not None
906
+ else None
907
+ ),
908
+ }
909
+ )
910
+
911
+ is_grouped = len(rows_grupo) > 1
912
+ idx_display = indices_display[0] if indices_display else marker_ordem
913
+ cor = cores_grupo[0] if cores_grupo else COR_PRINCIPAL
914
+ raio = max(raios_grupo) if raios_grupo else raio_padrao
915
  tooltip_payload = {
916
  "title": f"Índice {idx_display}",
917
  "label": str(tooltip_col or ""),
918
+ "value": valores_tooltip[0] if valores_tooltip else "",
 
 
 
 
919
  }
920
  row_id_raw = row["__mesa_row_id__"] if "__mesa_row_id__" in row.index else None
921
+ point_payload = {
922
+ "lat": float(row[lat_plot_key]),
923
+ "lon": float(row[lon_plot_key]),
924
+ "indice": _formatar_indices_badge(indices_display) if is_grouped else idx_display,
925
+ "row_id": int(row_id_raw) if (not is_grouped and row_id_raw is not None and pd.notna(row_id_raw)) else None,
926
+ "color": str(cor),
927
+ "base_radius": float(max(1.0, raio)),
928
+ }
929
+ if is_grouped:
930
+ point_payload.update(
931
+ {
932
+ "grouped": True,
933
+ "count": len(rows_grupo),
934
+ "stroke_color": "#243746",
935
+ "stroke_weight": 2.0,
936
+ "fill_opacity": 0.74,
937
+ "tooltip_html": _tooltip_html_grupo_mercado(
938
+ indices_display,
939
+ label=str(tooltip_col or "") if tooltip_col else None,
940
+ valores=valores_tooltip,
941
+ ),
942
+ "group_title": f"{len(rows_grupo)} dados neste local",
943
+ "group_items": registros_grupo,
944
+ }
945
+ )
946
+ else:
947
+ point_payload["tooltip"] = tooltip_payload
948
+ market_points.append(point_payload)
949
 
950
  return {
951
  "type": "mesa_leaflet_payload",
backend/app/services/elaboracao_service.py CHANGED
@@ -54,6 +54,7 @@ from app.core.elaboracao.formatadores import (
54
  formatar_micronumerosidade_html,
55
  formatar_outliers_anteriores_html,
56
  )
 
57
  from app.core.visualizacao.map_payload import build_elaboracao_map_payload
58
  from app.models.session import SessionState
59
  from app.runtime_paths import resolve_core_path
@@ -2107,7 +2108,11 @@ def apply_outlier_filters(session: SessionState, filtros: list[dict[str, Any]])
2107
 
2108
  indices = _coletar_indices_outliers(metricas, filtros)
2109
  outliers_propostos = sorted(set(_clean_int_list(session.outliers_anteriores) + indices))
2110
- _validar_outliers_micronumerosidade_geral(session, outliers_propostos)
 
 
 
 
2111
  texto = ", ".join(str(i) for i in indices)
2112
  return {
2113
  "indices": indices,
@@ -2213,7 +2218,10 @@ def _info_micronumerosidade_geral_outliers(session: SessionState, outliers_base:
2213
  }
2214
 
2215
 
2216
- def _formatar_erro_micronumerosidade_geral(limite_info: dict[str, Any] | None) -> str:
 
 
 
2217
  if not limite_info:
2218
  return "A exclusão ultrapassa a micronumerosidade mínima do modelo."
2219
  n_final = int(limite_info.get("n_final_atual") or 0)
@@ -2221,15 +2229,20 @@ def _formatar_erro_micronumerosidade_geral(limite_info: dict[str, Any] | None) -
2221
  k = int(limite_info.get("k") or 0)
2222
  total = int(limite_info.get("n_total") or 0)
2223
  excluidos = int(limite_info.get("excluidos_presentes") or 0)
 
2224
  return (
2225
  "A exclusão ultrapassa a micronumerosidade mínima: "
2226
  f"restariam {n_final} observações de {total}, mas o modelo precisa manter pelo menos {minimo} "
2227
  f"(n >= 3(k+1), k={k}). "
2228
- f"A seleção atual excluiria {excluidos} observação(ões). Reinclua dados ou ajuste os filtros de outliers."
2229
  )
2230
 
2231
 
2232
- def _validar_outliers_micronumerosidade_geral(session: SessionState, outliers: list[int]) -> None:
 
 
 
 
2233
  info = _info_micronumerosidade_geral_outliers(session, outliers)
2234
  n_final = int(info.get("n_final_atual") or 0)
2235
  minimo = int(info.get("min_observacoes") or 0)
@@ -2241,7 +2254,7 @@ def _validar_outliers_micronumerosidade_geral(session: SessionState, outliers: l
2241
 
2242
  raise HTTPException(
2243
  status_code=400,
2244
- detail=_formatar_erro_micronumerosidade_geral(info),
2245
  )
2246
 
2247
 
@@ -2358,7 +2371,11 @@ def apply_outlier_filters_recursive(
2358
  novos_iteracao = [idx for idx in indices_iteracao if idx not in outliers_atuais and idx not in novos_set]
2359
  if novos_iteracao:
2360
  outliers_propostos = sorted(set(outliers_atuais + novos_iteracao))
2361
- _validar_outliers_micronumerosidade_geral(session, outliers_propostos)
 
 
 
 
2362
  iteracoes += 1
2363
  novos_acumulados.extend(novos_iteracao)
2364
  novos_set.update(novos_iteracao)
@@ -2392,7 +2409,11 @@ def apply_outlier_filters_recursive(
2392
  break
2393
 
2394
  outliers_propostos = sorted(set(outliers_atuais + novos_iteracao))
2395
- _validar_outliers_micronumerosidade_geral(session, outliers_propostos)
 
 
 
 
2396
  iteracoes += 1
2397
  novos_acumulados.extend(novos_iteracao)
2398
  novos_set.update(novos_iteracao)
@@ -2453,7 +2474,11 @@ def reiniciar_iteracao(
2453
 
2454
  anteriores_atualizados = [i for i in session.outliers_anteriores if i not in reincluir]
2455
  outliers_combinados = sorted(set(anteriores_atualizados + novos))
2456
- _validar_outliers_micronumerosidade_geral(session, outliers_combinados)
 
 
 
 
2457
 
2458
  transformacao_y_atual = str(session.transformacao_y or "(x)")
2459
  transformacoes_x_atuais = {
@@ -3124,6 +3149,40 @@ def atualizar_mapa(session: SessionState, var_mapa: str | None, modo_mapa: str |
3124
  return {"mapa_html": mapa_html, "mapa_payload": mapa_payload}
3125
 
3126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3127
  def _normalizar_extremo_abs_residuos(valor: float | None) -> float | None:
3128
  if valor is None:
3129
  return None
@@ -3209,6 +3268,7 @@ def atualizar_mapa_residuos(
3209
  cor_colors=["#2e7d32", "#f1c40f", "#ffffff", "#f1c40f", "#c62828"],
3210
  cor_tick_values=ticks_valores,
3211
  cor_tick_labels=ticks_labels,
 
3212
  )
3213
  return {
3214
  "mapa_html": mapa_html,
 
54
  formatar_micronumerosidade_html,
55
  formatar_outliers_anteriores_html,
56
  )
57
+ from app.core.visualizacao import app as visualizacao_app
58
  from app.core.visualizacao.map_payload import build_elaboracao_map_payload
59
  from app.models.session import SessionState
60
  from app.runtime_paths import resolve_core_path
 
2108
 
2109
  indices = _coletar_indices_outliers(metricas, filtros)
2110
  outliers_propostos = sorted(set(_clean_int_list(session.outliers_anteriores) + indices))
2111
+ _validar_outliers_micronumerosidade_geral(
2112
+ session,
2113
+ outliers_propostos,
2114
+ orientacao="Ajuste os filtros de outliers.",
2115
+ )
2116
  texto = ", ".join(str(i) for i in indices)
2117
  return {
2118
  "indices": indices,
 
2218
  }
2219
 
2220
 
2221
+ def _formatar_erro_micronumerosidade_geral(
2222
+ limite_info: dict[str, Any] | None,
2223
+ orientacao: str | None = None,
2224
+ ) -> str:
2225
  if not limite_info:
2226
  return "A exclusão ultrapassa a micronumerosidade mínima do modelo."
2227
  n_final = int(limite_info.get("n_final_atual") or 0)
 
2229
  k = int(limite_info.get("k") or 0)
2230
  total = int(limite_info.get("n_total") or 0)
2231
  excluidos = int(limite_info.get("excluidos_presentes") or 0)
2232
+ orientacao_final = str(orientacao or "Ajuste os filtros de outliers.").strip()
2233
  return (
2234
  "A exclusão ultrapassa a micronumerosidade mínima: "
2235
  f"restariam {n_final} observações de {total}, mas o modelo precisa manter pelo menos {minimo} "
2236
  f"(n >= 3(k+1), k={k}). "
2237
+ f"A seleção atual excluiria {excluidos} observação(ões). {orientacao_final}"
2238
  )
2239
 
2240
 
2241
+ def _validar_outliers_micronumerosidade_geral(
2242
+ session: SessionState,
2243
+ outliers: list[int],
2244
+ orientacao: str | None = None,
2245
+ ) -> None:
2246
  info = _info_micronumerosidade_geral_outliers(session, outliers)
2247
  n_final = int(info.get("n_final_atual") or 0)
2248
  minimo = int(info.get("min_observacoes") or 0)
 
2254
 
2255
  raise HTTPException(
2256
  status_code=400,
2257
+ detail=_formatar_erro_micronumerosidade_geral(info, orientacao=orientacao),
2258
  )
2259
 
2260
 
 
2371
  novos_iteracao = [idx for idx in indices_iteracao if idx not in outliers_atuais and idx not in novos_set]
2372
  if novos_iteracao:
2373
  outliers_propostos = sorted(set(outliers_atuais + novos_iteracao))
2374
+ _validar_outliers_micronumerosidade_geral(
2375
+ session,
2376
+ outliers_propostos,
2377
+ orientacao="Ajuste os filtros de outliers antes de aplicar com recursividade.",
2378
+ )
2379
  iteracoes += 1
2380
  novos_acumulados.extend(novos_iteracao)
2381
  novos_set.update(novos_iteracao)
 
2409
  break
2410
 
2411
  outliers_propostos = sorted(set(outliers_atuais + novos_iteracao))
2412
+ _validar_outliers_micronumerosidade_geral(
2413
+ session,
2414
+ outliers_propostos,
2415
+ orientacao="Ajuste os filtros de outliers antes de aplicar com recursividade.",
2416
+ )
2417
  iteracoes += 1
2418
  novos_acumulados.extend(novos_iteracao)
2419
  novos_set.update(novos_iteracao)
 
2474
 
2475
  anteriores_atualizados = [i for i in session.outliers_anteriores if i not in reincluir]
2476
  outliers_combinados = sorted(set(anteriores_atualizados + novos))
2477
+ _validar_outliers_micronumerosidade_geral(
2478
+ session,
2479
+ outliers_combinados,
2480
+ orientacao="Modifique a lista de exclusões ou ajuste os filtros de outliers.",
2481
+ )
2482
 
2483
  transformacao_y_atual = str(session.transformacao_y or "(x)")
2484
  transformacoes_x_atuais = {
 
3149
  return {"mapa_html": mapa_html, "mapa_payload": mapa_payload}
3150
 
3151
 
3152
+ def carregar_popup_ponto_mapa(session: SessionState, row_id: int, source: str | None = None) -> dict[str, Any]:
3153
+ source_norm = str(source or "mercado").strip().lower()
3154
+ if source_norm in {"residuo", "residuos", "resíduos"}:
3155
+ df = session.tabela_metricas_estado
3156
+ mensagem_vazia = "Ajuste o modelo antes de abrir os detalhes do ponto"
3157
+ popup_source = "residuos"
3158
+ else:
3159
+ df = session.df_filtrado if session.df_filtrado is not None else session.df_original
3160
+ mensagem_vazia = "Carregue dados antes de abrir os detalhes do ponto"
3161
+ popup_source = "mercado"
3162
+
3163
+ if df is None or df.empty:
3164
+ raise HTTPException(status_code=400, detail=mensagem_vazia)
3165
+
3166
+ try:
3167
+ row_id_int = int(row_id)
3168
+ except (TypeError, ValueError) as exc:
3169
+ raise HTTPException(status_code=400, detail="Identificador de ponto invalido") from exc
3170
+
3171
+ if row_id_int < 0 or row_id_int >= len(df.index):
3172
+ raise HTTPException(status_code=404, detail="Ponto nao encontrado para esta sessao")
3173
+
3174
+ row = df.iloc[row_id_int]
3175
+ popup_html, popup_width = visualizacao_app.montar_popup_registro_html(
3176
+ row,
3177
+ popup_uid=f"mesa-popup-elab-{popup_source}-{row_id_int}",
3178
+ max_itens_pagina=8,
3179
+ )
3180
+ return {
3181
+ "popup_html": popup_html,
3182
+ "popup_width": int(popup_width),
3183
+ }
3184
+
3185
+
3186
  def _normalizar_extremo_abs_residuos(valor: float | None) -> float | None:
3187
  if valor is None:
3188
  return None
 
3268
  cor_colors=["#2e7d32", "#f1c40f", "#ffffff", "#f1c40f", "#c62828"],
3269
  cor_tick_values=ticks_valores,
3270
  cor_tick_labels=ticks_labels,
3271
+ popup_source="residuos",
3272
  )
3273
  return {
3274
  "mapa_html": mapa_html,
backend/app/services/pesquisa_service.py CHANGED
@@ -25,7 +25,6 @@ from app.core.elaboracao.core import _migrar_pacote_v1_para_v2, normalizar_obser
25
  from app.core.map_layers import (
26
  add_bairros_layer,
27
  add_marker_payloads,
28
- apply_marker_payload_jitter,
29
  build_trabalhos_tecnicos_marker_payloads,
30
  add_zoom_responsive_circle_markers,
31
  )
@@ -922,6 +921,7 @@ def gerar_mapa_modelos(
922
  avaliando_lon: float | None = None,
923
  avaliandos: list[dict[str, Any]] | None = None,
924
  modo_exibicao: str | None = "pontos",
 
925
  criterio_espacial: str | None = CRITERIO_ESPACIAL_PADRAO,
926
  trabalhos_tecnicos_modelos_modo: str | None = TRABALHOS_TECNICOS_MODELOS_SELECIONADOS,
927
  trabalhos_tecnicos_proximidade_modo: str | None = TRABALHOS_TECNICOS_PROXIMIDADE_DESATIVADA,
@@ -949,6 +949,7 @@ def gerar_mapa_modelos(
949
  raise HTTPException(status_code=404, detail="Nenhum modelo selecionado foi encontrado na pasta de pesquisa")
950
 
951
  modo_exibicao_norm = _normalizar_modo_exibicao_mapa(modo_exibicao)
 
952
  trabalhos_tecnicos_modelos_modo_norm = _normalizar_modo_trabalhos_tecnicos_modelos_mapa(trabalhos_tecnicos_modelos_modo)
953
  trabalhos_tecnicos_proximidade_modo_norm = _normalizar_modo_trabalhos_tecnicos_proximidade_mapa(
954
  trabalhos_tecnicos_proximidade_modo
@@ -1069,6 +1070,7 @@ def gerar_mapa_modelos(
1069
  bounds,
1070
  avaliandos_geo,
1071
  modo_exibicao_norm,
 
1072
  avaliandos_tecnicos_proximos=avaliandos_tecnicos_proximos,
1073
  trabalhos_tecnicos_raio_m=trabalhos_tecnicos_raio_m_norm,
1074
  )
@@ -1135,6 +1137,7 @@ def gerar_mapa_modelos(
1135
  "avaliandos": avaliandos_geo,
1136
  "criterio_espacial": criterio_espacial_norm,
1137
  "modo_exibicao": modo_exibicao_norm,
 
1138
  "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
1139
  "trabalhos_tecnicos_proximidade_modo": trabalhos_tecnicos_proximidade_modo_norm,
1140
  "trabalhos_tecnicos_raio_m": trabalhos_tecnicos_raio_m_norm,
@@ -1160,6 +1163,13 @@ def _normalizar_modo_exibicao_mapa(value: Any) -> str:
1160
  return "pontos"
1161
 
1162
 
 
 
 
 
 
 
 
1163
  def _normalizar_modo_trabalhos_tecnicos_modelos_mapa(value: Any) -> str:
1164
  modo = str(value or "").strip().lower()
1165
  if modo in {
@@ -1217,6 +1227,95 @@ def _tooltip_mapa_modelo_html(modelo: dict[str, Any]) -> str:
1217
  )
1218
 
1219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1220
  def _marker_payloads_avaliandos(avaliandos_geo: list[dict[str, Any]]) -> list[dict[str, Any]]:
1221
  payloads: list[dict[str, Any]] = []
1222
  for idx, avaliando in enumerate(avaliandos_geo):
@@ -1335,6 +1434,7 @@ def _build_mapa_modelos_payload(
1335
  bounds: list[list[float]],
1336
  avaliandos_geo: list[dict[str, Any]],
1337
  modo_exibicao: str,
 
1338
  avaliandos_tecnicos_proximos: list[dict[str, Any]] | None = None,
1339
  trabalhos_tecnicos_raio_m: int | None = None,
1340
  ) -> dict[str, Any] | None:
@@ -1362,8 +1462,11 @@ def _build_mapa_modelos_payload(
1362
  if avaliandos_tecnicos_proximos is not None
1363
  else []
1364
  )
1365
- if trabalhos_tecnicos_modelos_markers or trabalhos_tecnicos_proximos_markers:
1366
- apply_marker_payload_jitter([*trabalhos_tecnicos_modelos_markers, *trabalhos_tecnicos_proximos_markers])
 
 
 
1367
 
1368
  for modelo in modelos_plotados:
1369
  layer: dict[str, Any] = {
@@ -1373,20 +1476,11 @@ def _build_mapa_modelos_payload(
1373
  "hover_highlight_group": "pesquisa-modelos",
1374
  }
1375
  if modo_exibicao == "pontos":
1376
- tooltip_html = _tooltip_mapa_modelo_html(modelo)
1377
- layer["points"] = [
1378
- {
1379
- "lat": float(ponto["lat"]),
1380
- "lon": float(ponto["lon"]),
1381
- "color": str(modelo.get("cor") or "#1f77b4"),
1382
- "base_radius": 3.0,
1383
- "stroke_color": str(modelo.get("cor") or "#1f77b4"),
1384
- "stroke_weight": 1.0,
1385
- "fill_opacity": 0.72,
1386
- "tooltip_html": tooltip_html,
1387
- }
1388
- for ponto in (modelo.get("pontos") or [])
1389
- ]
1390
  else:
1391
  layer["shapes"] = _shapes_modelo_payload(modelo, aval_lat, aval_lon)
1392
  overlay_layers.append(layer)
@@ -1478,8 +1572,8 @@ def _renderizar_mapa_modelos(
1478
  control_scale=True,
1479
  tiles=None,
1480
  )
1481
- folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(mapa)
1482
- folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
1483
  add_bairros_layer(mapa, show=True)
1484
  nome_camada_avaliando = "Avaliando" if len(avaliandos_geo) == 1 else "Avaliandos"
1485
  camada_avaliando = folium.FeatureGroup(name=nome_camada_avaliando, show=True)
@@ -1503,8 +1597,6 @@ def _renderizar_mapa_modelos(
1503
  if avaliandos_tecnicos_proximos is not None
1504
  else []
1505
  )
1506
- if trabalhos_tecnicos_modelos_markers or trabalhos_tecnicos_proximos_markers:
1507
- apply_marker_payload_jitter([*trabalhos_tecnicos_modelos_markers, *trabalhos_tecnicos_proximos_markers])
1508
  camada_trabalhos_modelos = (
1509
  folium.FeatureGroup(name="Trabalhos tecnicos dos modelos", show=True)
1510
  if trabalhos_tecnicos_modelos
@@ -4415,13 +4507,15 @@ def _parse_datetime(texto: str) -> datetime | None:
4415
 
4416
 
4417
  def _contains_any(candidatos: list[Any], consulta: str) -> bool:
4418
- alvo = _normalize(consulta)
4419
- if not alvo:
 
4420
  return True
4421
  for item in candidatos:
4422
  if item is None:
4423
  continue
4424
- if alvo in _normalize(str(item)):
 
4425
  return True
4426
  return False
4427
 
 
25
  from app.core.map_layers import (
26
  add_bairros_layer,
27
  add_marker_payloads,
 
28
  build_trabalhos_tecnicos_marker_payloads,
29
  add_zoom_responsive_circle_markers,
30
  )
 
921
  avaliando_lon: float | None = None,
922
  avaliandos: list[dict[str, Any]] | None = None,
923
  modo_exibicao: str | None = "pontos",
924
+ agrupar_pontos_mercado: Any = False,
925
  criterio_espacial: str | None = CRITERIO_ESPACIAL_PADRAO,
926
  trabalhos_tecnicos_modelos_modo: str | None = TRABALHOS_TECNICOS_MODELOS_SELECIONADOS,
927
  trabalhos_tecnicos_proximidade_modo: str | None = TRABALHOS_TECNICOS_PROXIMIDADE_DESATIVADA,
 
949
  raise HTTPException(status_code=404, detail="Nenhum modelo selecionado foi encontrado na pasta de pesquisa")
950
 
951
  modo_exibicao_norm = _normalizar_modo_exibicao_mapa(modo_exibicao)
952
+ agrupar_pontos_mercado_norm = _normalizar_agrupar_pontos_mercado_mapa(agrupar_pontos_mercado)
953
  trabalhos_tecnicos_modelos_modo_norm = _normalizar_modo_trabalhos_tecnicos_modelos_mapa(trabalhos_tecnicos_modelos_modo)
954
  trabalhos_tecnicos_proximidade_modo_norm = _normalizar_modo_trabalhos_tecnicos_proximidade_mapa(
955
  trabalhos_tecnicos_proximidade_modo
 
1070
  bounds,
1071
  avaliandos_geo,
1072
  modo_exibicao_norm,
1073
+ agrupar_pontos_mercado=agrupar_pontos_mercado_norm,
1074
  avaliandos_tecnicos_proximos=avaliandos_tecnicos_proximos,
1075
  trabalhos_tecnicos_raio_m=trabalhos_tecnicos_raio_m_norm,
1076
  )
 
1137
  "avaliandos": avaliandos_geo,
1138
  "criterio_espacial": criterio_espacial_norm,
1139
  "modo_exibicao": modo_exibicao_norm,
1140
+ "agrupar_pontos_mercado": agrupar_pontos_mercado_norm,
1141
  "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
1142
  "trabalhos_tecnicos_proximidade_modo": trabalhos_tecnicos_proximidade_modo_norm,
1143
  "trabalhos_tecnicos_raio_m": trabalhos_tecnicos_raio_m_norm,
 
1163
  return "pontos"
1164
 
1165
 
1166
+ def _normalizar_agrupar_pontos_mercado_mapa(value: Any) -> bool:
1167
+ if isinstance(value, bool):
1168
+ return value
1169
+ texto = _normalize(str(value or "")).strip()
1170
+ return texto in {"1", "true", "sim", "s", "yes", "y", "agrupar", "agrupado", "agrupados"}
1171
+
1172
+
1173
  def _normalizar_modo_trabalhos_tecnicos_modelos_mapa(value: Any) -> str:
1174
  modo = str(value or "").strip().lower()
1175
  if modo in {
 
1227
  )
1228
 
1229
 
1230
+ def _tooltip_mapa_modelo_ponto_html(modelo: dict[str, Any], total_no_local: int) -> str:
1231
+ tooltip = _tooltip_mapa_modelo_html(modelo)
1232
+ if total_no_local <= 1:
1233
+ return tooltip
1234
+ detalhe = f"<br><span style='color:#555;'>Dados neste local:</span> <b>{total_no_local}</b>"
1235
+ if tooltip.endswith("</div>"):
1236
+ return f"{tooltip[:-6]}{detalhe}</div>"
1237
+ return f"{tooltip}{detalhe}"
1238
+
1239
+
1240
+ def _chave_coordenada_ponto(ponto: dict[str, Any], precisao: int = 7) -> tuple[float, float] | None:
1241
+ try:
1242
+ lat = float(ponto["lat"])
1243
+ lon = float(ponto["lon"])
1244
+ except (KeyError, TypeError, ValueError):
1245
+ return None
1246
+ if not math.isfinite(lat) or not math.isfinite(lon):
1247
+ return None
1248
+ return (round(lat, precisao), round(lon, precisao))
1249
+
1250
+
1251
+ def _contagens_coordenadas_modelos(modelos_plotados: list[dict[str, Any]]) -> dict[tuple[float, float], int]:
1252
+ contagens: dict[tuple[float, float], int] = {}
1253
+ for modelo in modelos_plotados:
1254
+ for ponto in modelo.get("pontos") or []:
1255
+ chave = _chave_coordenada_ponto(ponto)
1256
+ if chave is None:
1257
+ continue
1258
+ contagens[chave] = contagens.get(chave, 0) + 1
1259
+ return contagens
1260
+
1261
+
1262
+ def _ponto_mapa_modelo_payload_item(
1263
+ modelo: dict[str, Any],
1264
+ ponto: dict[str, Any],
1265
+ total_no_local: int = 1,
1266
+ ) -> dict[str, Any]:
1267
+ cor = str(modelo.get("cor") or "#1f77b4")
1268
+ item: dict[str, Any] = {
1269
+ "lat": float(ponto["lat"]),
1270
+ "lon": float(ponto["lon"]),
1271
+ "color": cor,
1272
+ "base_radius": 3.0,
1273
+ "stroke_color": cor,
1274
+ "stroke_weight": 1.0,
1275
+ "fill_opacity": 0.72,
1276
+ "tooltip_html": _tooltip_mapa_modelo_ponto_html(modelo, total_no_local),
1277
+ }
1278
+ if total_no_local > 1:
1279
+ item.update(
1280
+ {
1281
+ "grouped": True,
1282
+ "count": total_no_local,
1283
+ "group_title": f"{total_no_local} dados neste local",
1284
+ "pane": "mesa-market-group-pane",
1285
+ "tooltip_sticky": False,
1286
+ }
1287
+ )
1288
+ return item
1289
+
1290
+
1291
+ def _pontos_mapa_modelo_payload(
1292
+ modelo: dict[str, Any],
1293
+ contagens_coordenadas: dict[tuple[float, float], int] | None = None,
1294
+ agrupar_pontos_mercado: bool = False,
1295
+ ) -> list[dict[str, Any]]:
1296
+ if not agrupar_pontos_mercado:
1297
+ pontos_payload: list[dict[str, Any]] = []
1298
+ for ponto in modelo.get("pontos") or []:
1299
+ if _chave_coordenada_ponto(ponto) is None:
1300
+ continue
1301
+ pontos_payload.append(_ponto_mapa_modelo_payload_item(modelo, ponto))
1302
+ return pontos_payload
1303
+
1304
+ grupos: dict[tuple[float, float], list[dict[str, Any]]] = {}
1305
+ for ponto in modelo.get("pontos") or []:
1306
+ chave = _chave_coordenada_ponto(ponto)
1307
+ if chave is None:
1308
+ continue
1309
+ grupos.setdefault(chave, []).append(ponto)
1310
+
1311
+ pontos_payload: list[dict[str, Any]] = []
1312
+ for chave, pontos_no_local in grupos.items():
1313
+ ponto = pontos_no_local[0]
1314
+ total_no_local = max(len(pontos_no_local), int((contagens_coordenadas or {}).get(chave) or 0))
1315
+ pontos_payload.append(_ponto_mapa_modelo_payload_item(modelo, ponto, total_no_local))
1316
+ return pontos_payload
1317
+
1318
+
1319
  def _marker_payloads_avaliandos(avaliandos_geo: list[dict[str, Any]]) -> list[dict[str, Any]]:
1320
  payloads: list[dict[str, Any]] = []
1321
  for idx, avaliando in enumerate(avaliandos_geo):
 
1434
  bounds: list[list[float]],
1435
  avaliandos_geo: list[dict[str, Any]],
1436
  modo_exibicao: str,
1437
+ agrupar_pontos_mercado: bool = False,
1438
  avaliandos_tecnicos_proximos: list[dict[str, Any]] | None = None,
1439
  trabalhos_tecnicos_raio_m: int | None = None,
1440
  ) -> dict[str, Any] | None:
 
1462
  if avaliandos_tecnicos_proximos is not None
1463
  else []
1464
  )
1465
+ contagens_pontos = (
1466
+ _contagens_coordenadas_modelos(modelos_plotados)
1467
+ if modo_exibicao == "pontos" and agrupar_pontos_mercado
1468
+ else {}
1469
+ )
1470
 
1471
  for modelo in modelos_plotados:
1472
  layer: dict[str, Any] = {
 
1476
  "hover_highlight_group": "pesquisa-modelos",
1477
  }
1478
  if modo_exibicao == "pontos":
1479
+ layer["points"] = _pontos_mapa_modelo_payload(
1480
+ modelo,
1481
+ contagens_pontos,
1482
+ agrupar_pontos_mercado=agrupar_pontos_mercado,
1483
+ )
 
 
 
 
 
 
 
 
 
1484
  else:
1485
  layer["shapes"] = _shapes_modelo_payload(modelo, aval_lat, aval_lon)
1486
  overlay_layers.append(layer)
 
1572
  control_scale=True,
1573
  tiles=None,
1574
  )
1575
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=True).add_to(mapa)
1576
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=False).add_to(mapa)
1577
  add_bairros_layer(mapa, show=True)
1578
  nome_camada_avaliando = "Avaliando" if len(avaliandos_geo) == 1 else "Avaliandos"
1579
  camada_avaliando = folium.FeatureGroup(name=nome_camada_avaliando, show=True)
 
1597
  if avaliandos_tecnicos_proximos is not None
1598
  else []
1599
  )
 
 
1600
  camada_trabalhos_modelos = (
1601
  folium.FeatureGroup(name="Trabalhos tecnicos dos modelos", show=True)
1602
  if trabalhos_tecnicos_modelos
 
4507
 
4508
 
4509
  def _contains_any(candidatos: list[Any], consulta: str) -> bool:
4510
+ alvos = [_normalize(termo) for termo in re.split(r"\s*\|\|\s*|[;|]", str(consulta or ""))]
4511
+ alvos = [alvo for alvo in alvos if alvo]
4512
+ if not alvos:
4513
  return True
4514
  for item in candidatos:
4515
  if item is None:
4516
  continue
4517
+ item_norm = _normalize(str(item))
4518
+ if any(alvo in item_norm for alvo in alvos):
4519
  return True
4520
  return False
4521
 
backend/app/services/trabalhos_tecnicos_service.py CHANGED
@@ -1539,8 +1539,8 @@ def gerar_mapa_trabalhos(
1539
  prefer_canvas=True,
1540
  control_scale=True,
1541
  )
1542
- folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(mapa)
1543
- folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
1544
  add_bairros_layer(mapa, show=True)
1545
 
1546
  camada = folium.FeatureGroup(name="Trabalhos tecnicos", show=True)
@@ -1631,8 +1631,8 @@ def _criar_mapa_trabalho(nome_trabalho: str, imoveis: list[dict[str, Any]]) -> s
1631
  prefer_canvas=True,
1632
  control_scale=True,
1633
  )
1634
- folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(mapa)
1635
- folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
1636
  add_bairros_layer(mapa, show=True)
1637
 
1638
  camada = folium.FeatureGroup(name="Imoveis do trabalho", show=True)
 
1539
  prefer_canvas=True,
1540
  control_scale=True,
1541
  )
1542
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=True).add_to(mapa)
1543
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=False).add_to(mapa)
1544
  add_bairros_layer(mapa, show=True)
1545
 
1546
  camada = folium.FeatureGroup(name="Trabalhos tecnicos", show=True)
 
1631
  prefer_canvas=True,
1632
  control_scale=True,
1633
  )
1634
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=True).add_to(mapa)
1635
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=False).add_to(mapa)
1636
  add_bairros_layer(mapa, show=True)
1637
 
1638
  camada = folium.FeatureGroup(name="Imoveis do trabalho", show=True)
backend/app/services/visualizacao_service.py CHANGED
@@ -1,5 +1,7 @@
1
  from __future__ import annotations
2
 
 
 
3
  from html import escape
4
  from pathlib import Path
5
  from time import sleep
@@ -14,6 +16,7 @@ from folium import plugins
14
  from joblib import load
15
 
16
  from app.core.visualizacao import app as viz_app
 
17
  from app.core.map_layers import (
18
  add_bairros_layer,
19
  add_popup_pagination_handlers,
@@ -87,6 +90,98 @@ def _to_dataframe(value: Any) -> pd.DataFrame | None:
87
  return None
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  def _is_rh_col(coluna: str) -> bool:
91
  return str(coluna or "").strip().upper() == "RH"
92
 
@@ -247,8 +342,8 @@ def _criar_mapa_knn_destaque(
247
  prefer_canvas=True,
248
  control_scale=True,
249
  )
250
- folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=True).add_to(mapa)
251
- folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=False).add_to(mapa)
252
  add_bairros_layer(mapa, show=True)
253
 
254
  posicoes_set = {int(v) for v in (posicoes_knn or [])}
@@ -621,7 +716,7 @@ def _aplicar_jitter_pontos_knn(dados: pd.DataFrame) -> pd.DataFrame:
621
  if dados is None or dados.empty:
622
  return dados
623
  try:
624
- return viz_app._aplicar_jitter_sobrepostos(
625
  dados,
626
  lat_col="__lat__",
627
  lon_col="__lon__",
@@ -711,7 +806,7 @@ def _criar_payload_mapa_avaliacao_localizacao(
711
  return None
712
 
713
  try:
714
- dados_plot = viz_app._aplicar_jitter_sobrepostos(
715
  dados,
716
  lat_col="__lat__",
717
  lon_col="__lon__",
@@ -1118,12 +1213,21 @@ def _payload_modelo_graficos(session: SessionState) -> dict[str, Any]:
1118
  return cached
1119
 
1120
  figs = viz_app.gerar_todos_graficos(session.pacote_visualizacao)
 
 
 
 
 
1121
  payload = {
1122
- "grafico_obs_calc": figure_to_payload(figs.get("obs_calc")),
1123
- "grafico_residuos": figure_to_payload(figs.get("residuos")),
1124
- "grafico_histograma": figure_to_payload(figs.get("hist")),
1125
- "grafico_cook": figure_to_payload(figs.get("cook")),
1126
- "grafico_correlacao": figure_to_payload(figs.get("corr")),
 
 
 
 
1127
  }
1128
  tabs_cache["graficos"] = payload
1129
  return payload
 
1
  from __future__ import annotations
2
 
3
+ import base64
4
+ import json
5
  from html import escape
6
  from pathlib import Path
7
  from time import sleep
 
16
  from joblib import load
17
 
18
  from app.core.visualizacao import app as viz_app
19
+ from app.core.map_jitter import aplicar_jitter_dados_mercado
20
  from app.core.map_layers import (
21
  add_bairros_layer,
22
  add_popup_pagination_handlers,
 
90
  return None
91
 
92
 
93
+ def _trace_mode_includes_markers(mode: Any) -> bool:
94
+ return "markers" in str(mode or "").strip().lower()
95
+
96
+
97
+ def _extrair_sequencia_payload(valores: Any) -> list[Any]:
98
+ if isinstance(valores, list):
99
+ return valores
100
+ if isinstance(valores, tuple):
101
+ return list(valores)
102
+ if isinstance(valores, dict):
103
+ dtype_text = str(valores.get("dtype") or "").strip()
104
+ bdata_text = str(valores.get("bdata") or "").strip()
105
+ if dtype_text and bdata_text:
106
+ try:
107
+ buffer = base64.b64decode(bdata_text)
108
+ array = np.frombuffer(buffer, dtype=np.dtype(dtype_text))
109
+ return array.tolist()
110
+ except Exception:
111
+ return []
112
+ return []
113
+ try:
114
+ return list(valores)
115
+ except Exception:
116
+ return []
117
+
118
+
119
+ def _coletar_rotulos_indices_trace_payload(trace: dict[str, Any] | None) -> list[str]:
120
+ if not isinstance(trace, dict):
121
+ return []
122
+ if not _trace_mode_includes_markers(trace.get("mode")):
123
+ return []
124
+
125
+ for source_key in ("customdata", "ids", "text"):
126
+ valores = _extrair_sequencia_payload(trace.get(source_key))
127
+ if not valores:
128
+ continue
129
+ rotulos: list[str] = []
130
+ for item in valores:
131
+ if isinstance(item, (list, tuple)):
132
+ texto = next((str(sub).strip() for sub in item if str(sub).strip()), "")
133
+ elif isinstance(item, dict):
134
+ texto = str(
135
+ item.get("indice")
136
+ or item.get("Indice")
137
+ or item.get("Índice")
138
+ or "",
139
+ ).strip()
140
+ else:
141
+ texto = str(item or "").strip()
142
+ rotulos.append(texto)
143
+ if any(rotulos):
144
+ return rotulos
145
+ return []
146
+
147
+
148
+ def _payload_grafico_com_indices(payload: Any) -> dict[str, Any] | None:
149
+ payload_sanitizado = sanitize_value(payload)
150
+ if not isinstance(payload_sanitizado, dict):
151
+ return None
152
+
153
+ clone = json.loads(json.dumps(payload_sanitizado))
154
+ data = clone.get("data")
155
+ if not isinstance(data, list):
156
+ return None
157
+
158
+ alterado = False
159
+ for trace in data:
160
+ if not isinstance(trace, dict):
161
+ continue
162
+ rotulos = _coletar_rotulos_indices_trace_payload(trace)
163
+ if not rotulos:
164
+ continue
165
+ mode_parts = [part.strip() for part in str(trace.get("mode") or "markers").split("+") if part.strip()]
166
+ if "text" not in mode_parts:
167
+ mode_parts.append("text")
168
+ if "markers" not in mode_parts:
169
+ mode_parts.append("markers")
170
+ trace["mode"] = "+".join(mode_parts)
171
+ trace["text"] = rotulos
172
+ trace["textposition"] = trace.get("textposition") or "top center"
173
+ base_textfont = trace.get("textfont") if isinstance(trace.get("textfont"), dict) else {}
174
+ trace["textfont"] = {
175
+ "size": 10,
176
+ "color": "#243746",
177
+ **base_textfont,
178
+ }
179
+ trace["cliponaxis"] = False
180
+ alterado = True
181
+
182
+ return clone if alterado else None
183
+
184
+
185
  def _is_rh_col(coluna: str) -> bool:
186
  return str(coluna or "").strip().upper() == "RH"
187
 
 
342
  prefer_canvas=True,
343
  control_scale=True,
344
  )
345
+ folium.TileLayer(tiles="CartoDB positron", name="Positron", control=True, show=True).add_to(mapa)
346
+ folium.TileLayer(tiles="OpenStreetMap", name="OpenStreetMap", control=True, show=False).add_to(mapa)
347
  add_bairros_layer(mapa, show=True)
348
 
349
  posicoes_set = {int(v) for v in (posicoes_knn or [])}
 
716
  if dados is None or dados.empty:
717
  return dados
718
  try:
719
+ return aplicar_jitter_dados_mercado(
720
  dados,
721
  lat_col="__lat__",
722
  lon_col="__lon__",
 
806
  return None
807
 
808
  try:
809
+ dados_plot = aplicar_jitter_dados_mercado(
810
  dados,
811
  lat_col="__lat__",
812
  lon_col="__lon__",
 
1213
  return cached
1214
 
1215
  figs = viz_app.gerar_todos_graficos(session.pacote_visualizacao)
1216
+ grafico_obs_calc = figure_to_payload(figs.get("obs_calc"))
1217
+ grafico_residuos = figure_to_payload(figs.get("residuos"))
1218
+ grafico_histograma = figure_to_payload(figs.get("hist"))
1219
+ grafico_cook = figure_to_payload(figs.get("cook"))
1220
+ grafico_correlacao = figure_to_payload(figs.get("corr"))
1221
  payload = {
1222
+ "grafico_obs_calc": grafico_obs_calc,
1223
+ "grafico_obs_calc_com_indices": _payload_grafico_com_indices(grafico_obs_calc),
1224
+ "grafico_residuos": grafico_residuos,
1225
+ "grafico_residuos_com_indices": _payload_grafico_com_indices(grafico_residuos),
1226
+ "grafico_histograma": grafico_histograma,
1227
+ "grafico_histograma_com_indices": _payload_grafico_com_indices(grafico_histograma),
1228
+ "grafico_cook": grafico_cook,
1229
+ "grafico_cook_com_indices": _payload_grafico_com_indices(grafico_cook),
1230
+ "grafico_correlacao": grafico_correlacao,
1231
  }
1232
  tabs_cache["graficos"] = payload
1233
  return payload
frontend/src/api.js CHANGED
@@ -210,6 +210,7 @@ export const api = {
210
  trabalhosTecnicosModelosModo = 'selecionados',
211
  trabalhosTecnicosProximidadeModo = 'sem_proximidade',
212
  trabalhosTecnicosRaioM = 1000,
 
213
  ) {
214
  const avaliandos = Array.isArray(avaliando) ? avaliando : []
215
  const avaliandoUnico = !avaliandos.length && avaliando && typeof avaliando === 'object' ? avaliando : null
@@ -223,6 +224,7 @@ export const api = {
223
  trabalhos_tecnicos_modelos_modo: trabalhosTecnicosModelosModo,
224
  trabalhos_tecnicos_proximidade_modo: trabalhosTecnicosProximidadeModo,
225
  trabalhos_tecnicos_raio_m: trabalhosTecnicosRaioM,
 
226
  })
227
  },
228
 
 
210
  trabalhosTecnicosModelosModo = 'selecionados',
211
  trabalhosTecnicosProximidadeModo = 'sem_proximidade',
212
  trabalhosTecnicosRaioM = 1000,
213
+ agruparPontosMercado = false,
214
  ) {
215
  const avaliandos = Array.isArray(avaliando) ? avaliando : []
216
  const avaliandoUnico = !avaliandos.length && avaliando && typeof avaliando === 'object' ? avaliando : null
 
224
  trabalhos_tecnicos_modelos_modo: trabalhosTecnicosModelosModo,
225
  trabalhos_tecnicos_proximidade_modo: trabalhosTecnicosProximidadeModo,
226
  trabalhos_tecnicos_raio_m: trabalhosTecnicosRaioM,
227
+ agrupar_pontos_mercado: Boolean(agruparPontosMercado),
228
  })
229
  },
230
 
frontend/src/components/AvaliacaoTab.jsx CHANGED
@@ -596,6 +596,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
596
  const [confirmarLimpezaAvaliacoes, setConfirmarLimpezaAvaliacoes] = useState(false)
597
  const [confirmarExclusaoCardId, setConfirmarExclusaoCardId] = useState('')
598
  const [avaliacaoPopup, setAvaliacaoPopup] = useState(null)
 
599
  const [knnDetalheAberto, setKnnDetalheAberto] = useState(false)
600
  const [knnDetalheLoading, setKnnDetalheLoading] = useState(false)
601
  const [knnDetalheErro, setKnnDetalheErro] = useState('')
@@ -1230,6 +1231,20 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
1230
  setAvaliacaoPopup(null)
1231
  }
1232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1233
  async function onAbrirDetalheKnn(card, indice) {
1234
  if (!sessionId || !card?.avaliacao?.valores_x) return
1235
  setKnnDetalheAberto(true)
@@ -1955,6 +1970,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
1955
  onMouseLeave={onPopupLeave}
1956
  onFocus={(event) => onPopupEnter(event, popupPrecisaoHtml(aval))}
1957
  onBlur={onPopupLeave}
 
1958
  >
1959
 
1960
  </button>
@@ -1972,6 +1988,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
1972
  onMouseLeave={onPopupLeave}
1973
  onFocus={(event) => onPopupEnter(event, popupFundamentacaoHtml(aval))}
1974
  onBlur={onPopupLeave}
 
1975
  >
1976
 
1977
  </button>
@@ -2001,6 +2018,27 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
2001
  dangerouslySetInnerHTML={{ __html: avaliacaoPopup.html }}
2002
  />
2003
  ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2004
  {avaliandoMapaAberto ? (
2005
  <div className="pesquisa-modal-backdrop" onClick={(event) => {
2006
  if (event.target === event.currentTarget) onFecharMapaAvaliando()
 
596
  const [confirmarLimpezaAvaliacoes, setConfirmarLimpezaAvaliacoes] = useState(false)
597
  const [confirmarExclusaoCardId, setConfirmarExclusaoCardId] = useState('')
598
  const [avaliacaoPopup, setAvaliacaoPopup] = useState(null)
599
+ const [avaliacaoInfoModal, setAvaliacaoInfoModal] = useState(null)
600
  const [knnDetalheAberto, setKnnDetalheAberto] = useState(false)
601
  const [knnDetalheLoading, setKnnDetalheLoading] = useState(false)
602
  const [knnDetalheErro, setKnnDetalheErro] = useState('')
 
1231
  setAvaliacaoPopup(null)
1232
  }
1233
 
1234
+ function onAbrirInfoAvaliacao(titulo, html) {
1235
+ const conteudo = String(html || '').trim()
1236
+ if (!conteudo) return
1237
+ setAvaliacaoPopup(null)
1238
+ setAvaliacaoInfoModal({
1239
+ titulo: String(titulo || 'Detalhes do enquadramento'),
1240
+ html: conteudo,
1241
+ })
1242
+ }
1243
+
1244
+ function onFecharInfoAvaliacao() {
1245
+ setAvaliacaoInfoModal(null)
1246
+ }
1247
+
1248
  async function onAbrirDetalheKnn(card, indice) {
1249
  if (!sessionId || !card?.avaliacao?.valores_x) return
1250
  setKnnDetalheAberto(true)
 
1970
  onMouseLeave={onPopupLeave}
1971
  onFocus={(event) => onPopupEnter(event, popupPrecisaoHtml(aval))}
1972
  onBlur={onPopupLeave}
1973
+ onClick={() => onAbrirInfoAvaliacao('Detalhes da precisão', popupPrecisaoHtml(aval))}
1974
  >
1975
 
1976
  </button>
 
1988
  onMouseLeave={onPopupLeave}
1989
  onFocus={(event) => onPopupEnter(event, popupFundamentacaoHtml(aval))}
1990
  onBlur={onPopupLeave}
1991
+ onClick={() => onAbrirInfoAvaliacao('Detalhes da fundamentação', popupFundamentacaoHtml(aval))}
1992
  >
1993
 
1994
  </button>
 
2018
  dangerouslySetInnerHTML={{ __html: avaliacaoPopup.html }}
2019
  />
2020
  ) : null}
2021
+ {avaliacaoInfoModal?.html ? (
2022
+ <div className="pesquisa-modal-backdrop" onClick={(event) => {
2023
+ if (event.target === event.currentTarget) onFecharInfoAvaliacao()
2024
+ }}
2025
+ >
2026
+ <div className="pesquisa-modal avaliacao-info-modal">
2027
+ <div className="pesquisa-modal-head">
2028
+ <div>
2029
+ <h4>{avaliacaoInfoModal.titulo || 'Detalhes do enquadramento'}</h4>
2030
+ </div>
2031
+ <button type="button" className="pesquisa-modal-close" onClick={onFecharInfoAvaliacao}>
2032
+ Fechar
2033
+ </button>
2034
+ </div>
2035
+ <div
2036
+ className="pesquisa-modal-body avaliacao-info-modal-body"
2037
+ dangerouslySetInnerHTML={{ __html: avaliacaoInfoModal.html }}
2038
+ />
2039
+ </div>
2040
+ </div>
2041
+ ) : null}
2042
  {avaliandoMapaAberto ? (
2043
  <div className="pesquisa-modal-backdrop" onClick={(event) => {
2044
  if (event.target === event.currentTarget) onFecharMapaAvaliando()
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -240,13 +240,56 @@ function formatCurrencyBr(value) {
240
  return num.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
241
  }
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  function Section14MetricRows({ rows }) {
244
  return (
245
  <div className="section14-field-grid">
246
  {(rows || []).map((row) => (
247
  <div key={row.label} className="section14-field-row">
248
  <span className="section14-field-label">{row.label}</span>
249
- <span className="section14-field-value">{row.value}</span>
250
  </div>
251
  ))}
252
  </div>
@@ -1656,6 +1699,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
1656
  const [tabelaOutliersExcluidos, setTabelaOutliersExcluidos] = useState(null)
1657
  const [outlierLimitWarning, setOutlierLimitWarning] = useState('')
1658
  const [outlierUpdateWarning, setOutlierUpdateWarning] = useState('')
 
1659
 
1660
  const [camposAvaliacao, setCamposAvaliacao] = useState([])
1661
  const valoresAvaliacaoRef = useRef({})
@@ -2019,7 +2063,11 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2019
  id: 'curva',
2020
  label: 'Curva normal',
2021
  rows: [
2022
- { label: 'Percentuais atingidos', value: diagnosticos.perc_resid || '-' },
 
 
 
 
2023
  { label: 'Ideal 68%', value: 'aceitável entre 64% e 75%' },
2024
  { label: 'Ideal 90%', value: 'aceitável entre 88% e 95%' },
2025
  { label: 'Ideal 95%', value: 'aceitável entre 95% e 100%' },
@@ -3100,6 +3148,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3100
  setTabelaOutliersExcluidos(null)
3101
  setOutlierLimitWarning('')
3102
  setOutlierUpdateWarning('')
 
3103
  setOutliersAnteriores([])
3104
  setIteracao(1)
3105
  setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
@@ -3231,6 +3280,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3231
  setTabelaOutliersExcluidos(null)
3232
  setOutlierLimitWarning('')
3233
  setOutlierUpdateWarning('')
 
3234
  setCamposAvaliacao([])
3235
  valoresAvaliacaoRef.current = {}
3236
  setAvaliacaoFormVersion((prev) => prev + 1)
@@ -3328,6 +3378,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3328
  setFit(resp)
3329
  setOutlierLimitWarning('')
3330
  setOutlierUpdateWarning('')
 
3331
  setSecao13InterativoFigura(null)
3332
  setSecao13InterativoFiguraComIndices(null)
3333
  setSecao13InterativoSelecionado('none')
@@ -3497,6 +3548,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3497
  setTabelaOutliersExcluidos(null)
3498
  setOutlierLimitWarning('')
3499
  setOutlierUpdateWarning('')
 
3500
  setOutliersAnteriores([])
3501
  setIteracao(1)
3502
  setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
@@ -3881,6 +3933,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3881
  setTabelaOutliersExcluidos(null)
3882
  setOutlierLimitWarning('')
3883
  setOutlierUpdateWarning('')
 
3884
  setOutliersTexto('')
3885
  setReincluirTexto('')
3886
  setBaseChoices([])
@@ -4034,6 +4087,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4034
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
4035
  setOutlierLimitWarning('')
4036
  setOutlierUpdateWarning('')
 
4037
  setCamposAvaliacao([])
4038
  valoresAvaliacaoRef.current = {}
4039
  setAvaliacaoFormVersion((prev) => prev + 1)
@@ -4210,6 +4264,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4210
  if (!sessionId) return
4211
  setOutlierLimitWarning('')
4212
  setOutlierUpdateWarning('')
 
4213
  await withBusy(
4214
  async () => {
4215
  const filtrosValidos = (filtros || [])
@@ -4241,6 +4296,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4241
  if (!sessionId) return
4242
  setOutlierLimitWarning('')
4243
  setOutlierUpdateWarning('')
 
4244
  await withBusy(
4245
  async () => {
4246
  const filtrosValidos = (filtros || [])
@@ -4328,6 +4384,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4328
  if (!sessionId) return
4329
  setOutlierLimitWarning('')
4330
  setOutlierUpdateWarning('')
 
4331
  await withBusy(
4332
  async () => {
4333
  const resp = await api.restartOutlierIteration(sessionId, outliersInput, reincluirInput, grauCoef, grauF)
@@ -4350,6 +4407,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4350
  mensagemRegressao ||
4351
  'Outliers atualizados, mas a regressão não fechou. Reinclua dados ou ajuste os filtros antes de continuar.',
4352
  )
 
4353
  setSection14Tab('diagnosticos')
4354
  if (typeof window !== 'undefined') {
4355
  window.setTimeout(() => {
@@ -4375,6 +4433,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4375
  mensagemRegressao ||
4376
  'Outliers atualizados. Volte ao passo 12, Aplicação das Transformações, para ajustar novamente o modelo.',
4377
  )
 
4378
  await sleep(0)
4379
  if (typeof window !== 'undefined') {
4380
  window.setTimeout(() => {
@@ -4386,7 +4445,8 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4386
  suppressError: isOutlierLimitError,
4387
  onError: (err) => {
4388
  if (isOutlierLimitError(err)) {
4389
- setOutlierLimitWarning(getErrorMessage(err))
 
4390
  }
4391
  },
4392
  },
@@ -4445,6 +4505,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4445
  setTabelaOutliersExcluidos(null)
4446
  setOutlierLimitWarning('')
4447
  setOutlierUpdateWarning('')
 
4448
  setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
4449
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
4450
  syncPeriodoDataMercadoFromContext(resp.contexto)
@@ -6072,7 +6133,13 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
6072
  Fazer download
6073
  </button>
6074
  </div>
6075
- <DataTable table={dados} maxHeight={320} />
 
 
 
 
 
 
6076
  </div>
6077
  </SectionBlock>
6078
 
@@ -6674,7 +6741,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
6674
  {section10ManualOpen ? 'Ocultar ajustes manuais de transformação' : 'Proceder com as transformações manualmente'}
6675
  </button>
6676
  </div>
6677
- {!fit && outlierUpdateWarning ? (
6678
  <div className="outlier-update-warning">{outlierUpdateWarning}</div>
6679
  ) : null}
6680
  {!fit && outliersAnteriores.length > 0 && tabelaOutliersExcluidosAtual ? (
@@ -7063,7 +7130,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
7063
  </SectionBlock>
7064
 
7065
  <SectionBlock step="14" title="Diagnóstico de Modelo" subtitle="Resumo diagnóstico e tabelas principais do ajuste.">
7066
- {outlierUpdateWarning && fit ? (
7067
  <div className="outlier-update-warning">{outlierUpdateWarning}</div>
7068
  ) : null}
7069
  <div className="section14-tabs" role="tablist" aria-label="Conteúdos do diagnóstico do modelo">
@@ -7545,7 +7612,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
7545
  Reiniciar Modelo (Reincluir Todos)
7546
  </button>
7547
  </div>
7548
- {outlierUpdateWarning && fit ? (
7549
  <div className="outlier-update-warning">{outlierUpdateWarning}</div>
7550
  ) : null}
7551
  </div>
 
240
  return num.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
241
  }
242
 
243
+ const CURVA_NORMAL_PERCENTUAL_FAIXAS = [
244
+ { min: 64, max: 75 },
245
+ { min: 88, max: 95 },
246
+ { min: 95, max: 100 },
247
+ ]
248
+
249
+ function parseCurvaNormalPercentual(value) {
250
+ const match = String(value || '').replace(',', '.').match(/-?\d+(?:\.\d+)?/)
251
+ if (!match) return null
252
+ const numero = Number(match[0])
253
+ return Number.isFinite(numero) ? numero : null
254
+ }
255
+
256
+ function formatCurvaNormalPercentuais(value) {
257
+ const raw = String(value || '').trim()
258
+ if (!raw || raw === '-') return raw || '-'
259
+
260
+ const partes = raw.split(',').map((item) => item.trim()).filter(Boolean)
261
+ if (!partes.length) return raw
262
+
263
+ return (
264
+ <span className="section14-curva-normal-percentuais">
265
+ {partes.map((parte, index) => {
266
+ const faixa = CURVA_NORMAL_PERCENTUAL_FAIXAS[index]
267
+ const percentual = parseCurvaNormalPercentual(parte)
268
+ const foraDaFaixa = Boolean(
269
+ faixa
270
+ && percentual !== null
271
+ && (percentual < faixa.min || percentual > faixa.max),
272
+ )
273
+ return (
274
+ <React.Fragment key={`curva-normal-percentual-${index}-${parte}`}>
275
+ {index > 0 ? <span>, </span> : null}
276
+ <span className={foraDaFaixa ? 'section14-percentual-fora-faixa' : undefined}>
277
+ {parte}
278
+ </span>
279
+ </React.Fragment>
280
+ )
281
+ })}
282
+ </span>
283
+ )
284
+ }
285
+
286
  function Section14MetricRows({ rows }) {
287
  return (
288
  <div className="section14-field-grid">
289
  {(rows || []).map((row) => (
290
  <div key={row.label} className="section14-field-row">
291
  <span className="section14-field-label">{row.label}</span>
292
+ <span className="section14-field-value">{row.valueNode ?? row.value}</span>
293
  </div>
294
  ))}
295
  </div>
 
1699
  const [tabelaOutliersExcluidos, setTabelaOutliersExcluidos] = useState(null)
1700
  const [outlierLimitWarning, setOutlierLimitWarning] = useState('')
1701
  const [outlierUpdateWarning, setOutlierUpdateWarning] = useState('')
1702
+ const [outlierUpdateWarningKind, setOutlierUpdateWarningKind] = useState('')
1703
 
1704
  const [camposAvaliacao, setCamposAvaliacao] = useState([])
1705
  const valoresAvaliacaoRef = useRef({})
 
2063
  id: 'curva',
2064
  label: 'Curva normal',
2065
  rows: [
2066
+ {
2067
+ label: 'Percentuais atingidos',
2068
+ value: diagnosticos.perc_resid || '-',
2069
+ valueNode: formatCurvaNormalPercentuais(diagnosticos.perc_resid),
2070
+ },
2071
  { label: 'Ideal 68%', value: 'aceitável entre 64% e 75%' },
2072
  { label: 'Ideal 90%', value: 'aceitável entre 88% e 95%' },
2073
  { label: 'Ideal 95%', value: 'aceitável entre 95% e 100%' },
 
3148
  setTabelaOutliersExcluidos(null)
3149
  setOutlierLimitWarning('')
3150
  setOutlierUpdateWarning('')
3151
+ setOutlierUpdateWarningKind('')
3152
  setOutliersAnteriores([])
3153
  setIteracao(1)
3154
  setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
 
3280
  setTabelaOutliersExcluidos(null)
3281
  setOutlierLimitWarning('')
3282
  setOutlierUpdateWarning('')
3283
+ setOutlierUpdateWarningKind('')
3284
  setCamposAvaliacao([])
3285
  valoresAvaliacaoRef.current = {}
3286
  setAvaliacaoFormVersion((prev) => prev + 1)
 
3378
  setFit(resp)
3379
  setOutlierLimitWarning('')
3380
  setOutlierUpdateWarning('')
3381
+ setOutlierUpdateWarningKind('')
3382
  setSecao13InterativoFigura(null)
3383
  setSecao13InterativoFiguraComIndices(null)
3384
  setSecao13InterativoSelecionado('none')
 
3548
  setTabelaOutliersExcluidos(null)
3549
  setOutlierLimitWarning('')
3550
  setOutlierUpdateWarning('')
3551
+ setOutlierUpdateWarningKind('')
3552
  setOutliersAnteriores([])
3553
  setIteracao(1)
3554
  setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
 
3933
  setTabelaOutliersExcluidos(null)
3934
  setOutlierLimitWarning('')
3935
  setOutlierUpdateWarning('')
3936
+ setOutlierUpdateWarningKind('')
3937
  setOutliersTexto('')
3938
  setReincluirTexto('')
3939
  setBaseChoices([])
 
4087
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
4088
  setOutlierLimitWarning('')
4089
  setOutlierUpdateWarning('')
4090
+ setOutlierUpdateWarningKind('')
4091
  setCamposAvaliacao([])
4092
  valoresAvaliacaoRef.current = {}
4093
  setAvaliacaoFormVersion((prev) => prev + 1)
 
4264
  if (!sessionId) return
4265
  setOutlierLimitWarning('')
4266
  setOutlierUpdateWarning('')
4267
+ setOutlierUpdateWarningKind('')
4268
  await withBusy(
4269
  async () => {
4270
  const filtrosValidos = (filtros || [])
 
4296
  if (!sessionId) return
4297
  setOutlierLimitWarning('')
4298
  setOutlierUpdateWarning('')
4299
+ setOutlierUpdateWarningKind('')
4300
  await withBusy(
4301
  async () => {
4302
  const filtrosValidos = (filtros || [])
 
4384
  if (!sessionId) return
4385
  setOutlierLimitWarning('')
4386
  setOutlierUpdateWarning('')
4387
+ setOutlierUpdateWarningKind('')
4388
  await withBusy(
4389
  async () => {
4390
  const resp = await api.restartOutlierIteration(sessionId, outliersInput, reincluirInput, grauCoef, grauF)
 
4407
  mensagemRegressao ||
4408
  'Outliers atualizados, mas a regressão não fechou. Reinclua dados ou ajuste os filtros antes de continuar.',
4409
  )
4410
+ setOutlierUpdateWarningKind('regressao')
4411
  setSection14Tab('diagnosticos')
4412
  if (typeof window !== 'undefined') {
4413
  window.setTimeout(() => {
 
4433
  mensagemRegressao ||
4434
  'Outliers atualizados. Volte ao passo 12, Aplicação das Transformações, para ajustar novamente o modelo.',
4435
  )
4436
+ setOutlierUpdateWarningKind('success')
4437
  await sleep(0)
4438
  if (typeof window !== 'undefined') {
4439
  window.setTimeout(() => {
 
4445
  suppressError: isOutlierLimitError,
4446
  onError: (err) => {
4447
  if (isOutlierLimitError(err)) {
4448
+ setOutlierUpdateWarning(getErrorMessage(err))
4449
+ setOutlierUpdateWarningKind('micronumerosidade')
4450
  }
4451
  },
4452
  },
 
4505
  setTabelaOutliersExcluidos(null)
4506
  setOutlierLimitWarning('')
4507
  setOutlierUpdateWarning('')
4508
+ setOutlierUpdateWarningKind('')
4509
  setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
4510
  setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
4511
  syncPeriodoDataMercadoFromContext(resp.contexto)
 
6133
  Fazer download
6134
  </button>
6135
  </div>
6136
+ <DataTable
6137
+ table={dados}
6138
+ maxHeight={320}
6139
+ highlightedRowIndices={outliersAnteriores}
6140
+ highlightIndexColumn="_index"
6141
+ highlightClassName="table-row-outlier-excluded"
6142
+ />
6143
  </div>
6144
  </SectionBlock>
6145
 
 
6741
  {section10ManualOpen ? 'Ocultar ajustes manuais de transformação' : 'Proceder com as transformações manualmente'}
6742
  </button>
6743
  </div>
6744
+ {!fit && outlierUpdateWarning && outlierUpdateWarningKind === 'success' ? (
6745
  <div className="outlier-update-warning">{outlierUpdateWarning}</div>
6746
  ) : null}
6747
  {!fit && outliersAnteriores.length > 0 && tabelaOutliersExcluidosAtual ? (
 
7130
  </SectionBlock>
7131
 
7132
  <SectionBlock step="14" title="Diagnóstico de Modelo" subtitle="Resumo diagnóstico e tabelas principais do ajuste.">
7133
+ {outlierUpdateWarning && fit && outlierUpdateWarningKind === 'regressao' ? (
7134
  <div className="outlier-update-warning">{outlierUpdateWarning}</div>
7135
  ) : null}
7136
  <div className="section14-tabs" role="tablist" aria-label="Conteúdos do diagnóstico do modelo">
 
7612
  Reiniciar Modelo (Reincluir Todos)
7613
  </button>
7614
  </div>
7615
+ {outlierUpdateWarning && fit && ['regressao', 'micronumerosidade'].includes(outlierUpdateWarningKind) ? (
7616
  <div className="outlier-update-warning">{outlierUpdateWarning}</div>
7617
  ) : null}
7618
  </div>
frontend/src/components/LeafletMapFrame.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
2
  import L from 'leaflet'
3
  import 'leaflet.fullscreen'
4
  import 'leaflet.heat'
@@ -37,6 +37,42 @@ function buildPopupErrorHtml(message) {
37
  )
38
  }
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  function parseFiniteCoordinate(value, min, max) {
41
  if (value === null || value === undefined) return null
42
  const text = String(value).trim().replace(',', '.')
@@ -336,7 +372,7 @@ function buildLegacyOverlayLayers(payload) {
336
  show: true,
337
  points: payload.market_points.map((item) => ({
338
  ...item,
339
- popup_request: Number.isFinite(Number(item?.row_id))
340
  ? { kind: 'visualizacao_row', row_id: Number(item.row_id) }
341
  : null,
342
  })),
@@ -685,10 +721,12 @@ async function buildHighResolutionSelectionBlob({
685
 
686
  const bairrosPane = exportMap.createPane('mesa-bairros-pane')
687
  const marketPane = exportMap.createPane('mesa-market-pane')
 
688
  const trabalhosPane = exportMap.createPane('mesa-trabalhos-pane')
689
  const indicesPane = exportMap.createPane('mesa-indices-pane')
690
  bairrosPane.style.zIndex = '410'
691
  marketPane.style.zIndex = '420'
 
692
  trabalhosPane.style.zIndex = '430'
693
  indicesPane.style.zIndex = '440'
694
 
@@ -745,6 +783,20 @@ async function buildHighResolutionSelectionBlob({
745
  items.forEach((item) => {
746
  const latlng = parseLatLonPair(item?.lat, item?.lon)
747
  if (!latlng) return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
  const marker = L.circleMarker(latlng, {
749
  renderer: canvasRenderer,
750
  pane: String(item?.pane || 'mesa-market-pane'),
@@ -971,7 +1023,10 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
971
  const mapRef = useRef(null)
972
  const payloadRef = useRef(payload)
973
  const popupCacheRef = useRef(new Map())
 
 
974
  const [runtimeError, setRuntimeError] = useState('')
 
975
 
976
  payloadRef.current = payload
977
 
@@ -1016,10 +1071,26 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1016
  },
1017
  }), [])
1018
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1019
  useEffect(() => {
1020
  if (!hostRef.current || !payload) return undefined
1021
  let disposed = false
1022
  setRuntimeError('')
 
 
1023
  hostRef.current.innerHTML = ''
1024
 
1025
  const map = L.map(hostRef.current, {
@@ -1030,10 +1101,12 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1030
  let restoreMapInteractions = null
1031
  const bairrosPane = map.createPane('mesa-bairros-pane')
1032
  const marketPane = map.createPane('mesa-market-pane')
 
1033
  const trabalhosPane = map.createPane('mesa-trabalhos-pane')
1034
  const indicesPane = map.createPane('mesa-indices-pane')
1035
  bairrosPane.style.zIndex = '410'
1036
  marketPane.style.zIndex = '420'
 
1037
  trabalhosPane.style.zIndex = '430'
1038
  indicesPane.style.zIndex = '440'
1039
 
@@ -1046,9 +1119,7 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1046
 
1047
  ;(payload.tile_layers || []).forEach((layerDef, index) => {
1048
  const tileLayer = L.tileLayer(String(layerDef?.url || ''), {
1049
- attribution: index === 0
1050
- ? '&copy; OpenStreetMap contributors'
1051
- : '&copy; OpenStreetMap contributors &copy; CARTO',
1052
  crossOrigin: 'anonymous',
1053
  detectRetina: true,
1054
  })
@@ -1101,17 +1172,29 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1101
  responsivePointContainers.forEach((container) => {
1102
  if (!container || typeof container.eachLayer !== 'function') return
1103
  container.eachLayer((layer) => {
1104
- if (typeof layer.setRadius !== 'function') return
1105
  const base = Number(layer.options?.mesaBaseRadius || layer.options?.radius || 4)
1106
  const dynamicMin = Math.max(minRadius, base * floorScale)
1107
  const dynamicMax = Math.max(dynamicMin + 0.1, Math.min(maxRadius, base * 8.0))
1108
- layer.setRadius(clamp(base * expFactor, dynamicMin, dynamicMax))
 
 
 
 
 
 
 
1109
  })
1110
  })
1111
  }
1112
 
1113
- async function carregarPopupVisualizacao(rowId, layer) {
1114
- const cacheKey = String(rowId)
 
 
 
 
 
 
1115
  const cached = popupCacheRef.current.get(cacheKey)
1116
  if (cached) {
1117
  const cachedHtml = typeof cached === 'string' ? cached : String(cached?.html || '')
@@ -1131,16 +1214,20 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1131
  ).openPopup()
1132
 
1133
  try {
1134
- const response = await fetch(apiUrl('/api/visualizacao/map/popup'), {
 
 
 
 
 
 
 
1135
  method: 'POST',
1136
  headers: {
1137
  'Content-Type': 'application/json',
1138
  ...(getAuthToken() ? { 'X-Auth-Token': getAuthToken() } : {}),
1139
  },
1140
- body: JSON.stringify({
1141
- session_id: sessionId,
1142
- row_id: rowId,
1143
- }),
1144
  })
1145
  const payloadResp = await readJsonSafely(response)
1146
  if (!response.ok) {
@@ -1167,6 +1254,8 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1167
  }
1168
  }
1169
 
 
 
1170
  function bindPointPopup(marker, item) {
1171
  const popupHtml = String(item?.popup_html || '').trim()
1172
  if (popupHtml) {
@@ -1175,9 +1264,14 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1175
  }
1176
 
1177
  const popupRequest = item?.popup_request
1178
- if (popupRequest?.kind === 'visualizacao_row' && Number.isFinite(Number(popupRequest?.row_id)) && sessionId) {
 
 
 
 
 
1179
  marker.on('click', () => {
1180
- void carregarPopupVisualizacao(Number(popupRequest.row_id), marker)
1181
  })
1182
  }
1183
  }
@@ -1378,6 +1472,36 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1378
  items.forEach((item) => {
1379
  const latlng = parseLatLonPair(item?.lat, item?.lon)
1380
  if (!latlng) return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1381
  const marker = L.circleMarker(latlng, {
1382
  renderer: canvasRenderer,
1383
  pane: String(item?.pane || 'mesa-market-pane'),
@@ -1937,6 +2061,10 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1937
  }
1938
  disposed = true
1939
  restoreMapInteractions?.()
 
 
 
 
1940
  if (mapRef.current === map) {
1941
  mapRef.current = null
1942
  }
@@ -1948,6 +2076,47 @@ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId
1948
  <div className="map-frame leaflet-map-host">
1949
  <div ref={hostRef} className="leaflet-map-canvas" />
1950
  {runtimeError ? <div className="leaflet-map-runtime-error">{runtimeError}</div> : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1951
  </div>
1952
  )
1953
  })
 
1
+ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
2
  import L from 'leaflet'
3
  import 'leaflet.fullscreen'
4
  import 'leaflet.heat'
 
37
  )
38
  }
39
 
40
+ function normalizeCssColor(value, fallback = '#607d8b') {
41
+ const text = String(value || '').trim()
42
+ if (/^#[0-9a-fA-F]{3,8}$/.test(text)) return text
43
+ if (/^rgba?\(\s*[\d.\s%,]+\)$/.test(text)) return text
44
+ return fallback
45
+ }
46
+
47
+ function buildGroupedMarketMarkerHtml(item, size = 22) {
48
+ const count = Math.max(2, Number(item?.count) || 2)
49
+ const color = normalizeCssColor(item?.color || item?.fill_color || '#607d8b')
50
+ const markerSize = Math.max(14, Math.min(38, Number(size) || 22))
51
+ const fontSize = Math.max(9, Math.min(12, markerSize * 0.38))
52
+ const borderSize = markerSize >= 24 ? 3 : 2
53
+ const haloSize = markerSize >= 24 ? 4 : 2
54
+ return (
55
+ `<div class="mesa-market-group-marker" style="--mesa-group-color:${color};--mesa-group-size:${markerSize}px;--mesa-group-font:${fontSize}px;--mesa-group-border:${borderSize}px;--mesa-group-halo:${haloSize}px;">`
56
+ + `<span>${escapeHtml(count > 99 ? '99+' : count)}</span>`
57
+ + '</div>'
58
+ )
59
+ }
60
+
61
+ function buildGroupedMarketIcon(item, size = 22) {
62
+ const markerSize = Math.max(14, Math.min(38, Number(size) || 22))
63
+ return L.divIcon({
64
+ html: buildGroupedMarketMarkerHtml(item, markerSize),
65
+ iconSize: [markerSize, markerSize],
66
+ iconAnchor: [markerSize / 2, markerSize / 2],
67
+ className: 'mesa-market-group-icon',
68
+ })
69
+ }
70
+
71
+ function hasValidRowId(value) {
72
+ if (value === null || value === undefined || value === '') return false
73
+ return Number.isInteger(Number(value)) && Number(value) >= 0
74
+ }
75
+
76
  function parseFiniteCoordinate(value, min, max) {
77
  if (value === null || value === undefined) return null
78
  const text = String(value).trim().replace(',', '.')
 
372
  show: true,
373
  points: payload.market_points.map((item) => ({
374
  ...item,
375
+ popup_request: !item?.grouped && hasValidRowId(item?.row_id)
376
  ? { kind: 'visualizacao_row', row_id: Number(item.row_id) }
377
  : null,
378
  })),
 
721
 
722
  const bairrosPane = exportMap.createPane('mesa-bairros-pane')
723
  const marketPane = exportMap.createPane('mesa-market-pane')
724
+ const marketGroupPane = exportMap.createPane('mesa-market-group-pane')
725
  const trabalhosPane = exportMap.createPane('mesa-trabalhos-pane')
726
  const indicesPane = exportMap.createPane('mesa-indices-pane')
727
  bairrosPane.style.zIndex = '410'
728
  marketPane.style.zIndex = '420'
729
+ marketGroupPane.style.zIndex = '425'
730
  trabalhosPane.style.zIndex = '430'
731
  indicesPane.style.zIndex = '440'
732
 
 
783
  items.forEach((item) => {
784
  const latlng = parseLatLonPair(item?.lat, item?.lon)
785
  if (!latlng) return
786
+ if (item?.grouped) {
787
+ const marker = L.marker(latlng, {
788
+ icon: buildGroupedMarketIcon(item, 16),
789
+ pane: String(item?.pane || 'mesa-market-group-pane'),
790
+ interactive: false,
791
+ keyboard: false,
792
+ bubblingMouseEvents: false,
793
+ })
794
+ marker.options.mesaBaseRadius = Number(item?.base_radius) || 4
795
+ marker.options.mesaGroupedMarketMarker = true
796
+ marker.options.mesaGroupedMarketItem = item
797
+ layerGroup.addLayer(marker)
798
+ return
799
+ }
800
  const marker = L.circleMarker(latlng, {
801
  renderer: canvasRenderer,
802
  pane: String(item?.pane || 'mesa-market-pane'),
 
1023
  const mapRef = useRef(null)
1024
  const payloadRef = useRef(payload)
1025
  const popupCacheRef = useRef(new Map())
1026
+ const popupLoaderRef = useRef(null)
1027
+ const groupSelectionMarkerRef = useRef(null)
1028
  const [runtimeError, setRuntimeError] = useState('')
1029
+ const [groupSelection, setGroupSelection] = useState(null)
1030
 
1031
  payloadRef.current = payload
1032
 
 
1071
  },
1072
  }), [])
1073
 
1074
+ const closeGroupSelection = useCallback(() => {
1075
+ setGroupSelection(null)
1076
+ groupSelectionMarkerRef.current = null
1077
+ }, [])
1078
+
1079
+ const selectGroupedMarketItem = useCallback((item) => {
1080
+ const popupRequest = item?.popup_request
1081
+ const marker = groupSelectionMarkerRef.current
1082
+ const loader = popupLoaderRef.current
1083
+ if (!popupRequest || !marker || !loader) return
1084
+ setGroupSelection(null)
1085
+ void loader(popupRequest, marker)
1086
+ }, [])
1087
+
1088
  useEffect(() => {
1089
  if (!hostRef.current || !payload) return undefined
1090
  let disposed = false
1091
  setRuntimeError('')
1092
+ setGroupSelection(null)
1093
+ groupSelectionMarkerRef.current = null
1094
  hostRef.current.innerHTML = ''
1095
 
1096
  const map = L.map(hostRef.current, {
 
1101
  let restoreMapInteractions = null
1102
  const bairrosPane = map.createPane('mesa-bairros-pane')
1103
  const marketPane = map.createPane('mesa-market-pane')
1104
+ const marketGroupPane = map.createPane('mesa-market-group-pane')
1105
  const trabalhosPane = map.createPane('mesa-trabalhos-pane')
1106
  const indicesPane = map.createPane('mesa-indices-pane')
1107
  bairrosPane.style.zIndex = '410'
1108
  marketPane.style.zIndex = '420'
1109
+ marketGroupPane.style.zIndex = '425'
1110
  trabalhosPane.style.zIndex = '430'
1111
  indicesPane.style.zIndex = '440'
1112
 
 
1119
 
1120
  ;(payload.tile_layers || []).forEach((layerDef, index) => {
1121
  const tileLayer = L.tileLayer(String(layerDef?.url || ''), {
1122
+ attribution: String(layerDef?.attribution || '&copy; OpenStreetMap contributors'),
 
 
1123
  crossOrigin: 'anonymous',
1124
  detectRetina: true,
1125
  })
 
1172
  responsivePointContainers.forEach((container) => {
1173
  if (!container || typeof container.eachLayer !== 'function') return
1174
  container.eachLayer((layer) => {
 
1175
  const base = Number(layer.options?.mesaBaseRadius || layer.options?.radius || 4)
1176
  const dynamicMin = Math.max(minRadius, base * floorScale)
1177
  const dynamicMax = Math.max(dynamicMin + 0.1, Math.min(maxRadius, base * 8.0))
1178
+ const radius = clamp(base * expFactor, dynamicMin, dynamicMax)
1179
+ if (layer.options?.mesaGroupedMarketMarker && typeof layer.setIcon === 'function') {
1180
+ const iconSize = Math.max(16, Math.min(36, Math.round(radius * 2.35)))
1181
+ layer.setIcon(buildGroupedMarketIcon(layer.options.mesaGroupedMarketItem || {}, iconSize))
1182
+ return
1183
+ }
1184
+ if (typeof layer.setRadius !== 'function') return
1185
+ layer.setRadius(radius)
1186
  })
1187
  })
1188
  }
1189
 
1190
+ async function carregarPopupRegistro(popupRequest, layer) {
1191
+ const kind = String(popupRequest?.kind || '').trim()
1192
+ const rowId = Number(popupRequest?.row_id)
1193
+ const source = String(popupRequest?.source || '').trim()
1194
+ const endpoint = kind === 'elaboracao_row'
1195
+ ? '/api/elaboracao/map/popup'
1196
+ : '/api/visualizacao/map/popup'
1197
+ const cacheKey = `${kind || 'visualizacao_row'}:${source || 'default'}:${rowId}`
1198
  const cached = popupCacheRef.current.get(cacheKey)
1199
  if (cached) {
1200
  const cachedHtml = typeof cached === 'string' ? cached : String(cached?.html || '')
 
1214
  ).openPopup()
1215
 
1216
  try {
1217
+ const body = {
1218
+ session_id: sessionId,
1219
+ row_id: rowId,
1220
+ }
1221
+ if (source) {
1222
+ body.source = source
1223
+ }
1224
+ const response = await fetch(apiUrl(endpoint), {
1225
  method: 'POST',
1226
  headers: {
1227
  'Content-Type': 'application/json',
1228
  ...(getAuthToken() ? { 'X-Auth-Token': getAuthToken() } : {}),
1229
  },
1230
+ body: JSON.stringify(body),
 
 
 
1231
  })
1232
  const payloadResp = await readJsonSafely(response)
1233
  if (!response.ok) {
 
1254
  }
1255
  }
1256
 
1257
+ popupLoaderRef.current = carregarPopupRegistro
1258
+
1259
  function bindPointPopup(marker, item) {
1260
  const popupHtml = String(item?.popup_html || '').trim()
1261
  if (popupHtml) {
 
1264
  }
1265
 
1266
  const popupRequest = item?.popup_request
1267
+ const popupKind = String(popupRequest?.kind || '').trim()
1268
+ if (
1269
+ ['visualizacao_row', 'elaboracao_row'].includes(popupKind)
1270
+ && Number.isFinite(Number(popupRequest?.row_id))
1271
+ && sessionId
1272
+ ) {
1273
  marker.on('click', () => {
1274
+ void carregarPopupRegistro(popupRequest, marker)
1275
  })
1276
  }
1277
  }
 
1472
  items.forEach((item) => {
1473
  const latlng = parseLatLonPair(item?.lat, item?.lon)
1474
  if (!latlng) return
1475
+ if (item?.grouped) {
1476
+ const marker = L.marker(latlng, {
1477
+ icon: buildGroupedMarketIcon(item, 16),
1478
+ pane: String(item?.pane || 'mesa-market-group-pane'),
1479
+ interactive: item?.interactive !== false,
1480
+ keyboard: item?.keyboard !== false,
1481
+ bubblingMouseEvents: false,
1482
+ })
1483
+ marker.options.mesaBaseRadius = Number(item?.base_radius) || 4
1484
+ marker.options.mesaGroupedMarketMarker = true
1485
+ marker.options.mesaGroupedMarketItem = item
1486
+ const tooltipHtml = String(item?.tooltip_html || '').trim()
1487
+ if (tooltipHtml) {
1488
+ marker.bindTooltip(tooltipHtml, { sticky: item?.tooltip_sticky !== false })
1489
+ }
1490
+ marker.on('click', () => {
1491
+ map.closePopup()
1492
+ marker.unbindPopup()
1493
+ const groupItems = Array.isArray(item?.group_items) ? item.group_items : []
1494
+ if (!groupItems.length) return
1495
+ groupSelectionMarkerRef.current = marker
1496
+ setGroupSelection({
1497
+ title: String(item?.group_title || `${groupItems.length} dados neste local`),
1498
+ count: Number(item?.count) || groupItems.length,
1499
+ items: groupItems,
1500
+ })
1501
+ })
1502
+ layerGroup.addLayer(marker)
1503
+ return
1504
+ }
1505
  const marker = L.circleMarker(latlng, {
1506
  renderer: canvasRenderer,
1507
  pane: String(item?.pane || 'mesa-market-pane'),
 
2061
  }
2062
  disposed = true
2063
  restoreMapInteractions?.()
2064
+ if (popupLoaderRef.current === carregarPopupRegistro) {
2065
+ popupLoaderRef.current = null
2066
+ }
2067
+ groupSelectionMarkerRef.current = null
2068
  if (mapRef.current === map) {
2069
  mapRef.current = null
2070
  }
 
2076
  <div className="map-frame leaflet-map-host">
2077
  <div ref={hostRef} className="leaflet-map-canvas" />
2078
  {runtimeError ? <div className="leaflet-map-runtime-error">{runtimeError}</div> : null}
2079
+ {groupSelection ? (
2080
+ <div className="leaflet-market-group-modal-backdrop" onClick={closeGroupSelection}>
2081
+ <div
2082
+ className="leaflet-market-group-modal"
2083
+ role="dialog"
2084
+ aria-modal="true"
2085
+ aria-label="Selecionar dado de mercado"
2086
+ onClick={(event) => event.stopPropagation()}
2087
+ >
2088
+ <div className="leaflet-market-group-modal-head">
2089
+ <div>
2090
+ <strong>{groupSelection.title}</strong>
2091
+ <span>{Number(groupSelection.count) || groupSelection.items?.length || 0} registros sobrepostos</span>
2092
+ </div>
2093
+ <button type="button" onClick={closeGroupSelection} aria-label="Fechar">×</button>
2094
+ </div>
2095
+ <div className="leaflet-market-group-list">
2096
+ {(groupSelection.items || []).map((item, index) => {
2097
+ const disabled = !item?.popup_request
2098
+ return (
2099
+ <button
2100
+ key={`${item?.indice ?? index}-${index}`}
2101
+ type="button"
2102
+ className="leaflet-market-group-option"
2103
+ onClick={() => selectGroupedMarketItem(item)}
2104
+ disabled={disabled}
2105
+ >
2106
+ <span>{item?.label || `Índice ${item?.indice ?? index + 1}`}</span>
2107
+ {item?.value ? (
2108
+ <small>
2109
+ {item?.value_label ? `${item.value_label}: ` : ''}
2110
+ {item.value}
2111
+ </small>
2112
+ ) : null}
2113
+ </button>
2114
+ )
2115
+ })}
2116
+ </div>
2117
+ </div>
2118
+ </div>
2119
+ ) : null}
2120
  </div>
2121
  )
2122
  })
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -1015,6 +1015,7 @@ export default function PesquisaTab({
1015
  const [mapaHtmls, setMapaHtmls] = useState({ pontos: '', cobertura: '' })
1016
  const [mapaPayloads, setMapaPayloads] = useState({ pontos: null, cobertura: null })
1017
  const [mapaModoExibicao, setMapaModoExibicao] = useState('pontos')
 
1018
  const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState('selecionados_e_outras_versoes')
1019
  const [mapaTrabalhosTecnicosProximidadeModo, setMapaTrabalhosTecnicosProximidadeModo] = useState('sem_proximidade')
1020
  const [mapaTrabalhosTecnicosRaio, setMapaTrabalhosTecnicosRaio] = useState(1000)
@@ -1039,9 +1040,13 @@ export default function PesquisaTab({
1039
  const [modeloAbertoTrabalhosTecnicosModelosModo, setModeloAbertoTrabalhosTecnicosModelosModo] = useState(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
1040
  const [modeloAbertoTrabalhosTecnicos, setModeloAbertoTrabalhosTecnicos] = useState([])
1041
  const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
 
1042
  const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
 
1043
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
 
1044
  const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
 
1045
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
1046
  const [modeloAbertoLoadedTabs, setModeloAbertoLoadedTabs] = useState({})
1047
  const [modeloAbertoLoadingTabs, setModeloAbertoLoadingTabs] = useState({})
@@ -1136,6 +1141,7 @@ export default function PesquisaTab({
1136
  setMapaStatus('')
1137
  setMapaError('')
1138
  setMapaModoExibicao('pontos')
 
1139
  mapaTrabalhosTecnicosConfigRef.current = ''
1140
  }
1141
 
@@ -1166,6 +1172,7 @@ export default function PesquisaTab({
1166
  async function carregarMapaPesquisa(ids, overrides = {}) {
1167
  const idsValidos = (ids || []).map((item) => String(item || '').trim()).filter(Boolean)
1168
  const modoExibicaoSolicitado = String(overrides.modoExibicao || mapaModoExibicao || 'pontos')
 
1169
  const trabalhosTecnicosConfig = getMapaTrabalhosTecnicosRequestConfig(overrides)
1170
 
1171
  if (!idsValidos.length) {
@@ -1188,6 +1195,7 @@ export default function PesquisaTab({
1188
  trabalhosTecnicosConfig.modelosModo,
1189
  trabalhosTecnicosConfig.proximidadeModo,
1190
  trabalhosTecnicosConfig.raio,
 
1191
  )
1192
  const mapaHtmlSolicitado = String(
1193
  response.mapa_html
@@ -1491,9 +1499,13 @@ export default function PesquisaTab({
1491
  setModeloAbertoTrabalhosTecnicosModelosModo(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
1492
  setModeloAbertoTrabalhosTecnicos([])
1493
  setModeloAbertoPlotObsCalc(null)
 
1494
  setModeloAbertoPlotResiduos(null)
 
1495
  setModeloAbertoPlotHistograma(null)
 
1496
  setModeloAbertoPlotCook(null)
 
1497
  setModeloAbertoPlotCorr(null)
1498
  setModeloAbertoLoadedTabs({})
1499
  setModeloAbertoLoadingTabs({})
@@ -1529,9 +1541,13 @@ export default function PesquisaTab({
1529
  }
1530
  if (key === 'graficos') {
1531
  setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
 
1532
  setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
 
1533
  setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
 
1534
  setModeloAbertoPlotCook(resp?.grafico_cook || null)
 
1535
  setModeloAbertoPlotCorr(resp?.grafico_correlacao || null)
1536
  return
1537
  }
@@ -1723,6 +1739,18 @@ export default function PesquisaTab({
1723
  void carregarMapaPesquisa(selectedIds, { modoExibicao: nextModo })
1724
  }
1725
 
 
 
 
 
 
 
 
 
 
 
 
 
1726
  async function onAdminConfigSalva() {
1727
  if (pesquisaInicializada) {
1728
  await buscarModelos(filters, avaliandosGeolocalizados)
@@ -1942,10 +1970,31 @@ export default function PesquisaTab({
1942
  modeloAbertoLoadedTabs.graficos ? (
1943
  <>
1944
  <div className="plot-grid-2-fixed">
1945
- <PlotFigure figure={modeloAbertoPlotObsCalc} title="Obs x Calc" />
1946
- <PlotFigure figure={modeloAbertoPlotResiduos} title="Resíduos" />
1947
- <PlotFigure figure={modeloAbertoPlotHistograma} title="Histograma" />
1948
- <PlotFigure figure={modeloAbertoPlotCook} title="Cook" forceHideLegend />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1949
  </div>
1950
  <div className="plot-full-width">
1951
  <PlotFigure figure={modeloAbertoPlotCorr} title="Correlação" className="plot-correlation-card" />
@@ -2454,6 +2503,21 @@ export default function PesquisaTab({
2454
  </select>
2455
  </label>
2456
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2457
  <label className="pesquisa-field pesquisa-mapa-trabalhos-field">
2458
  Exibição dos trabalhos técnicos
2459
  <select
 
1015
  const [mapaHtmls, setMapaHtmls] = useState({ pontos: '', cobertura: '' })
1016
  const [mapaPayloads, setMapaPayloads] = useState({ pontos: null, cobertura: null })
1017
  const [mapaModoExibicao, setMapaModoExibicao] = useState('pontos')
1018
+ const [mapaAgruparPontosMercado, setMapaAgruparPontosMercado] = useState(false)
1019
  const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState('selecionados_e_outras_versoes')
1020
  const [mapaTrabalhosTecnicosProximidadeModo, setMapaTrabalhosTecnicosProximidadeModo] = useState('sem_proximidade')
1021
  const [mapaTrabalhosTecnicosRaio, setMapaTrabalhosTecnicosRaio] = useState(1000)
 
1040
  const [modeloAbertoTrabalhosTecnicosModelosModo, setModeloAbertoTrabalhosTecnicosModelosModo] = useState(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
1041
  const [modeloAbertoTrabalhosTecnicos, setModeloAbertoTrabalhosTecnicos] = useState([])
1042
  const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
1043
+ const [modeloAbertoPlotObsCalcIndices, setModeloAbertoPlotObsCalcIndices] = useState(null)
1044
  const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
1045
+ const [modeloAbertoPlotResiduosIndices, setModeloAbertoPlotResiduosIndices] = useState(null)
1046
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
1047
+ const [modeloAbertoPlotHistogramaIndices, setModeloAbertoPlotHistogramaIndices] = useState(null)
1048
  const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
1049
+ const [modeloAbertoPlotCookIndices, setModeloAbertoPlotCookIndices] = useState(null)
1050
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
1051
  const [modeloAbertoLoadedTabs, setModeloAbertoLoadedTabs] = useState({})
1052
  const [modeloAbertoLoadingTabs, setModeloAbertoLoadingTabs] = useState({})
 
1141
  setMapaStatus('')
1142
  setMapaError('')
1143
  setMapaModoExibicao('pontos')
1144
+ setMapaAgruparPontosMercado(false)
1145
  mapaTrabalhosTecnicosConfigRef.current = ''
1146
  }
1147
 
 
1172
  async function carregarMapaPesquisa(ids, overrides = {}) {
1173
  const idsValidos = (ids || []).map((item) => String(item || '').trim()).filter(Boolean)
1174
  const modoExibicaoSolicitado = String(overrides.modoExibicao || mapaModoExibicao || 'pontos')
1175
+ const agruparPontosMercadoSolicitado = Boolean(overrides.agruparPontosMercado ?? mapaAgruparPontosMercado)
1176
  const trabalhosTecnicosConfig = getMapaTrabalhosTecnicosRequestConfig(overrides)
1177
 
1178
  if (!idsValidos.length) {
 
1195
  trabalhosTecnicosConfig.modelosModo,
1196
  trabalhosTecnicosConfig.proximidadeModo,
1197
  trabalhosTecnicosConfig.raio,
1198
+ agruparPontosMercadoSolicitado,
1199
  )
1200
  const mapaHtmlSolicitado = String(
1201
  response.mapa_html
 
1499
  setModeloAbertoTrabalhosTecnicosModelosModo(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
1500
  setModeloAbertoTrabalhosTecnicos([])
1501
  setModeloAbertoPlotObsCalc(null)
1502
+ setModeloAbertoPlotObsCalcIndices(null)
1503
  setModeloAbertoPlotResiduos(null)
1504
+ setModeloAbertoPlotResiduosIndices(null)
1505
  setModeloAbertoPlotHistograma(null)
1506
+ setModeloAbertoPlotHistogramaIndices(null)
1507
  setModeloAbertoPlotCook(null)
1508
+ setModeloAbertoPlotCookIndices(null)
1509
  setModeloAbertoPlotCorr(null)
1510
  setModeloAbertoLoadedTabs({})
1511
  setModeloAbertoLoadingTabs({})
 
1541
  }
1542
  if (key === 'graficos') {
1543
  setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
1544
+ setModeloAbertoPlotObsCalcIndices(resp?.grafico_obs_calc_com_indices || null)
1545
  setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
1546
+ setModeloAbertoPlotResiduosIndices(resp?.grafico_residuos_com_indices || null)
1547
  setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
1548
+ setModeloAbertoPlotHistogramaIndices(resp?.grafico_histograma_com_indices || null)
1549
  setModeloAbertoPlotCook(resp?.grafico_cook || null)
1550
+ setModeloAbertoPlotCookIndices(resp?.grafico_cook_com_indices || null)
1551
  setModeloAbertoPlotCorr(resp?.grafico_correlacao || null)
1552
  return
1553
  }
 
1739
  void carregarMapaPesquisa(selectedIds, { modoExibicao: nextModo })
1740
  }
1741
 
1742
+ function onMapaAgrupamentoPontosChange(event) {
1743
+ const nextAgrupar = String(event?.target?.value || 'individual') === 'agrupado'
1744
+ setMapaAgruparPontosMercado(nextAgrupar)
1745
+ setMapaHtmls((prev) => ({ ...prev, pontos: '' }))
1746
+ setMapaPayloads((prev) => ({ ...prev, pontos: null }))
1747
+ if (!mapaFoiGerado || mapaLoading || !selectedIds.length || mapaModoExibicao !== 'pontos') return
1748
+ void carregarMapaPesquisa(selectedIds, {
1749
+ modoExibicao: 'pontos',
1750
+ agruparPontosMercado: nextAgrupar,
1751
+ })
1752
+ }
1753
+
1754
  async function onAdminConfigSalva() {
1755
  if (pesquisaInicializada) {
1756
  await buscarModelos(filters, avaliandosGeolocalizados)
 
1970
  modeloAbertoLoadedTabs.graficos ? (
1971
  <>
1972
  <div className="plot-grid-2-fixed">
1973
+ <PlotFigure
1974
+ figure={modeloAbertoPlotObsCalc}
1975
+ indexedFigure={modeloAbertoPlotObsCalcIndices}
1976
+ title="Obs x Calc"
1977
+ showPointIndexToggle={Boolean(modeloAbertoPlotObsCalcIndices)}
1978
+ />
1979
+ <PlotFigure
1980
+ figure={modeloAbertoPlotResiduos}
1981
+ indexedFigure={modeloAbertoPlotResiduosIndices}
1982
+ title="Resíduos"
1983
+ showPointIndexToggle={Boolean(modeloAbertoPlotResiduosIndices)}
1984
+ />
1985
+ <PlotFigure
1986
+ figure={modeloAbertoPlotHistograma}
1987
+ indexedFigure={modeloAbertoPlotHistogramaIndices}
1988
+ title="Histograma"
1989
+ showPointIndexToggle={Boolean(modeloAbertoPlotHistogramaIndices)}
1990
+ />
1991
+ <PlotFigure
1992
+ figure={modeloAbertoPlotCook}
1993
+ indexedFigure={modeloAbertoPlotCookIndices}
1994
+ title="Cook"
1995
+ forceHideLegend
1996
+ showPointIndexToggle={Boolean(modeloAbertoPlotCookIndices)}
1997
+ />
1998
  </div>
1999
  <div className="plot-full-width">
2000
  <PlotFigure figure={modeloAbertoPlotCorr} title="Correlação" className="plot-correlation-card" />
 
2503
  </select>
2504
  </label>
2505
 
2506
+ {mapaModoExibicao === 'pontos' ? (
2507
+ <label className="pesquisa-field pesquisa-mapa-agrupamento-field">
2508
+ Pontos sobrepostos
2509
+ <select
2510
+ {...buildSelectAutofillProps('mapaAgrupamentoPontos')}
2511
+ value={mapaAgruparPontosMercado ? 'agrupado' : 'individual'}
2512
+ onChange={onMapaAgrupamentoPontosChange}
2513
+ disabled={mapaLoading || !selectedIds.length}
2514
+ >
2515
+ <option value="individual">Mostrar individualmente</option>
2516
+ <option value="agrupado">Agrupar por coordenada</option>
2517
+ </select>
2518
+ </label>
2519
+ ) : null}
2520
+
2521
  <label className="pesquisa-field pesquisa-mapa-trabalhos-field">
2522
  Exibição dos trabalhos técnicos
2523
  <select
frontend/src/components/RepositorioTab.jsx CHANGED
@@ -180,9 +180,13 @@ export default function RepositorioTab({
180
  const [modeloAbertoTrabalhosTecnicosModelosModo, setModeloAbertoTrabalhosTecnicosModelosModo] = useState(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
181
  const [modeloAbertoTrabalhosTecnicos, setModeloAbertoTrabalhosTecnicos] = useState([])
182
  const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
 
183
  const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
 
184
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
 
185
  const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
 
186
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
187
  const [modeloAbertoLoadedTabs, setModeloAbertoLoadedTabs] = useState({})
188
  const [modeloAbertoLoadingTabs, setModeloAbertoLoadingTabs] = useState({})
@@ -356,9 +360,13 @@ export default function RepositorioTab({
356
  setModeloAbertoTrabalhosTecnicosModelosModo(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
357
  setModeloAbertoTrabalhosTecnicos([])
358
  setModeloAbertoPlotObsCalc(null)
 
359
  setModeloAbertoPlotResiduos(null)
 
360
  setModeloAbertoPlotHistograma(null)
 
361
  setModeloAbertoPlotCook(null)
 
362
  setModeloAbertoPlotCorr(null)
363
  setModeloAbertoLoadedTabs({})
364
  setModeloAbertoLoadingTabs({})
@@ -394,9 +402,13 @@ export default function RepositorioTab({
394
  }
395
  if (secaoNormalizada === 'graficos') {
396
  setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
 
397
  setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
 
398
  setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
 
399
  setModeloAbertoPlotCook(resp?.grafico_cook || null)
 
400
  setModeloAbertoPlotCorr(resp?.grafico_correlacao || null)
401
  return
402
  }
@@ -755,10 +767,31 @@ export default function RepositorioTab({
755
  activeTabLoaded ? (
756
  <>
757
  <div className="plot-grid-2-fixed">
758
- <PlotFigure figure={modeloAbertoPlotObsCalc} title="Obs x Calc" />
759
- <PlotFigure figure={modeloAbertoPlotResiduos} title="Resíduos" />
760
- <PlotFigure figure={modeloAbertoPlotHistograma} title="Histograma" />
761
- <PlotFigure figure={modeloAbertoPlotCook} title="Cook" forceHideLegend />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
762
  </div>
763
  <div className="plot-full-width">
764
  <PlotFigure figure={modeloAbertoPlotCorr} title="Correlação" className="plot-correlation-card" />
 
180
  const [modeloAbertoTrabalhosTecnicosModelosModo, setModeloAbertoTrabalhosTecnicosModelosModo] = useState(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
181
  const [modeloAbertoTrabalhosTecnicos, setModeloAbertoTrabalhosTecnicos] = useState([])
182
  const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
183
+ const [modeloAbertoPlotObsCalcIndices, setModeloAbertoPlotObsCalcIndices] = useState(null)
184
  const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
185
+ const [modeloAbertoPlotResiduosIndices, setModeloAbertoPlotResiduosIndices] = useState(null)
186
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
187
+ const [modeloAbertoPlotHistogramaIndices, setModeloAbertoPlotHistogramaIndices] = useState(null)
188
  const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
189
+ const [modeloAbertoPlotCookIndices, setModeloAbertoPlotCookIndices] = useState(null)
190
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
191
  const [modeloAbertoLoadedTabs, setModeloAbertoLoadedTabs] = useState({})
192
  const [modeloAbertoLoadingTabs, setModeloAbertoLoadingTabs] = useState({})
 
360
  setModeloAbertoTrabalhosTecnicosModelosModo(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
361
  setModeloAbertoTrabalhosTecnicos([])
362
  setModeloAbertoPlotObsCalc(null)
363
+ setModeloAbertoPlotObsCalcIndices(null)
364
  setModeloAbertoPlotResiduos(null)
365
+ setModeloAbertoPlotResiduosIndices(null)
366
  setModeloAbertoPlotHistograma(null)
367
+ setModeloAbertoPlotHistogramaIndices(null)
368
  setModeloAbertoPlotCook(null)
369
+ setModeloAbertoPlotCookIndices(null)
370
  setModeloAbertoPlotCorr(null)
371
  setModeloAbertoLoadedTabs({})
372
  setModeloAbertoLoadingTabs({})
 
402
  }
403
  if (secaoNormalizada === 'graficos') {
404
  setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
405
+ setModeloAbertoPlotObsCalcIndices(resp?.grafico_obs_calc_com_indices || null)
406
  setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
407
+ setModeloAbertoPlotResiduosIndices(resp?.grafico_residuos_com_indices || null)
408
  setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
409
+ setModeloAbertoPlotHistogramaIndices(resp?.grafico_histograma_com_indices || null)
410
  setModeloAbertoPlotCook(resp?.grafico_cook || null)
411
+ setModeloAbertoPlotCookIndices(resp?.grafico_cook_com_indices || null)
412
  setModeloAbertoPlotCorr(resp?.grafico_correlacao || null)
413
  return
414
  }
 
767
  activeTabLoaded ? (
768
  <>
769
  <div className="plot-grid-2-fixed">
770
+ <PlotFigure
771
+ figure={modeloAbertoPlotObsCalc}
772
+ indexedFigure={modeloAbertoPlotObsCalcIndices}
773
+ title="Obs x Calc"
774
+ showPointIndexToggle={Boolean(modeloAbertoPlotObsCalcIndices)}
775
+ />
776
+ <PlotFigure
777
+ figure={modeloAbertoPlotResiduos}
778
+ indexedFigure={modeloAbertoPlotResiduosIndices}
779
+ title="Resíduos"
780
+ showPointIndexToggle={Boolean(modeloAbertoPlotResiduosIndices)}
781
+ />
782
+ <PlotFigure
783
+ figure={modeloAbertoPlotHistograma}
784
+ indexedFigure={modeloAbertoPlotHistogramaIndices}
785
+ title="Histograma"
786
+ showPointIndexToggle={Boolean(modeloAbertoPlotHistogramaIndices)}
787
+ />
788
+ <PlotFigure
789
+ figure={modeloAbertoPlotCook}
790
+ indexedFigure={modeloAbertoPlotCookIndices}
791
+ title="Cook"
792
+ forceHideLegend
793
+ showPointIndexToggle={Boolean(modeloAbertoPlotCookIndices)}
794
+ />
795
  </div>
796
  <div className="plot-full-width">
797
  <PlotFigure figure={modeloAbertoPlotCorr} title="Correlação" className="plot-correlation-card" />
frontend/src/styles.css CHANGED
@@ -191,7 +191,7 @@ textarea {
191
  .tab-pill {
192
  text-align: center;
193
  border: 1px solid #d2deea;
194
- border-radius: 10px;
195
  background: linear-gradient(180deg, #f7fafd 0%, #edf3f8 100%);
196
  padding: 8px 12px;
197
  color: #32475d;
@@ -3125,6 +3125,12 @@ button.pesquisa-coluna-remove:hover {
3125
  margin: 0;
3126
  }
3127
 
 
 
 
 
 
 
3128
  .pesquisa-mapa-trabalhos-field {
3129
  min-width: 0;
3130
  max-width: none;
@@ -4261,12 +4267,17 @@ button.pesquisa-coluna-remove:hover {
4261
  display: inline-flex;
4262
  align-items: center;
4263
  justify-content: center;
4264
- cursor: help;
4265
  font-size: 0.85em;
4266
  line-height: 1;
4267
  opacity: 0.7;
4268
  }
4269
 
 
 
 
 
 
4270
  .avaliacao-popup-overlay {
4271
  position: fixed;
4272
  z-index: 3600;
@@ -4287,6 +4298,18 @@ button.pesquisa-coluna-remove:hover {
4287
  pointer-events: none;
4288
  }
4289
 
 
 
 
 
 
 
 
 
 
 
 
 
4290
  .avaliacao-knn-modal {
4291
  width: min(1180px, 100%);
4292
  }
@@ -5595,6 +5618,11 @@ button.import-preview-clear-btn {
5595
  text-align: right;
5596
  }
5597
 
 
 
 
 
 
5598
  .section14-table-head {
5599
  display: flex;
5600
  align-items: center;
@@ -5645,6 +5673,136 @@ button.import-preview-clear-btn {
5645
  z-index: 600;
5646
  }
5647
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5648
  .mesa-leaflet-legend {
5649
  min-width: 168px;
5650
  border-radius: 12px;
@@ -6052,7 +6210,7 @@ button.import-preview-clear-btn {
6052
  }
6053
 
6054
  .table-wrapper tr:hover td {
6055
- background: #fff8ef;
6056
  }
6057
 
6058
  .table-wrapper tr.table-row-highlight td {
@@ -6063,6 +6221,16 @@ button.import-preview-clear-btn {
6063
  background: #ffe882;
6064
  }
6065
 
 
 
 
 
 
 
 
 
 
 
6066
  .table-cell-clamp,
6067
  .avaliacao-knn-table-cell {
6068
  display: -webkit-box;
 
191
  .tab-pill {
192
  text-align: center;
193
  border: 1px solid #d2deea;
194
+ border-radius: 8px;
195
  background: linear-gradient(180deg, #f7fafd 0%, #edf3f8 100%);
196
  padding: 8px 12px;
197
  color: #32475d;
 
3125
  margin: 0;
3126
  }
3127
 
3128
+ .pesquisa-mapa-agrupamento-field {
3129
+ min-width: 0;
3130
+ max-width: none;
3131
+ margin: 0;
3132
+ }
3133
+
3134
  .pesquisa-mapa-trabalhos-field {
3135
  min-width: 0;
3136
  max-width: none;
 
4267
  display: inline-flex;
4268
  align-items: center;
4269
  justify-content: center;
4270
+ cursor: pointer;
4271
  font-size: 0.85em;
4272
  line-height: 1;
4273
  opacity: 0.7;
4274
  }
4275
 
4276
+ .avaliacao-popup-trigger:hover,
4277
+ .avaliacao-popup-trigger:focus-visible {
4278
+ opacity: 1;
4279
+ }
4280
+
4281
  .avaliacao-popup-overlay {
4282
  position: fixed;
4283
  z-index: 3600;
 
4298
  pointer-events: none;
4299
  }
4300
 
4301
+ .avaliacao-info-modal {
4302
+ width: min(680px, 100%);
4303
+ }
4304
+
4305
+ .avaliacao-info-modal-body {
4306
+ color: #333;
4307
+ font-size: 12px;
4308
+ font-weight: 400;
4309
+ line-height: 1.4;
4310
+ text-align: left;
4311
+ }
4312
+
4313
  .avaliacao-knn-modal {
4314
  width: min(1180px, 100%);
4315
  }
 
5618
  text-align: right;
5619
  }
5620
 
5621
+ .section14-percentual-fora-faixa {
5622
+ color: var(--danger);
5623
+ font-weight: 800;
5624
+ }
5625
+
5626
  .section14-table-head {
5627
  display: flex;
5628
  align-items: center;
 
5673
  z-index: 600;
5674
  }
5675
 
5676
+ .mesa-market-group-icon {
5677
+ background: transparent;
5678
+ border: 0;
5679
+ }
5680
+
5681
+ .mesa-market-group-marker {
5682
+ width: var(--mesa-group-size, 22px);
5683
+ height: var(--mesa-group-size, 22px);
5684
+ display: flex;
5685
+ align-items: center;
5686
+ justify-content: center;
5687
+ border: var(--mesa-group-border, 2px) solid #243746;
5688
+ border-radius: 50%;
5689
+ background: var(--mesa-group-color, #607d8b);
5690
+ color: #fff;
5691
+ box-shadow: 0 0 0 var(--mesa-group-halo, 2px) rgba(36, 55, 70, 0.18), 0 6px 16px rgba(23, 39, 55, 0.26);
5692
+ font-size: var(--mesa-group-font, 10px);
5693
+ font-weight: 800;
5694
+ line-height: 1;
5695
+ }
5696
+
5697
+ .mesa-market-group-marker span {
5698
+ min-width: calc(var(--mesa-group-size, 22px) * 0.48);
5699
+ padding: 1px 2px;
5700
+ border-radius: 999px;
5701
+ background: rgba(20, 33, 45, 0.42);
5702
+ pointer-events: none;
5703
+ text-align: center;
5704
+ }
5705
+
5706
+ .leaflet-market-group-modal-backdrop {
5707
+ position: absolute;
5708
+ inset: 0;
5709
+ z-index: 900;
5710
+ display: flex;
5711
+ align-items: center;
5712
+ justify-content: center;
5713
+ padding: 24px;
5714
+ background: rgba(18, 30, 42, 0.24);
5715
+ }
5716
+
5717
+ .leaflet-market-group-modal {
5718
+ width: min(420px, 100%);
5719
+ max-height: min(460px, calc(100% - 32px));
5720
+ display: flex;
5721
+ flex-direction: column;
5722
+ overflow: hidden;
5723
+ border: 1px solid rgba(186, 201, 214, 0.95);
5724
+ border-radius: 8px;
5725
+ background: #fff;
5726
+ box-shadow: 0 18px 46px rgba(22, 39, 58, 0.26);
5727
+ }
5728
+
5729
+ .leaflet-market-group-modal-head {
5730
+ display: flex;
5731
+ align-items: flex-start;
5732
+ justify-content: space-between;
5733
+ gap: 16px;
5734
+ padding: 14px 16px;
5735
+ border-bottom: 1px solid #e3ebf2;
5736
+ background: #f6f9fc;
5737
+ color: #243746;
5738
+ }
5739
+
5740
+ .leaflet-market-group-modal-head strong,
5741
+ .leaflet-market-group-modal-head span {
5742
+ display: block;
5743
+ }
5744
+
5745
+ .leaflet-market-group-modal-head strong {
5746
+ font-size: 0.92rem;
5747
+ }
5748
+
5749
+ .leaflet-market-group-modal-head span {
5750
+ margin-top: 3px;
5751
+ color: #627487;
5752
+ font-size: 0.78rem;
5753
+ }
5754
+
5755
+ .leaflet-market-group-modal-head button {
5756
+ width: 28px;
5757
+ height: 28px;
5758
+ border: 1px solid #cbd8e4;
5759
+ border-radius: 50%;
5760
+ background: #fff;
5761
+ color: #40576d;
5762
+ cursor: pointer;
5763
+ font-size: 20px;
5764
+ line-height: 1;
5765
+ }
5766
+
5767
+ .leaflet-market-group-list {
5768
+ overflow-y: auto;
5769
+ padding: 8px;
5770
+ }
5771
+
5772
+ .leaflet-market-group-option {
5773
+ width: 100%;
5774
+ display: flex;
5775
+ align-items: center;
5776
+ justify-content: space-between;
5777
+ gap: 12px;
5778
+ padding: 9px 10px;
5779
+ border: 0;
5780
+ border-bottom: 1px solid #edf2f6;
5781
+ background: #fff;
5782
+ color: #243746;
5783
+ cursor: pointer;
5784
+ text-align: left;
5785
+ }
5786
+
5787
+ .leaflet-market-group-option:hover {
5788
+ background: #eef5fb;
5789
+ }
5790
+
5791
+ .leaflet-market-group-option span {
5792
+ font-weight: 700;
5793
+ }
5794
+
5795
+ .leaflet-market-group-option small {
5796
+ color: #627487;
5797
+ font-size: 0.74rem;
5798
+ text-align: right;
5799
+ }
5800
+
5801
+ .leaflet-market-group-option:disabled {
5802
+ cursor: not-allowed;
5803
+ opacity: 0.5;
5804
+ }
5805
+
5806
  .mesa-leaflet-legend {
5807
  min-width: 168px;
5808
  border-radius: 12px;
 
6210
  }
6211
 
6212
  .table-wrapper tr:hover td {
6213
+ background: #eef4fa;
6214
  }
6215
 
6216
  .table-wrapper tr.table-row-highlight td {
 
6221
  background: #ffe882;
6222
  }
6223
 
6224
+ .table-wrapper tr.table-row-outlier-excluded td {
6225
+ background: #fde8e6;
6226
+ color: #8a1f17;
6227
+ font-weight: 600;
6228
+ }
6229
+
6230
+ .table-wrapper tr.table-row-outlier-excluded:hover td {
6231
+ background: #fbd3cf;
6232
+ }
6233
+
6234
  .table-cell-clamp,
6235
  .avaliacao-knn-table-cell {
6236
  display: -webkit-box;