Guilherme Silberfarb Costa commited on
Commit
3ee46e7
·
1 Parent(s): 950e36a

Refina mapas Leaflet e prepara nova release Windows

Browse files
backend/app/api/visualizacao.py CHANGED
@@ -3,10 +3,11 @@ from __future__ import annotations
3
  import os
4
  from typing import Any
5
 
6
- from fastapi import APIRouter, File, Form, Request, UploadFile
7
  from fastapi.responses import FileResponse
8
  from pydantic import BaseModel
9
 
 
10
  from app.services import auth_service, elaboracao_service, visualizacao_service
11
  from app.services.audit_log_service import log_event
12
  from app.services.session_store import session_store
@@ -151,6 +152,15 @@ def map_popup(payload: MapaPopupPayload) -> dict[str, Any]:
151
  return visualizacao_service.carregar_popup_ponto_mapa(session, payload.row_id)
152
 
153
 
 
 
 
 
 
 
 
 
 
154
  @router.post("/evaluation/fields")
155
  def evaluation_fields(payload: SessionPayload) -> dict[str, Any]:
156
  session = session_store.get(payload.session_id)
 
3
  import os
4
  from typing import Any
5
 
6
+ from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
7
  from fastapi.responses import FileResponse
8
  from pydantic import BaseModel
9
 
10
+ from app.core.map_layers import get_bairros_geojson
11
  from app.services import auth_service, elaboracao_service, visualizacao_service
12
  from app.services.audit_log_service import log_event
13
  from app.services.session_store import session_store
 
152
  return visualizacao_service.carregar_popup_ponto_mapa(session, payload.row_id)
153
 
154
 
155
+ @router.get("/map/bairros.geojson")
156
+ def map_bairros_geojson(request: Request) -> dict[str, Any]:
157
+ auth_service.require_user(request)
158
+ geojson = get_bairros_geojson()
159
+ if not isinstance(geojson, dict):
160
+ raise HTTPException(status_code=404, detail="Camada de bairros indisponivel")
161
+ return geojson
162
+
163
+
164
  @router.post("/evaluation/fields")
165
  def evaluation_fields(payload: SessionPayload) -> dict[str, Any]:
166
  session = session_store.get(payload.session_id)
backend/app/core/map_layers.py CHANGED
@@ -95,6 +95,10 @@ def _carregar_bairros_geojson() -> dict[str, Any] | None:
95
  return geojson
96
 
97
 
 
 
 
 
98
  def add_bairros_layer(
99
  mapa: folium.Map,
100
  *,
@@ -180,14 +184,14 @@ def add_indice_marker(
180
  ).add_to(camada)
181
 
182
 
183
- def add_trabalhos_tecnicos_markers(
184
- camada: folium.map.FeatureGroup,
185
  trabalhos: list[dict[str, Any]] | None,
186
  *,
187
  origem: str = "pesquisa_mapa",
188
  marker_style: str = "estrela",
189
  ignore_bounds: bool = True,
190
- ) -> None:
 
191
  for item in trabalhos or []:
192
  try:
193
  lat = float(item.get("coord_lat"))
@@ -261,43 +265,83 @@ def add_trabalhos_tecnicos_markers(
261
  + "</div>"
262
  )
263
 
264
- if str(marker_style or "").strip().lower() == "ponto":
265
- marcador = folium.Marker(
266
- location=[lat, lon],
267
- tooltip=folium.Tooltip(detalhes_html, sticky=True),
268
- popup=folium.Popup(detalhes_html, max_width=360),
269
- icon=folium.DivIcon(
270
- html=(
271
- "<div style='display:flex;align-items:center;justify-content:center;"
272
- "width:8px;height:8px;border-radius:999px;background:#1f6fb2;"
273
- "border:1px solid #ffffff;box-shadow:0 0 0 1px rgba(20,42,66,0.20);'></div>"
274
- ),
275
- icon_size=(8, 8),
276
- icon_anchor=(4, 4),
277
- class_name="mesa-trabalho-tecnico-marker mesa-trabalho-tecnico-marker-dot",
278
- ),
279
  )
280
- else:
281
- marcador = folium.Marker(
282
- location=[lat, lon],
283
- tooltip=folium.Tooltip(detalhes_html, sticky=True),
284
- popup=folium.Popup(detalhes_html, max_width=360),
285
- icon=folium.DivIcon(
286
- html=(
287
- "<div style='display:flex;align-items:center;justify-content:center;"
288
- "width:14px;height:14px;'>"
289
- "<svg width='14' height='14' viewBox='0 0 24 24' aria-hidden='true'>"
290
- "<polygon points='12,1.8 15.2,8.2 22.2,9.2 17.1,14.1 18.3,21.1 "
291
- "12,17.8 5.7,21.1 6.9,14.1 1.8,9.2 8.8,8.2' "
292
- "fill='#c62828' stroke='#000000' stroke-width='1.4' stroke-linejoin='round'/>"
293
- "</svg></div>"
294
- ),
295
- icon_size=(14, 14),
296
- icon_anchor=(7, 7),
297
- class_name="mesa-trabalho-tecnico-marker",
298
- ),
299
  )
300
- marcador.options["mesaIgnoreBounds"] = bool(ignore_bounds)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  marcador.add_to(camada)
302
 
303
 
 
95
  return geojson
96
 
97
 
98
+ def get_bairros_geojson() -> dict[str, Any] | None:
99
+ return _carregar_bairros_geojson()
100
+
101
+
102
  def add_bairros_layer(
103
  mapa: folium.Map,
104
  *,
 
184
  ).add_to(camada)
185
 
186
 
187
+ def build_trabalhos_tecnicos_marker_payloads(
 
188
  trabalhos: list[dict[str, Any]] | None,
189
  *,
190
  origem: str = "pesquisa_mapa",
191
  marker_style: str = "estrela",
192
  ignore_bounds: bool = True,
193
+ ) -> list[dict[str, Any]]:
194
+ payloads: list[dict[str, Any]] = []
195
  for item in trabalhos or []:
196
  try:
197
  lat = float(item.get("coord_lat"))
 
265
  + "</div>"
266
  )
267
 
268
+ if str(marker_style or "").strip().lower() != "ponto":
269
+ marker_html = (
270
+ "<div style='display:flex;align-items:center;justify-content:center;"
271
+ "width:14px;height:14px;'>"
272
+ "<svg width='14' height='14' viewBox='0 0 24 24' aria-hidden='true'>"
273
+ "<polygon points='12,1.8 15.2,8.2 22.2,9.2 17.1,14.1 18.3,21.1 "
274
+ "12,17.8 5.7,21.1 6.9,14.1 1.8,9.2 8.8,8.2' "
275
+ "fill='#c62828' stroke='#000000' stroke-width='1.4' stroke-linejoin='round'/>"
276
+ "</svg></div>"
 
 
 
 
 
 
277
  )
278
+ payloads.append(
279
+ {
280
+ "lat": lat,
281
+ "lon": lon,
282
+ "tooltip_html": detalhes_html,
283
+ "popup_html": detalhes_html,
284
+ "marker_html": marker_html,
285
+ "marker_style": "estrela",
286
+ "ignore_bounds": bool(ignore_bounds),
287
+ "icon_size": [14, 14],
288
+ "icon_anchor": [7, 7],
289
+ "class_name": "mesa-trabalho-tecnico-marker",
290
+ }
 
 
 
 
 
 
291
  )
292
+ continue
293
+
294
+ marker_html = (
295
+ "<div style='display:flex;align-items:center;justify-content:center;"
296
+ "width:8px;height:8px;border-radius:999px;background:#1f6fb2;"
297
+ "border:1px solid #ffffff;box-shadow:0 0 0 1px rgba(20,42,66,0.20);'></div>"
298
+ )
299
+ payloads.append(
300
+ {
301
+ "lat": lat,
302
+ "lon": lon,
303
+ "tooltip_html": detalhes_html,
304
+ "popup_html": detalhes_html,
305
+ "marker_html": marker_html,
306
+ "marker_style": "ponto",
307
+ "ignore_bounds": bool(ignore_bounds),
308
+ "icon_size": [8, 8],
309
+ "icon_anchor": [4, 4],
310
+ "class_name": "mesa-trabalho-tecnico-marker mesa-trabalho-tecnico-marker-dot",
311
+ }
312
+ )
313
+
314
+ return payloads
315
+
316
+
317
+ def add_trabalhos_tecnicos_markers(
318
+ camada: folium.map.FeatureGroup,
319
+ trabalhos: list[dict[str, Any]] | None,
320
+ *,
321
+ origem: str = "pesquisa_mapa",
322
+ marker_style: str = "estrela",
323
+ ignore_bounds: bool = True,
324
+ ) -> None:
325
+ payloads = build_trabalhos_tecnicos_marker_payloads(
326
+ trabalhos,
327
+ origem=origem,
328
+ marker_style=marker_style,
329
+ ignore_bounds=ignore_bounds,
330
+ )
331
+
332
+ for item in payloads:
333
+ marcador = folium.Marker(
334
+ location=[float(item["lat"]), float(item["lon"])],
335
+ tooltip=folium.Tooltip(str(item.get("tooltip_html") or ""), sticky=True),
336
+ popup=folium.Popup(str(item.get("popup_html") or ""), max_width=360),
337
+ icon=folium.DivIcon(
338
+ html=str(item.get("marker_html") or ""),
339
+ icon_size=tuple(item.get("icon_size") or [14, 14]),
340
+ icon_anchor=tuple(item.get("icon_anchor") or [7, 7]),
341
+ class_name=str(item.get("class_name") or "mesa-trabalho-tecnico-marker"),
342
+ ),
343
+ )
344
+ marcador.options["mesaIgnoreBounds"] = bool(item.get("ignore_bounds"))
345
  marcador.add_to(camada)
346
 
347
 
backend/app/core/visualizacao/app.py CHANGED
@@ -929,7 +929,7 @@ def criar_mapa(
929
  camada_indices.add_to(mapa)
930
 
931
  if avaliandos_tecnicos:
932
- camada_trabalhos_tecnicos = folium.FeatureGroup(name="Avaliandos que usaram o modelo", show=True)
933
  add_trabalhos_tecnicos_markers(camada_trabalhos_tecnicos, avaliandos_tecnicos)
934
  camada_trabalhos_tecnicos.add_to(mapa)
935
 
 
929
  camada_indices.add_to(mapa)
930
 
931
  if avaliandos_tecnicos:
932
+ camada_trabalhos_tecnicos = folium.FeatureGroup(name="Avaliandos", show=True)
933
  add_trabalhos_tecnicos_markers(camada_trabalhos_tecnicos, avaliandos_tecnicos)
934
  camada_trabalhos_tecnicos.add_to(mapa)
935
 
backend/app/core/visualizacao/map_payload.py ADDED
@@ -0,0 +1,828 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import branca.colormap as cm
6
+ import numpy as np
7
+ import pandas as pd
8
+ from scipy.interpolate import griddata
9
+
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
+
28
+ def _primeira_serie_por_nome(dataframe: pd.DataFrame, nome_coluna: str) -> pd.Series | None:
29
+ matches = [i for i, c in enumerate(dataframe.columns) if str(c) == str(nome_coluna)]
30
+ if not matches:
31
+ return None
32
+ return dataframe.iloc[:, matches[0]]
33
+
34
+
35
+ def _detectar_coluna(df: pd.DataFrame, aliases: set[str]) -> str | None:
36
+ for col in df.columns:
37
+ if str(col).lower() in aliases:
38
+ return str(col)
39
+ return None
40
+
41
+
42
+ def _formatar_tooltip_valor(coluna: str | None, valor: Any) -> str:
43
+ if valor is None:
44
+ return "—"
45
+ try:
46
+ if pd.isna(valor):
47
+ return "—"
48
+ except Exception:
49
+ pass
50
+
51
+ col_norm = str(coluna or "").lower()
52
+ if isinstance(valor, (int, float, np.integer, np.floating)):
53
+ numero = float(valor)
54
+ if not np.isfinite(numero):
55
+ return "—"
56
+ if any(k in col_norm for k in ["valor", "preco", "vu", "vunit"]):
57
+ return formatar_monetario(numero)
58
+ return f"{numero:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
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:
65
+ lat_vals = df_mapa[lat_key]
66
+ lon_vals = df_mapa[lon_key]
67
+ lat_med = float(lat_vals.median())
68
+ lon_med = float(lon_vals.median())
69
+ lat_mad = float((lat_vals - lat_med).abs().median())
70
+ lon_mad = float((lon_vals - lon_med).abs().median())
71
+ lat_span = float(lat_vals.max() - lat_vals.min())
72
+ lon_span = float(lon_vals.max() - lon_vals.min())
73
+ lat_scale = max(lat_mad, lat_span / 30.0, 1e-6)
74
+ lon_scale = max(lon_mad, lon_span / 30.0, 1e-6)
75
+ score = ((lat_vals - lat_med) / lat_scale) ** 2 + ((lon_vals - lon_med) / lon_scale) ** 2
76
+ lim = float(score.quantile(0.75))
77
+ df_core = df_mapa[score <= lim]
78
+ if len(df_core) >= max(5, int(len(df_mapa) * 0.45)):
79
+ df_bounds = df_core
80
+
81
+ if len(df_bounds) >= 50:
82
+ lat_min, lat_max = df_bounds[lat_key].quantile([0.01, 0.99]).tolist()
83
+ lon_min, lon_max = df_bounds[lon_key].quantile([0.01, 0.99]).tolist()
84
+ else:
85
+ lat_min, lat_max = float(df_bounds[lat_key].min()), float(df_bounds[lat_key].max())
86
+ lon_min, lon_max = float(df_bounds[lon_key].min()), float(df_bounds[lon_key].max())
87
+
88
+ if not np.isfinite(lat_min) or not np.isfinite(lat_max):
89
+ lat_min, lat_max = float(df_mapa[lat_key].min()), float(df_mapa[lat_key].max())
90
+ if not np.isfinite(lon_min) or not np.isfinite(lon_max):
91
+ lon_min, lon_max = float(df_mapa[lon_key].min()), float(df_mapa[lon_key].max())
92
+
93
+ if np.isclose(lat_min, lat_max):
94
+ lat_min = float(lat_min) - 0.0008
95
+ lat_max = float(lat_max) + 0.0008
96
+ if np.isclose(lon_min, lon_max):
97
+ lon_min = float(lon_min) - 0.0008
98
+ lon_max = float(lon_max) + 0.0008
99
+
100
+ return [[float(lat_min), float(lon_min)], [float(lat_max), float(lon_max)]]
101
+
102
+
103
+ def _normalizar_bounds_lista(bounds: list[list[float]] | None) -> list[list[float]] | None:
104
+ coords = []
105
+ for item in bounds or []:
106
+ if not isinstance(item, (list, tuple)) or len(item) < 2:
107
+ continue
108
+ try:
109
+ lat = float(item[0])
110
+ lon = float(item[1])
111
+ except Exception:
112
+ continue
113
+ if not np.isfinite(lat) or not np.isfinite(lon):
114
+ continue
115
+ coords.append((lat, lon))
116
+
117
+ if not coords:
118
+ return None
119
+
120
+ lat_values = [lat for lat, _ in coords]
121
+ lon_values = [lon for _, lon in coords]
122
+ lat_min = min(lat_values)
123
+ lat_max = max(lat_values)
124
+ lon_min = min(lon_values)
125
+ lon_max = max(lon_values)
126
+
127
+ if np.isclose(lat_min, lat_max):
128
+ lat_min -= 0.0008
129
+ lat_max += 0.0008
130
+ if np.isclose(lon_min, lon_max):
131
+ lon_min -= 0.0008
132
+ lon_max += 0.0008
133
+
134
+ return [[float(lat_min), float(lon_min)], [float(lat_max), float(lon_max)]]
135
+
136
+
137
+ def build_leaflet_payload(
138
+ *,
139
+ bounds: list[list[float]] | None,
140
+ center: list[float] | None = None,
141
+ legend: dict[str, Any] | None = None,
142
+ overlay_layers: list[dict[str, Any]] | None = None,
143
+ notice: dict[str, Any] | None = None,
144
+ show_bairros: bool = True,
145
+ bairros_geojson_url: str = "/api/visualizacao/map/bairros.geojson",
146
+ ) -> dict[str, Any] | None:
147
+ normalized_bounds = _normalizar_bounds_lista(bounds)
148
+ if normalized_bounds is None:
149
+ return None
150
+
151
+ if center and len(center) >= 2:
152
+ center_lat = float(center[0])
153
+ center_lon = float(center[1])
154
+ else:
155
+ center_lat = float((normalized_bounds[0][0] + normalized_bounds[1][0]) / 2.0)
156
+ center_lon = float((normalized_bounds[0][1] + normalized_bounds[1][1]) / 2.0)
157
+
158
+ final_overlay_layers = list(overlay_layers or [])
159
+ if show_bairros:
160
+ final_overlay_layers.insert(
161
+ 0,
162
+ {
163
+ "id": "bairros",
164
+ "label": "Bairros",
165
+ "show": True,
166
+ "geojson_url": bairros_geojson_url,
167
+ "geojson_style": {
168
+ "color": "#4c6882",
169
+ "weight": 1.0,
170
+ "fillColor": "#f39c12",
171
+ "fillOpacity": 0.04,
172
+ },
173
+ "geojson_tooltip_properties": ["NOME", "BAIRRO", "NME_BAI", "NOME_BAIRRO"],
174
+ "geojson_tooltip_label": "Bairro",
175
+ },
176
+ )
177
+
178
+ return {
179
+ "type": "mesa_leaflet_payload",
180
+ "version": 1,
181
+ "center": [center_lat, center_lon],
182
+ "bounds": normalized_bounds,
183
+ "tile_layers": _TILE_LAYERS,
184
+ "controls": {
185
+ "fullscreen": True,
186
+ "measure": True,
187
+ "layer_control": True,
188
+ },
189
+ "radius_behavior": {
190
+ "min_radius": 1.6,
191
+ "max_radius": 52.0,
192
+ "reference_zoom": 12.0,
193
+ "growth_factor": 0.20,
194
+ },
195
+ "legend": legend,
196
+ "notice": notice,
197
+ "overlay_layers": final_overlay_layers,
198
+ }
199
+
200
+
201
+ def build_elaboracao_map_payload(
202
+ df: pd.DataFrame,
203
+ *,
204
+ lat_col: str = "lat",
205
+ lon_col: str = "lon",
206
+ cor_col: str | None = None,
207
+ indice_destacado: Any = None,
208
+ tamanho_col: str | None = None,
209
+ modo: str | None = "pontos",
210
+ cor_vmin: float | None = None,
211
+ cor_vmax: float | None = None,
212
+ cor_caption: str | None = None,
213
+ cor_colors: list[str] | None = None,
214
+ cor_stops: list[float] | None = None,
215
+ cor_tick_values: list[float] | None = None,
216
+ cor_tick_labels: list[str] | None = None,
217
+ bairros_geojson_url: str = "/api/visualizacao/map/bairros.geojson",
218
+ ) -> dict[str, Any] | None:
219
+ modo_normalizado = str(modo or "pontos").strip().lower()
220
+
221
+ cols_lower = {str(c).lower(): c for c in df.columns}
222
+ lat_real = cols_lower.get(str(lat_col).lower()) if str(lat_col).lower() in cols_lower else None
223
+ lon_real = cols_lower.get(str(lon_col).lower()) if str(lon_col).lower() in cols_lower else None
224
+
225
+ if lat_real is None:
226
+ for nome in ["lat", "latitude", "siat_latitude"]:
227
+ if nome in cols_lower:
228
+ lat_real = cols_lower[nome]
229
+ break
230
+ if lon_real is None:
231
+ for nome in ["lon", "longitude", "long", "siat_longitude"]:
232
+ if nome in cols_lower:
233
+ lon_real = cols_lower[nome]
234
+ break
235
+ if lat_real is None or lon_real is None:
236
+ return None
237
+
238
+ df_mapa = df.copy()
239
+ df_mapa[lat_real] = pd.to_numeric(df_mapa[lat_real], errors="coerce")
240
+ df_mapa[lon_real] = pd.to_numeric(df_mapa[lon_real], errors="coerce")
241
+ df_mapa = df_mapa.dropna(subset=[lat_real, lon_real])
242
+ df_mapa = df_mapa[~((df_mapa[lat_real] == 0.0) & (df_mapa[lon_real] == 0.0))]
243
+ df_mapa = df_mapa[
244
+ (df_mapa[lat_real] >= -90.0)
245
+ & (df_mapa[lat_real] <= 90.0)
246
+ & (df_mapa[lon_real] >= -180.0)
247
+ & (df_mapa[lon_real] <= 180.0)
248
+ ].copy()
249
+ if df_mapa.empty:
250
+ return None
251
+
252
+ limite_pontos = 2500
253
+ total_pontos = len(df_mapa)
254
+ houve_amostragem = total_pontos > limite_pontos
255
+ if houve_amostragem:
256
+ df_mapa = df_mapa.sample(n=limite_pontos, random_state=42).copy()
257
+
258
+ centro_lat = float(df_mapa[lat_real].median())
259
+ centro_lon = float(df_mapa[lon_real].median())
260
+
261
+ colors = (
262
+ [str(item) for item in cor_colors if str(item).strip()]
263
+ if isinstance(cor_colors, list) and len(cor_colors) >= 2
264
+ else ["#2ecc71", "#a8e06c", "#f1c40f", "#e67e22", "#e74c3c"]
265
+ )
266
+ modo_calor = modo_normalizado == "calor" and tamanho_col is not None and tamanho_col in df_mapa.columns
267
+ modo_superficie = modo_normalizado == "superficie" and tamanho_col is not None and tamanho_col in df_mapa.columns
268
+ if modo_normalizado not in {"pontos", "calor", "superficie"}:
269
+ modo_normalizado = "pontos"
270
+
271
+ cor_col_resolvida = cor_col or tamanho_col
272
+ colormap = None
273
+ legend = None
274
+ if cor_col_resolvida and cor_col_resolvida in df_mapa.columns:
275
+ serie_cor = pd.to_numeric(df_mapa[cor_col_resolvida], errors="coerce")
276
+ vmin = float(cor_vmin) if cor_vmin is not None and np.isfinite(cor_vmin) else float(serie_cor.min())
277
+ vmax = float(cor_vmax) if cor_vmax is not None and np.isfinite(cor_vmax) else float(serie_cor.max())
278
+ if np.isfinite(vmin) and np.isfinite(vmax):
279
+ if np.isclose(vmin, vmax):
280
+ vmax = float(vmin) + 1.0
281
+ color_index = _normalizar_stops_cor(cor_stops, colors, float(vmin), float(vmax))
282
+ colormap_kwargs: dict[str, Any] = {
283
+ "colors": colors,
284
+ "vmin": float(vmin),
285
+ "vmax": float(vmax),
286
+ "caption": str(cor_caption or cor_col_resolvida),
287
+ }
288
+ if color_index is not None:
289
+ colormap_kwargs["index"] = color_index
290
+ colormap = cm.LinearColormap(**colormap_kwargs)
291
+ legend = {
292
+ "title": str(cor_caption or cor_col_resolvida),
293
+ "vmin": float(vmin),
294
+ "vmax": float(vmax),
295
+ "colors": colors,
296
+ }
297
+ if (
298
+ isinstance(cor_tick_values, list)
299
+ and isinstance(cor_tick_labels, list)
300
+ and len(cor_tick_values) == len(cor_tick_labels)
301
+ and len(cor_tick_values) > 1
302
+ ):
303
+ try:
304
+ legend["tick_values"] = [float(item) for item in cor_tick_values]
305
+ legend["tick_labels"] = [str(item) for item in cor_tick_labels]
306
+ except (TypeError, ValueError):
307
+ pass
308
+
309
+ raio_min, raio_max = 3.0, 18.0
310
+ tamanho_func = None
311
+ if tamanho_col and tamanho_col in df_mapa.columns:
312
+ serie_tamanho = pd.to_numeric(df_mapa[tamanho_col], errors="coerce")
313
+ t_min = float(serie_tamanho.min())
314
+ t_max = float(serie_tamanho.max())
315
+ if np.isfinite(t_min) and np.isfinite(t_max):
316
+ if t_max > t_min:
317
+ tamanho_func = (
318
+ lambda v, _min=t_min, _max=t_max: raio_min
319
+ + (v - _min) / (_max - _min) * (raio_max - raio_min)
320
+ )
321
+ else:
322
+ tamanho_func = lambda _v: (raio_min + raio_max) / 2.0
323
+
324
+ mostrar_indices = not modo_calor and not modo_superficie and len(df_mapa) <= 800
325
+ lat_plot_col = "__mesa_lat_plot__"
326
+ lon_plot_col = "__mesa_lon_plot__"
327
+ if not modo_calor and not modo_superficie:
328
+ df_plot_pontos = _aplicar_jitter_sobrepostos(
329
+ df_mapa,
330
+ lat_col=str(lat_real),
331
+ lon_col=str(lon_real),
332
+ lat_plot_col=lat_plot_col,
333
+ lon_plot_col=lon_plot_col,
334
+ )
335
+ else:
336
+ df_plot_pontos = df_mapa.copy()
337
+ df_plot_pontos[lat_plot_col] = df_plot_pontos[lat_real]
338
+ df_plot_pontos[lon_plot_col] = df_plot_pontos[lon_real]
339
+
340
+ overlay_layers: list[dict[str, Any]] = []
341
+ if modo_calor and tamanho_col and tamanho_col in df_mapa.columns:
342
+ pesos = pd.to_numeric(df_mapa[tamanho_col], errors="coerce")
343
+ mask_pesos = np.isfinite(pesos.to_numpy())
344
+ df_calor = df_mapa.loc[mask_pesos, [lat_real, lon_real]].copy()
345
+ if not df_calor.empty:
346
+ pesos_validos = pesos.loc[df_calor.index].to_numpy(dtype=float)
347
+ fixed_scale = (
348
+ cor_vmin is not None
349
+ and cor_vmax is not None
350
+ and np.isfinite(cor_vmin)
351
+ and np.isfinite(cor_vmax)
352
+ and float(cor_vmax) > float(cor_vmin)
353
+ )
354
+ if fixed_scale:
355
+ peso_min = float(cor_vmin)
356
+ peso_max = float(cor_vmax)
357
+ pesos_clip = np.clip(pesos_validos, peso_min, peso_max)
358
+ pesos_norm = (pesos_clip - peso_min) / (peso_max - peso_min)
359
+ else:
360
+ peso_min = float(np.min(pesos_validos))
361
+ peso_max = float(np.max(pesos_validos))
362
+ if np.isfinite(peso_min) and np.isfinite(peso_max) and peso_max > peso_min:
363
+ pesos_norm = 0.1 + 0.9 * (pesos_validos - peso_min) / (peso_max - peso_min)
364
+ else:
365
+ pesos_norm = np.ones_like(pesos_validos)
366
+ gradient = None
367
+ if len(colors) >= 2:
368
+ if (
369
+ cor_vmin is not None
370
+ and cor_vmax is not None
371
+ and np.isfinite(cor_vmin)
372
+ and np.isfinite(cor_vmax)
373
+ and float(cor_vmax) > float(cor_vmin)
374
+ ):
375
+ color_index = _normalizar_stops_cor(cor_stops, colors, float(cor_vmin), float(cor_vmax))
376
+ if color_index is not None:
377
+ gradient = {}
378
+ for stop, color in zip(color_index, colors):
379
+ ratio = (float(stop) - float(cor_vmin)) / (float(cor_vmax) - float(cor_vmin))
380
+ gradient[float(np.clip(ratio, 0.0, 1.0))] = color
381
+ elif len(colors) == 2:
382
+ gradient = {0.0: colors[0], 1.0: colors[1]}
383
+ else:
384
+ gradient = {i / (len(colors) - 1): colors[i] for i in range(len(colors))}
385
+ elif len(colors) == 2:
386
+ gradient = {0.0: colors[0], 1.0: colors[1]}
387
+ else:
388
+ gradient = {i / (len(colors) - 1): colors[i] for i in range(len(colors))}
389
+ overlay_layers.append(
390
+ {
391
+ "id": "mapa_calor",
392
+ "label": "Mapa de calor",
393
+ "show": True,
394
+ "heatmap": {
395
+ "points": [
396
+ {
397
+ "lat": float(df_calor.iloc[idx][lat_real]),
398
+ "lon": float(df_calor.iloc[idx][lon_real]),
399
+ "weight": float(pesos_norm[idx]),
400
+ }
401
+ for idx in range(len(df_calor))
402
+ ],
403
+ "radius": 20,
404
+ "blur": 18,
405
+ "min_opacity": 0.28,
406
+ "max_zoom": 17,
407
+ "gradient": gradient,
408
+ },
409
+ }
410
+ )
411
+ elif modo_superficie and tamanho_col and tamanho_col in df_mapa.columns:
412
+ lats = pd.to_numeric(df_mapa[lat_real], errors="coerce").to_numpy(dtype=float)
413
+ lons = pd.to_numeric(df_mapa[lon_real], errors="coerce").to_numpy(dtype=float)
414
+ valores = pd.to_numeric(df_mapa[tamanho_col], errors="coerce").to_numpy(dtype=float)
415
+ mask_valid = np.isfinite(lats) & np.isfinite(lons) & np.isfinite(valores)
416
+ if mask_valid.sum() >= 6:
417
+ lats = lats[mask_valid]
418
+ lons = lons[mask_valid]
419
+ valores = valores[mask_valid]
420
+ contorno = _contorno_convexo_lng_lat(lons, lats)
421
+ if contorno is not None:
422
+ lon_min = float(np.min(contorno[:, 0]))
423
+ lon_max = float(np.max(contorno[:, 0]))
424
+ lat_min = float(np.min(contorno[:, 1]))
425
+ lat_max = float(np.max(contorno[:, 1]))
426
+ if not np.isclose(lon_min, lon_max) and not np.isclose(lat_min, lat_max):
427
+ n_obs = len(valores)
428
+ n_grid = 46 if n_obs <= 400 else (40 if n_obs <= 1200 else 34)
429
+ grid_lon = np.linspace(lon_min, lon_max, n_grid)
430
+ grid_lat = np.linspace(lat_min, lat_max, n_grid)
431
+ mesh_lon, mesh_lat = np.meshgrid(grid_lon, grid_lat)
432
+ pontos = np.column_stack([lons, lats])
433
+ try:
434
+ superficie = griddata(pontos, valores, (mesh_lon, mesh_lat), method="linear")
435
+ except Exception:
436
+ superficie = None
437
+ if superficie is not None:
438
+ superficie = np.asarray(superficie, dtype=float)
439
+ if np.isnan(superficie).all():
440
+ try:
441
+ superficie = griddata(pontos, valores, (mesh_lon, mesh_lat), method="nearest")
442
+ except Exception:
443
+ superficie = None
444
+ elif np.isnan(superficie).any():
445
+ try:
446
+ nearest = griddata(pontos, valores, (mesh_lon, mesh_lat), method="nearest")
447
+ except Exception:
448
+ nearest = None
449
+ if nearest is not None:
450
+ superficie = np.where(np.isfinite(superficie), superficie, np.asarray(nearest, dtype=float))
451
+ if superficie is not None:
452
+ superficie = np.asarray(superficie, dtype=float)
453
+ mascara = _mascara_dentro_poligono(mesh_lon, mesh_lat, contorno)
454
+ superficie = np.where(mascara, superficie, np.nan)
455
+ if np.isfinite(superficie).any():
456
+ vmin_sup = float(cor_vmin) if cor_vmin is not None and np.isfinite(cor_vmin) else float(np.nanmin(superficie))
457
+ vmax_sup = float(cor_vmax) if cor_vmax is not None and np.isfinite(cor_vmax) else float(np.nanmax(superficie))
458
+ if np.isfinite(vmin_sup) and np.isfinite(vmax_sup):
459
+ if np.isclose(vmin_sup, vmax_sup):
460
+ vmax_sup = vmin_sup + 1.0
461
+ color_index = _normalizar_stops_cor(cor_stops, colors, vmin_sup, vmax_sup)
462
+ colormap_kwargs = {
463
+ "colors": colors,
464
+ "vmin": float(vmin_sup),
465
+ "vmax": float(vmax_sup),
466
+ "caption": str(cor_caption or f"{tamanho_col} (superfície)"),
467
+ }
468
+ if color_index is not None:
469
+ colormap_kwargs["index"] = color_index
470
+ colormap = cm.LinearColormap(**colormap_kwargs)
471
+ legend = {
472
+ "title": str(cor_caption or f"{tamanho_col} (superfície)"),
473
+ "vmin": float(vmin_sup),
474
+ "vmax": float(vmax_sup),
475
+ "colors": colors,
476
+ }
477
+ if (
478
+ isinstance(cor_tick_values, list)
479
+ and isinstance(cor_tick_labels, list)
480
+ and len(cor_tick_values) == len(cor_tick_labels)
481
+ and len(cor_tick_values) > 1
482
+ ):
483
+ try:
484
+ legend["tick_values"] = [float(item) for item in cor_tick_values]
485
+ legend["tick_labels"] = [str(item) for item in cor_tick_labels]
486
+ except (TypeError, ValueError):
487
+ pass
488
+ centros_lon = (grid_lon[:-1] + grid_lon[1:]) / 2.0
489
+ centros_lat = (grid_lat[:-1] + grid_lat[1:]) / 2.0
490
+ centro_mesh_lon, centro_mesh_lat = np.meshgrid(centros_lon, centros_lat)
491
+ mascara_centros = _mascara_dentro_poligono(centro_mesh_lon, centro_mesh_lat, contorno)
492
+ valores_celula = (
493
+ superficie[:-1, :-1]
494
+ + superficie[1:, :-1]
495
+ + superficie[:-1, 1:]
496
+ + superficie[1:, 1:]
497
+ ) / 4.0
498
+ shapes = []
499
+ for i in range(valores_celula.shape[0]):
500
+ for j in range(valores_celula.shape[1]):
501
+ if not mascara_centros[i, j]:
502
+ continue
503
+ valor = valores_celula[i, j]
504
+ if not np.isfinite(valor):
505
+ continue
506
+ cor = str(colormap(float(valor)))
507
+ valor_fmt = f"{float(valor):.3f}".replace(".", ",")
508
+ shapes.append(
509
+ {
510
+ "type": "polygon",
511
+ "coords": [
512
+ [float(grid_lat[i]), float(grid_lon[j])],
513
+ [float(grid_lat[i]), float(grid_lon[j + 1])],
514
+ [float(grid_lat[i + 1]), float(grid_lon[j + 1])],
515
+ [float(grid_lat[i + 1]), float(grid_lon[j])],
516
+ ],
517
+ "color": cor,
518
+ "weight": 0,
519
+ "fill": True,
520
+ "fill_color": cor,
521
+ "fill_opacity": 0.6,
522
+ "tooltip_html": (
523
+ f"<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px;'>"
524
+ f"{str(tamanho_col)} interpolado: <b>{valor_fmt}</b>"
525
+ "</div>"
526
+ ),
527
+ }
528
+ )
529
+ if shapes:
530
+ overlay_layers.append(
531
+ {
532
+ "id": "superficie_continua",
533
+ "label": "Superfície contínua",
534
+ "show": True,
535
+ "shapes": shapes,
536
+ }
537
+ )
538
+ else:
539
+ popup_cols: list[str]
540
+ if len(df_plot_pontos) <= 1200:
541
+ popup_cols = [str(c) for c in df_plot_pontos.columns]
542
+ elif tamanho_col and tamanho_col in df_plot_pontos.columns:
543
+ popup_cols = [str(tamanho_col)]
544
+ else:
545
+ popup_cols = []
546
+
547
+ market_points: list[dict[str, Any]] = []
548
+ indices_markers: list[dict[str, Any]] = []
549
+ for marker_ordem, (idx, row) in enumerate(df_plot_pontos.iterrows()):
550
+ idx_display = int(idx) if isinstance(idx, (int, np.integer)) else marker_ordem + 1
551
+ popup_html, popup_width = _montar_popup_registro_em_colunas(
552
+ idx_display,
553
+ row,
554
+ popup_cols,
555
+ max_itens_coluna=8,
556
+ popup_uid=f"mesa-pop-elab-{marker_ordem}",
557
+ )
558
+ tooltip_html = (
559
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:14px; line-height:1.7; padding:2px 4px;'>"
560
+ f"<b>Índice {idx_display}</b>"
561
+ )
562
+ if tamanho_col and tamanho_col in df_plot_pontos.columns:
563
+ valor_tooltip = row[tamanho_col]
564
+ valor_texto = (
565
+ f"{float(valor_tooltip):.2f}"
566
+ if isinstance(valor_tooltip, (int, float, np.integer, np.floating)) and np.isfinite(float(valor_tooltip))
567
+ else str(valor_tooltip)
568
+ )
569
+ tooltip_html += (
570
+ f"<br><span style='color:#555;'>{str(tamanho_col)}:</span> "
571
+ f"<b>{valor_texto}</b>"
572
+ )
573
+ tooltip_html += "</div>"
574
+
575
+ cor = COR_PRINCIPAL
576
+ if colormap and cor_col_resolvida and cor_col_resolvida in df_mapa.columns:
577
+ valor_cor = pd.to_numeric(pd.Series([row.get(cor_col_resolvida)]), errors="coerce").iloc[0]
578
+ if pd.notna(valor_cor):
579
+ cor = str(colormap(float(valor_cor)))
580
+
581
+ if idx == indice_destacado:
582
+ raio = raio_max + 4.0
583
+ elif tamanho_func and tamanho_col and tamanho_col in row.index and pd.notna(row[tamanho_col]):
584
+ raio = float(tamanho_func(float(row[tamanho_col])))
585
+ else:
586
+ raio = 4.0
587
+ stroke_weight = 3.0 if idx == indice_destacado else 1.0
588
+ fill_opacity = 0.8 if idx == indice_destacado else 0.6
589
+
590
+ market_points.append(
591
+ {
592
+ "lat": float(row[lat_plot_col]),
593
+ "lon": float(row[lon_plot_col]),
594
+ "indice": idx_display,
595
+ "color": cor,
596
+ "base_radius": float(max(1.0, raio)),
597
+ "stroke_color": "#000000",
598
+ "stroke_weight": float(stroke_weight),
599
+ "fill_opacity": float(fill_opacity),
600
+ "tooltip_html": tooltip_html,
601
+ "popup_html": popup_html,
602
+ "popup_max_width": int(popup_width),
603
+ }
604
+ )
605
+
606
+ if mostrar_indices:
607
+ indices_markers.append(
608
+ {
609
+ "lat": float(row[lat_plot_col]),
610
+ "lon": float(row[lon_plot_col]),
611
+ "marker_html": (
612
+ '<div style="transform: translate(10px, -14px);display:inline-block;background: rgba(255, 255, 255, 0.9);'
613
+ + 'border: 1px solid rgba(28, 45, 66, 0.45);border-radius: 10px;padding: 1px 6px;font-size: 11px;'
614
+ + 'font-weight: 700;line-height: 1.2;color: #1f2f44;white-space: nowrap;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);'
615
+ + 'pointer-events: none;">'
616
+ + f"{idx_display}"
617
+ + "</div>"
618
+ ),
619
+ "icon_size": [72, 24],
620
+ "icon_anchor": [0, 0],
621
+ "class_name": "mesa-indice-label",
622
+ "interactive": False,
623
+ "keyboard": False,
624
+ }
625
+ )
626
+
627
+ overlay_layers.append(
628
+ {
629
+ "id": "mercado",
630
+ "label": "Mercado",
631
+ "show": True,
632
+ "points": market_points,
633
+ }
634
+ )
635
+ if mostrar_indices and indices_markers:
636
+ overlay_layers.append(
637
+ {
638
+ "id": "indices",
639
+ "label": "Índices",
640
+ "show": False,
641
+ "markers": indices_markers,
642
+ }
643
+ )
644
+
645
+ notice = None
646
+ if houve_amostragem:
647
+ notice = {
648
+ "message": f"Exibindo {len(df_mapa)} de {total_pontos} pontos para melhor desempenho.",
649
+ "position": "topright",
650
+ }
651
+
652
+ return build_leaflet_payload(
653
+ bounds=_resolver_bounds(df_mapa, str(lat_real), str(lon_real)),
654
+ center=[centro_lat, centro_lon],
655
+ legend=legend,
656
+ notice=notice,
657
+ overlay_layers=overlay_layers,
658
+ show_bairros=True,
659
+ bairros_geojson_url=bairros_geojson_url,
660
+ )
661
+
662
+
663
+ def build_visualizacao_map_payload(
664
+ df: pd.DataFrame,
665
+ *,
666
+ cor_col: str | None = None,
667
+ tamanho_col: str | None = None,
668
+ col_y: str | None = None,
669
+ avaliandos_tecnicos: list[dict[str, Any]] | None = None,
670
+ bairros_geojson_url: str = "/api/visualizacao/map/bairros.geojson",
671
+ ) -> dict[str, Any] | None:
672
+ lat_real = _detectar_coluna(df, _LAT_ALIASES)
673
+ lon_real = _detectar_coluna(df, _LON_ALIASES)
674
+ if lat_real is None or lon_real is None:
675
+ return None
676
+
677
+ df_mapa = df.copy()
678
+ lat_key = "__mesa_lat__"
679
+ lon_key = "__mesa_lon__"
680
+ lat_serie = _primeira_serie_por_nome(df_mapa, lat_real)
681
+ lon_serie = _primeira_serie_por_nome(df_mapa, lon_real)
682
+ if lat_serie is None or lon_serie is None:
683
+ return None
684
+
685
+ df_mapa[lat_key] = pd.to_numeric(lat_serie, errors="coerce")
686
+ df_mapa[lon_key] = pd.to_numeric(lon_serie, errors="coerce")
687
+ df_mapa = df_mapa.dropna(subset=[lat_key, lon_key])
688
+ df_mapa = df_mapa[
689
+ (df_mapa[lat_key] >= -90.0)
690
+ & (df_mapa[lat_key] <= 90.0)
691
+ & (df_mapa[lon_key] >= -180.0)
692
+ & (df_mapa[lon_key] <= 180.0)
693
+ ].copy()
694
+ if df_mapa.empty:
695
+ return None
696
+
697
+ centro_lat = float(df_mapa[lat_key].median())
698
+ centro_lon = float(df_mapa[lon_key].median())
699
+
700
+ cor_col_resolvida = cor_col
701
+ if tamanho_col and tamanho_col != "Visualização Padrão" and not cor_col_resolvida:
702
+ cor_col_resolvida = tamanho_col
703
+
704
+ colormap = None
705
+ cor_key = None
706
+ legend = None
707
+ if cor_col_resolvida and cor_col_resolvida in df_mapa.columns:
708
+ serie_cor = _primeira_serie_por_nome(df_mapa, cor_col_resolvida)
709
+ if serie_cor is not None:
710
+ cor_key = "__mesa_cor__"
711
+ df_mapa[cor_key] = pd.to_numeric(serie_cor, errors="coerce")
712
+ vmin = df_mapa[cor_key].min()
713
+ vmax = df_mapa[cor_key].max()
714
+ if pd.notna(vmin) and pd.notna(vmax):
715
+ colormap = cm.LinearColormap(
716
+ colors=["#2ecc71", "#a8e06c", "#f1c40f", "#e67e22", "#e74c3c"],
717
+ vmin=vmin,
718
+ vmax=vmax,
719
+ caption=cor_col_resolvida,
720
+ )
721
+ legend = {
722
+ "title": str(cor_col_resolvida),
723
+ "vmin": float(vmin),
724
+ "vmax": float(vmax),
725
+ "colors": ["#2ecc71", "#a8e06c", "#f1c40f", "#e67e22", "#e74c3c"],
726
+ }
727
+
728
+ raio_min, raio_max = 3.0, 18.0
729
+ tamanho_func = None
730
+ tamanho_key = None
731
+ if tamanho_col and tamanho_col != "Visualização Padrão" and tamanho_col in df_mapa.columns:
732
+ serie_tamanho = _primeira_serie_por_nome(df_mapa, tamanho_col)
733
+ if serie_tamanho is not None:
734
+ tamanho_key = "__mesa_tamanho__"
735
+ df_mapa[tamanho_key] = pd.to_numeric(serie_tamanho, errors="coerce")
736
+ t_min = df_mapa[tamanho_key].min()
737
+ t_max = df_mapa[tamanho_key].max()
738
+ if pd.notna(t_min) and pd.notna(t_max):
739
+ if t_max > t_min:
740
+ tamanho_func = (
741
+ lambda v, _min=t_min, _max=t_max: raio_min
742
+ + (v - _min) / (_max - _min) * (raio_max - raio_min)
743
+ )
744
+ else:
745
+ tamanho_func = lambda v: (raio_min + raio_max) / 2.0
746
+
747
+ show_indices = False
748
+ lat_plot_key = "__mesa_lat_plot__"
749
+ lon_plot_key = "__mesa_lon_plot__"
750
+ df_plot_pontos = _aplicar_jitter_sobrepostos(
751
+ df_mapa,
752
+ lat_col=lat_key,
753
+ lon_col=lon_key,
754
+ lat_plot_col=lat_plot_key,
755
+ lon_plot_col=lon_plot_key,
756
+ )
757
+
758
+ tooltip_col = None
759
+ tooltip_key = None
760
+ if tamanho_key:
761
+ tooltip_col = tamanho_col
762
+ tooltip_key = tamanho_key
763
+ elif col_y and col_y in df_mapa.columns:
764
+ serie_tooltip = _primeira_serie_por_nome(df_mapa, col_y)
765
+ if serie_tooltip is not None:
766
+ tooltip_col = col_y
767
+ tooltip_key = "__mesa_tooltip__"
768
+ df_mapa[tooltip_key] = serie_tooltip
769
+ df_plot_pontos[tooltip_key] = df_mapa.loc[df_plot_pontos.index, tooltip_key]
770
+
771
+ total_pontos_plot = len(df_plot_pontos)
772
+ raio_padrao = 4.0 if total_pontos_plot <= 2500 else 3.0
773
+
774
+ market_points: list[dict[str, Any]] = []
775
+ for idx, row in df_plot_pontos.iterrows():
776
+ cor = colormap(row[cor_key]) if colormap and cor_key and pd.notna(row[cor_key]) else COR_PRINCIPAL
777
+ if tamanho_func and tamanho_key and pd.notna(row[tamanho_key]):
778
+ raio = float(tamanho_func(row[tamanho_key]))
779
+ else:
780
+ raio = raio_padrao
781
+
782
+ idx_display = int(row["index"]) if "index" in row.index else int(idx)
783
+ tooltip_payload = {
784
+ "title": f"Índice {idx_display}",
785
+ "label": str(tooltip_col or ""),
786
+ "value": (
787
+ _formatar_tooltip_valor(str(tooltip_col or ""), row[tooltip_key])
788
+ if tooltip_col and tooltip_key and tooltip_key in row.index
789
+ else ""
790
+ ),
791
+ }
792
+ row_id_raw = row["__mesa_row_id__"] if "__mesa_row_id__" in row.index else None
793
+ market_points.append(
794
+ {
795
+ "lat": float(row[lat_plot_key]),
796
+ "lon": float(row[lon_plot_key]),
797
+ "indice": idx_display,
798
+ "row_id": int(row_id_raw) if row_id_raw is not None and pd.notna(row_id_raw) else None,
799
+ "color": str(cor),
800
+ "base_radius": float(max(1.0, raio)),
801
+ "tooltip": tooltip_payload,
802
+ }
803
+ )
804
+
805
+ return {
806
+ "type": "mesa_leaflet_payload",
807
+ "version": 1,
808
+ "center": [centro_lat, centro_lon],
809
+ "bounds": _resolver_bounds(df_mapa, lat_key, lon_key),
810
+ "tile_layers": _TILE_LAYERS,
811
+ "controls": {
812
+ "fullscreen": True,
813
+ "measure": True,
814
+ "layer_control": True,
815
+ },
816
+ "radius_behavior": {
817
+ "min_radius": 1.6,
818
+ "max_radius": 52.0,
819
+ "reference_zoom": 12.0,
820
+ "growth_factor": 0.20,
821
+ },
822
+ "show_indices": show_indices,
823
+ "show_bairros": True,
824
+ "bairros_geojson_url": bairros_geojson_url,
825
+ "legend": legend,
826
+ "market_points": market_points,
827
+ "trabalhos_tecnicos_points": build_trabalhos_tecnicos_marker_payloads(avaliandos_tecnicos),
828
+ }
backend/app/services/elaboracao_service.py CHANGED
@@ -53,6 +53,7 @@ from app.core.elaboracao.formatadores import (
53
  formatar_micronumerosidade_html,
54
  formatar_outliers_anteriores_html,
55
  )
 
56
  from app.models.session import SessionState
57
  from app.runtime_config import resolve_core_path
58
  from app.services import model_repository
@@ -899,6 +900,19 @@ def _render_mapa_if_enabled(session: SessionState, df: pd.DataFrame | None, **kw
899
  return charts.criar_mapa(df, **kwargs)
900
 
901
 
 
 
 
 
 
 
 
 
 
 
 
 
 
902
  def classificar_tipos_variaveis_x(session: SessionState, colunas_x: list[str] | None) -> dict[str, Any]:
903
  df = session.df_original if session.df_original is not None else session.df_filtrado
904
  if df is None:
@@ -1020,11 +1034,11 @@ def _set_dataframe_base(
1020
 
1021
  colunas_numericas = [str(c) for c in obter_colunas_numericas(df)]
1022
  coluna_y_padrao = identificar_coluna_y_padrao(df)
1023
- mapa_html = _render_mapa_if_enabled(session, df)
1024
 
1025
  return {
1026
  "dados": dataframe_to_payload(df, decimals=4),
1027
- "mapa_html": mapa_html,
1028
  "colunas_numericas": colunas_numericas,
1029
  "coluna_y_padrao": coluna_y_padrao,
1030
  "colunas_area_candidatas": detectar_colunas_area(df),
@@ -1308,7 +1322,7 @@ def apply_selection(
1308
  },
1309
  "transformacao_y": session.transformacao_y,
1310
  "transform_fields": transform_fields,
1311
- "mapa_html": _render_mapa_if_enabled(session, df_filtrado),
1312
  "contexto": _selection_context(session),
1313
  }
1314
 
@@ -2328,16 +2342,25 @@ def detalhes_knn_avaliacao_elaboracao(session: SessionState, valores_x: dict[str
2328
  tabela_payload = dataframe_to_payload(df_vizinhos, decimals=4, max_rows=None)
2329
 
2330
  # Import local evita acoplamento no carregamento inicial do módulo.
2331
- from app.services.visualizacao_service import _coordenadas_de_valores_knn, _criar_mapa_knn_destaque
2332
 
2333
  aval_lat, aval_lon = _coordenadas_de_valores_knn(entradas)
2334
- mapa_html = _criar_mapa_knn_destaque(
2335
  df_knn,
2336
  posicoes_vizinhos,
2337
  coluna_y_knn,
2338
  avaliando_lat=aval_lat,
2339
  avaliando_lon=aval_lon,
2340
  )
 
 
 
 
 
 
 
 
 
2341
  avaliando = [{"variavel": str(col), "valor": entradas.get(col)} for col in colunas_x]
2342
  if session.coluna_area and session.coluna_area not in colunas_x:
2343
  avaliando.append({"variavel": f"{session.coluna_area} (area)", "valor": valor_area})
@@ -2350,6 +2373,7 @@ def detalhes_knn_avaliacao_elaboracao(session: SessionState, valores_x: dict[str
2350
  return sanitize_value(
2351
  {
2352
  "mapa_html": mapa_html,
 
2353
  "avaliando": avaliando,
2354
  "vizinhos_tabela": tabela_payload,
2355
  "knn": resultado_knn,
@@ -2612,7 +2636,8 @@ def atualizar_mapa(session: SessionState, var_mapa: str | None, modo_mapa: str |
2612
  modo = "pontos"
2613
  session.mapa_habilitado = True
2614
  mapa_html = charts.criar_mapa(df, tamanho_col=tamanho_col, modo=modo)
2615
- return {"mapa_html": mapa_html}
 
2616
 
2617
 
2618
  def _normalizar_extremo_abs_residuos(valor: float | None) -> float | None:
@@ -2690,8 +2715,20 @@ def atualizar_mapa_residuos(
2690
  cor_tick_values=ticks_valores,
2691
  cor_tick_labels=ticks_labels,
2692
  )
 
 
 
 
 
 
 
 
 
 
 
2693
  return {
2694
  "mapa_html": mapa_html,
 
2695
  "variavel_mapa": var_escolhida,
2696
  "modo_mapa": modo,
2697
  "escala_extremo_abs": extremo_abs,
@@ -2759,7 +2796,7 @@ def mapear_coordenadas_manualmente(session: SessionState, col_lat: str, col_lon:
2759
 
2760
  return {
2761
  "status": "Coordenadas mapeadas com sucesso",
2762
- "mapa_html": _render_mapa_if_enabled(session, df_filtrado),
2763
  "dados": dataframe_to_payload(df_filtrado, decimals=4),
2764
  "coords": _build_coords_payload(df_novo, True),
2765
  }
@@ -2790,7 +2827,7 @@ def geocodificar(session: SessionState, col_cdlog: str, col_num: str, auto_200:
2790
  "status_html": status_html,
2791
  "falhas_html": falhas_html,
2792
  "falhas_para_correcao": dataframe_to_payload(df_correcoes, decimals=None),
2793
- "mapa_html": _render_mapa_if_enabled(session, session.df_filtrado),
2794
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
2795
  "coords": _build_coords_payload(session.df_original, True),
2796
  }
@@ -2822,7 +2859,7 @@ def reiniciar_geocodificacao(session: SessionState) -> dict[str, Any]:
2822
  "status_html": "",
2823
  "falhas_html": "",
2824
  "falhas_para_correcao": dataframe_to_payload(pd.DataFrame(), decimals=None),
2825
- "mapa_html": _render_mapa_if_enabled(session, session.df_filtrado),
2826
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
2827
  "coords": _build_coords_payload(df_base, tem_coords),
2828
  }
@@ -2849,7 +2886,7 @@ def excluir_coordenadas_para_geocodificacao(session: SessionState) -> dict[str,
2849
  "status_html": "",
2850
  "falhas_html": "",
2851
  "falhas_para_correcao": dataframe_to_payload(pd.DataFrame(), decimals=None),
2852
- "mapa_html": _render_mapa_if_enabled(session, session.df_filtrado),
2853
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
2854
  "coords": _build_coords_payload(session.df_original, False),
2855
  }
@@ -2920,7 +2957,7 @@ def aplicar_correcoes_geocodificacao(
2920
  "status_html": status_html,
2921
  "falhas_html": falhas_html,
2922
  "falhas_para_correcao": dataframe_to_payload(df_correcoes, decimals=None),
2923
- "mapa_html": _render_mapa_if_enabled(session, session.df_filtrado),
2924
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
2925
  "coords": _build_coords_payload(session.df_original, True),
2926
  }
@@ -2938,7 +2975,7 @@ def limpar_historico_outliers(session: SessionState) -> dict[str, Any]:
2938
 
2939
  return {
2940
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
2941
- "mapa_html": _render_mapa_if_enabled(session, session.df_filtrado),
2942
  "outliers_html": "",
2943
  "resumo_outliers": "Excluidos: 0 | A excluir: 0 | A reincluir: 0 | Total: 0",
2944
  "contexto": _selection_context(session),
 
53
  formatar_micronumerosidade_html,
54
  formatar_outliers_anteriores_html,
55
  )
56
+ from app.core.visualizacao.map_payload import build_elaboracao_map_payload
57
  from app.models.session import SessionState
58
  from app.runtime_config import resolve_core_path
59
  from app.services import model_repository
 
900
  return charts.criar_mapa(df, **kwargs)
901
 
902
 
903
+ def _build_mapa_payload_if_enabled(session: SessionState, df: pd.DataFrame | None, **kwargs: Any) -> dict[str, Any] | None:
904
+ if not session.mapa_habilitado or df is None:
905
+ return None
906
+ return build_elaboracao_map_payload(df, **kwargs)
907
+
908
+
909
+ def _mapa_bundle_if_enabled(session: SessionState, df: pd.DataFrame | None, **kwargs: Any) -> dict[str, Any]:
910
+ return {
911
+ "mapa_html": _render_mapa_if_enabled(session, df, **kwargs),
912
+ "mapa_payload": _build_mapa_payload_if_enabled(session, df, **kwargs),
913
+ }
914
+
915
+
916
  def classificar_tipos_variaveis_x(session: SessionState, colunas_x: list[str] | None) -> dict[str, Any]:
917
  df = session.df_original if session.df_original is not None else session.df_filtrado
918
  if df is None:
 
1034
 
1035
  colunas_numericas = [str(c) for c in obter_colunas_numericas(df)]
1036
  coluna_y_padrao = identificar_coluna_y_padrao(df)
1037
+ mapa_bundle = _mapa_bundle_if_enabled(session, df)
1038
 
1039
  return {
1040
  "dados": dataframe_to_payload(df, decimals=4),
1041
+ **mapa_bundle,
1042
  "colunas_numericas": colunas_numericas,
1043
  "coluna_y_padrao": coluna_y_padrao,
1044
  "colunas_area_candidatas": detectar_colunas_area(df),
 
1322
  },
1323
  "transformacao_y": session.transformacao_y,
1324
  "transform_fields": transform_fields,
1325
+ **_mapa_bundle_if_enabled(session, df_filtrado),
1326
  "contexto": _selection_context(session),
1327
  }
1328
 
 
2342
  tabela_payload = dataframe_to_payload(df_vizinhos, decimals=4, max_rows=None)
2343
 
2344
  # Import local evita acoplamento no carregamento inicial do módulo.
2345
+ from app.services.visualizacao_service import _coordenadas_de_valores_knn, _criar_mapa_knn_destaque, _criar_payload_knn_destaque
2346
 
2347
  aval_lat, aval_lon = _coordenadas_de_valores_knn(entradas)
2348
+ mapa_payload = _criar_payload_knn_destaque(
2349
  df_knn,
2350
  posicoes_vizinhos,
2351
  coluna_y_knn,
2352
  avaliando_lat=aval_lat,
2353
  avaliando_lon=aval_lon,
2354
  )
2355
+ mapa_html = ""
2356
+ if mapa_payload is None:
2357
+ mapa_html = _criar_mapa_knn_destaque(
2358
+ df_knn,
2359
+ posicoes_vizinhos,
2360
+ coluna_y_knn,
2361
+ avaliando_lat=aval_lat,
2362
+ avaliando_lon=aval_lon,
2363
+ )
2364
  avaliando = [{"variavel": str(col), "valor": entradas.get(col)} for col in colunas_x]
2365
  if session.coluna_area and session.coluna_area not in colunas_x:
2366
  avaliando.append({"variavel": f"{session.coluna_area} (area)", "valor": valor_area})
 
2373
  return sanitize_value(
2374
  {
2375
  "mapa_html": mapa_html,
2376
+ "mapa_payload": mapa_payload,
2377
  "avaliando": avaliando,
2378
  "vizinhos_tabela": tabela_payload,
2379
  "knn": resultado_knn,
 
2636
  modo = "pontos"
2637
  session.mapa_habilitado = True
2638
  mapa_html = charts.criar_mapa(df, tamanho_col=tamanho_col, modo=modo)
2639
+ mapa_payload = build_elaboracao_map_payload(df, tamanho_col=tamanho_col, modo=modo)
2640
+ return {"mapa_html": mapa_html, "mapa_payload": mapa_payload}
2641
 
2642
 
2643
  def _normalizar_extremo_abs_residuos(valor: float | None) -> float | None:
 
2715
  cor_tick_values=ticks_valores,
2716
  cor_tick_labels=ticks_labels,
2717
  )
2718
+ mapa_payload = build_elaboracao_map_payload(
2719
+ df,
2720
+ tamanho_col=var_escolhida,
2721
+ modo=modo,
2722
+ cor_vmin=cor_vmin,
2723
+ cor_vmax=cor_vmax,
2724
+ cor_caption=caption,
2725
+ cor_colors=["#2e7d32", "#f1c40f", "#ffffff", "#f1c40f", "#c62828"],
2726
+ cor_tick_values=ticks_valores,
2727
+ cor_tick_labels=ticks_labels,
2728
+ )
2729
  return {
2730
  "mapa_html": mapa_html,
2731
+ "mapa_payload": mapa_payload,
2732
  "variavel_mapa": var_escolhida,
2733
  "modo_mapa": modo,
2734
  "escala_extremo_abs": extremo_abs,
 
2796
 
2797
  return {
2798
  "status": "Coordenadas mapeadas com sucesso",
2799
+ **_mapa_bundle_if_enabled(session, df_filtrado),
2800
  "dados": dataframe_to_payload(df_filtrado, decimals=4),
2801
  "coords": _build_coords_payload(df_novo, True),
2802
  }
 
2827
  "status_html": status_html,
2828
  "falhas_html": falhas_html,
2829
  "falhas_para_correcao": dataframe_to_payload(df_correcoes, decimals=None),
2830
+ **_mapa_bundle_if_enabled(session, session.df_filtrado),
2831
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
2832
  "coords": _build_coords_payload(session.df_original, True),
2833
  }
 
2859
  "status_html": "",
2860
  "falhas_html": "",
2861
  "falhas_para_correcao": dataframe_to_payload(pd.DataFrame(), decimals=None),
2862
+ **_mapa_bundle_if_enabled(session, session.df_filtrado),
2863
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
2864
  "coords": _build_coords_payload(df_base, tem_coords),
2865
  }
 
2886
  "status_html": "",
2887
  "falhas_html": "",
2888
  "falhas_para_correcao": dataframe_to_payload(pd.DataFrame(), decimals=None),
2889
+ **_mapa_bundle_if_enabled(session, session.df_filtrado),
2890
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
2891
  "coords": _build_coords_payload(session.df_original, False),
2892
  }
 
2957
  "status_html": status_html,
2958
  "falhas_html": falhas_html,
2959
  "falhas_para_correcao": dataframe_to_payload(df_correcoes, decimals=None),
2960
+ **_mapa_bundle_if_enabled(session, session.df_filtrado),
2961
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
2962
  "coords": _build_coords_payload(session.df_original, True),
2963
  }
 
2975
 
2976
  return {
2977
  "dados": dataframe_to_payload(session.df_filtrado, decimals=4),
2978
+ **_mapa_bundle_if_enabled(session, session.df_filtrado),
2979
  "outliers_html": "",
2980
  "resumo_outliers": "Excluidos: 0 | A excluir: 0 | A reincluir: 0 | Total: 0",
2981
  "contexto": _selection_context(session),
backend/app/services/pesquisa_service.py CHANGED
@@ -24,9 +24,11 @@ from app.core.shapefile_runtime import load_attribute_records
24
  from app.core.elaboracao.core import _migrar_pacote_v1_para_v2, normalizar_observacao_modelo
25
  from app.core.map_layers import (
26
  add_bairros_layer,
 
27
  add_trabalhos_tecnicos_markers,
28
  add_zoom_responsive_circle_markers,
29
  )
 
30
  from app.portable_runtime_log import append_runtime_log
31
  from app.runtime_config import resolve_core_path
32
  from app.services import model_repository, trabalhos_tecnicos_service
@@ -227,15 +229,19 @@ _ADMIN_CONFIG_LOCK = Lock()
227
  _CACHE_SOURCE_SIGNATURE: str | None = None
228
  _ADMIN_FONTES_SESSION: dict[str, list[str]] = {}
229
  _CATALOGO_VIAS_CACHE: list[dict[str, Any]] | None = None
 
 
230
 
231
 
232
  def _resolver_repositorio_modelos() -> model_repository.ModelRepositoryResolution:
233
- global _CACHE_SOURCE_SIGNATURE
234
  resolved = model_repository.resolve_model_repository()
235
  with _CACHE_LOCK:
236
  if _CACHE_SOURCE_SIGNATURE != resolved.signature:
237
  _CACHE.clear()
238
  _CACHE_SOURCE_SIGNATURE = resolved.signature
 
 
239
  return resolved
240
 
241
 
@@ -622,6 +628,46 @@ def _montar_familias_versoes_modelos(caminhos_modelo: list[Path]) -> dict[str, l
622
  return familias
623
 
624
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
625
  def _agrupar_nomes_modelo_por_familia(values: list[Any]) -> dict[str, list[str]]:
626
  familias: dict[str, list[str]] = {}
627
  for value in values:
@@ -929,7 +975,7 @@ def gerar_mapa_modelos(
929
  total_pontos = 0
930
  for modelo in modelos_plotados:
931
  total_pontos += int(modelo["total_pontos"])
932
- mapa_html = _renderizar_mapa_modelos(
933
  modelos_plotados,
934
  bounds,
935
  avaliandos_geo,
@@ -937,6 +983,16 @@ def gerar_mapa_modelos(
937
  avaliandos_tecnicos_proximos=avaliandos_tecnicos_proximos,
938
  trabalhos_tecnicos_raio_m=trabalhos_tecnicos_raio_m_norm,
939
  )
 
 
 
 
 
 
 
 
 
 
940
 
941
  total_trabalhos_modelos = len(
942
  {
@@ -964,6 +1020,9 @@ def gerar_mapa_modelos(
964
  "mapa_html": mapa_html,
965
  "mapa_html_pontos": mapa_html if modo_exibicao_norm == "pontos" else "",
966
  "mapa_html_cobertura": mapa_html if modo_exibicao_norm == "cobertura" else "",
 
 
 
967
  "total_modelos_plotados": len(modelos_plotados),
968
  "total_pontos": total_pontos,
969
  "modelos_plotados": [
@@ -1053,6 +1112,224 @@ def _montar_tooltip_distancia_modelo(modelo: dict[str, Any]) -> str:
1053
  return ""
1054
 
1055
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1056
  def _renderizar_mapa_modelos(
1057
  modelos_plotados: list[dict[str, Any]],
1058
  bounds: list[list[float]],
 
24
  from app.core.elaboracao.core import _migrar_pacote_v1_para_v2, normalizar_observacao_modelo
25
  from app.core.map_layers import (
26
  add_bairros_layer,
27
+ build_trabalhos_tecnicos_marker_payloads,
28
  add_trabalhos_tecnicos_markers,
29
  add_zoom_responsive_circle_markers,
30
  )
31
+ from app.core.visualizacao.map_payload import build_leaflet_payload
32
  from app.portable_runtime_log import append_runtime_log
33
  from app.runtime_config import resolve_core_path
34
  from app.services import model_repository, trabalhos_tecnicos_service
 
229
  _CACHE_SOURCE_SIGNATURE: str | None = None
230
  _ADMIN_FONTES_SESSION: dict[str, list[str]] = {}
231
  _CATALOGO_VIAS_CACHE: list[dict[str, Any]] | None = None
232
+ _FAMILIAS_VERSOES_CACHE: dict[str, list[dict[str, Any]]] | None = None
233
+ _FAMILIAS_VERSOES_SIGNATURE: tuple[str, tuple[tuple[str, int, int], ...]] | None = None
234
 
235
 
236
  def _resolver_repositorio_modelos() -> model_repository.ModelRepositoryResolution:
237
+ global _CACHE_SOURCE_SIGNATURE, _FAMILIAS_VERSOES_CACHE, _FAMILIAS_VERSOES_SIGNATURE
238
  resolved = model_repository.resolve_model_repository()
239
  with _CACHE_LOCK:
240
  if _CACHE_SOURCE_SIGNATURE != resolved.signature:
241
  _CACHE.clear()
242
  _CACHE_SOURCE_SIGNATURE = resolved.signature
243
+ _FAMILIAS_VERSOES_CACHE = None
244
+ _FAMILIAS_VERSOES_SIGNATURE = None
245
  return resolved
246
 
247
 
 
628
  return familias
629
 
630
 
631
+ def _assinatura_modelos_familias(caminhos_modelo: list[Path]) -> tuple[tuple[str, int, int], ...]:
632
+ assinatura: list[tuple[str, int, int]] = []
633
+ for caminho in sorted(caminhos_modelo, key=lambda item: item.name.lower()):
634
+ try:
635
+ stat = caminho.stat()
636
+ except OSError:
637
+ continue
638
+ assinatura.append((caminho.name, int(stat.st_mtime_ns), int(stat.st_size)))
639
+ return tuple(assinatura)
640
+
641
+
642
+ def obter_familias_versoes_modelos_cache(caminhos_modelo: list[Path] | None = None) -> dict[str, list[dict[str, Any]]]:
643
+ global _FAMILIAS_VERSOES_CACHE, _FAMILIAS_VERSOES_SIGNATURE
644
+
645
+ resolved = _resolver_repositorio_modelos()
646
+ caminhos = list(caminhos_modelo) if caminhos_modelo is not None else list(resolved.modelos_dir.glob("*.dai"))
647
+ assinatura = (resolved.signature, _assinatura_modelos_familias(caminhos))
648
+
649
+ with _CACHE_LOCK:
650
+ if _FAMILIAS_VERSOES_SIGNATURE == assinatura and isinstance(_FAMILIAS_VERSOES_CACHE, dict):
651
+ return {
652
+ chave: [dict(item) for item in itens]
653
+ for chave, itens in _FAMILIAS_VERSOES_CACHE.items()
654
+ }
655
+
656
+ familias = _montar_familias_versoes_modelos(caminhos)
657
+
658
+ with _CACHE_LOCK:
659
+ _FAMILIAS_VERSOES_SIGNATURE = assinatura
660
+ _FAMILIAS_VERSOES_CACHE = {
661
+ chave: [dict(item) for item in itens]
662
+ for chave, itens in familias.items()
663
+ }
664
+
665
+ return {
666
+ chave: [dict(item) for item in itens]
667
+ for chave, itens in familias.items()
668
+ }
669
+
670
+
671
  def _agrupar_nomes_modelo_por_familia(values: list[Any]) -> dict[str, list[str]]:
672
  familias: dict[str, list[str]] = {}
673
  for value in values:
 
975
  total_pontos = 0
976
  for modelo in modelos_plotados:
977
  total_pontos += int(modelo["total_pontos"])
978
+ mapa_payload = _build_mapa_modelos_payload(
979
  modelos_plotados,
980
  bounds,
981
  avaliandos_geo,
 
983
  avaliandos_tecnicos_proximos=avaliandos_tecnicos_proximos,
984
  trabalhos_tecnicos_raio_m=trabalhos_tecnicos_raio_m_norm,
985
  )
986
+ mapa_html = ""
987
+ if mapa_payload is None:
988
+ mapa_html = _renderizar_mapa_modelos(
989
+ modelos_plotados,
990
+ bounds,
991
+ avaliandos_geo,
992
+ modo_exibicao_norm,
993
+ avaliandos_tecnicos_proximos=avaliandos_tecnicos_proximos,
994
+ trabalhos_tecnicos_raio_m=trabalhos_tecnicos_raio_m_norm,
995
+ )
996
 
997
  total_trabalhos_modelos = len(
998
  {
 
1020
  "mapa_html": mapa_html,
1021
  "mapa_html_pontos": mapa_html if modo_exibicao_norm == "pontos" else "",
1022
  "mapa_html_cobertura": mapa_html if modo_exibicao_norm == "cobertura" else "",
1023
+ "mapa_payload": mapa_payload,
1024
+ "mapa_payload_pontos": mapa_payload if modo_exibicao_norm == "pontos" else None,
1025
+ "mapa_payload_cobertura": mapa_payload if modo_exibicao_norm == "cobertura" else None,
1026
  "total_modelos_plotados": len(modelos_plotados),
1027
  "total_pontos": total_pontos,
1028
  "modelos_plotados": [
 
1112
  return ""
1113
 
1114
 
1115
+ def _tooltip_mapa_modelo_html(modelo: dict[str, Any]) -> str:
1116
+ nome = str(modelo.get("nome") or "Modelo").strip() or "Modelo"
1117
+ tooltip_distancia = _montar_tooltip_distancia_modelo(modelo)
1118
+ if tooltip_distancia:
1119
+ return (
1120
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.5;'>"
1121
+ f"<b>{escape(nome)}</b><br>{escape(tooltip_distancia)}"
1122
+ "</div>"
1123
+ )
1124
+ return (
1125
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.5;'>"
1126
+ f"<b>{escape(nome)}</b>"
1127
+ "</div>"
1128
+ )
1129
+
1130
+
1131
+ def _marker_payloads_avaliandos(avaliandos_geo: list[dict[str, Any]]) -> list[dict[str, Any]]:
1132
+ payloads: list[dict[str, Any]] = []
1133
+ for idx, avaliando in enumerate(avaliandos_geo):
1134
+ lat_item, lon_item = _normalizar_coordenadas_avaliando(avaliando.get("lat"), avaliando.get("lon"))
1135
+ if lat_item is None or lon_item is None:
1136
+ continue
1137
+ label = str(avaliando.get("label") or f"A{idx + 1}").strip() or f"A{idx + 1}"
1138
+ endereco = str(avaliando.get("logradouro") or "").strip()
1139
+ numero_usado = str(avaliando.get("numero_usado") or "").strip()
1140
+ tooltip = label
1141
+ if endereco:
1142
+ tooltip = f"{tooltip} • {endereco}{f', {numero_usado}' if numero_usado else ''}"
1143
+ marker_html = (
1144
+ "<div style='display:flex;align-items:center;justify-content:center;"
1145
+ "width:28px;height:28px;border-radius:999px;background:#c62828;color:#fff;"
1146
+ "font-weight:800;font-size:12px;border:2px solid rgba(255,255,255,0.95);"
1147
+ "box-shadow:0 2px 6px rgba(0,0,0,0.18);'>"
1148
+ f"{escape(label)}"
1149
+ "</div>"
1150
+ )
1151
+ payloads.append(
1152
+ {
1153
+ "lat": float(lat_item),
1154
+ "lon": float(lon_item),
1155
+ "tooltip_html": escape(tooltip),
1156
+ "marker_html": marker_html,
1157
+ "icon_size": [28, 28],
1158
+ "icon_anchor": [14, 14],
1159
+ "class_name": "mesa-avaliando-marker",
1160
+ }
1161
+ )
1162
+ return payloads
1163
+
1164
+
1165
+ def _shapes_modelo_payload(
1166
+ modelo: dict[str, Any],
1167
+ aval_lat: float | None,
1168
+ aval_lon: float | None,
1169
+ ) -> list[dict[str, Any]]:
1170
+ geometria = modelo.get("geometria") or {}
1171
+ geom_wgs84 = geometria.get("geom_wgs84")
1172
+ if geom_wgs84 is None:
1173
+ return []
1174
+
1175
+ tooltip = _tooltip_mapa_modelo_html(modelo)
1176
+ cor = str(modelo.get("cor") or "#1f77b4")
1177
+ geom_type = str(geometria.get("geom_type") or "")
1178
+ shapes: list[dict[str, Any]] = []
1179
+
1180
+ try:
1181
+ if geom_type == "Polygon":
1182
+ coords = [[float(lat), float(lon)] for lon, lat in list(geom_wgs84.exterior.coords)]
1183
+ shapes.append({"type": "polygon", "coords": coords, "color": cor, "fill": True, "fill_opacity": 0.12, "weight": 2, "tooltip_html": tooltip})
1184
+ elif geom_type == "MultiPolygon":
1185
+ for poligono in list(getattr(geom_wgs84, "geoms", [])):
1186
+ coords = [[float(lat), float(lon)] for lon, lat in list(poligono.exterior.coords)]
1187
+ shapes.append({"type": "polygon", "coords": coords, "color": cor, "fill": True, "fill_opacity": 0.12, "weight": 2, "tooltip_html": tooltip})
1188
+ elif geom_type == "LineString":
1189
+ coords = [[float(lat), float(lon)] for lon, lat in list(geom_wgs84.coords)]
1190
+ shapes.append({"type": "polyline", "coords": coords, "color": cor, "weight": 3, "opacity": 0.8, "tooltip_html": tooltip})
1191
+ elif geom_type == "MultiLineString":
1192
+ for linha in list(getattr(geom_wgs84, "geoms", [])):
1193
+ coords = [[float(lat), float(lon)] for lon, lat in list(linha.coords)]
1194
+ shapes.append({"type": "polyline", "coords": coords, "color": cor, "weight": 3, "opacity": 0.8, "tooltip_html": tooltip})
1195
+ elif geom_type == "Point":
1196
+ shapes.append({"type": "circlemarker", "center": [float(geom_wgs84.y), float(geom_wgs84.x)], "radius": 6, "color": cor, "fill": True, "fill_opacity": 0.7, "tooltip_html": tooltip})
1197
+ elif geom_type == "MultiPoint":
1198
+ for ponto in list(getattr(geom_wgs84, "geoms", [])):
1199
+ shapes.append({"type": "circlemarker", "center": [float(ponto.y), float(ponto.x)], "radius": 6, "color": cor, "fill": True, "fill_opacity": 0.7, "tooltip_html": tooltip})
1200
+ except Exception:
1201
+ return []
1202
+
1203
+ if aval_lat is None or aval_lon is None:
1204
+ return shapes
1205
+
1206
+ try:
1207
+ from shapely.geometry import Point
1208
+ from shapely.ops import nearest_points
1209
+ except ImportError:
1210
+ return shapes
1211
+
1212
+ try:
1213
+ aval_point = Point(float(aval_lon), float(aval_lat))
1214
+ _, nearest_geom = nearest_points(aval_point, geom_wgs84)
1215
+ distancia_km = _to_float_or_none(modelo.get("distancia_km"))
1216
+ if nearest_geom is None or distancia_km is None or distancia_km <= 0:
1217
+ return shapes
1218
+ shapes.append(
1219
+ {
1220
+ "type": "polyline",
1221
+ "coords": [
1222
+ [float(aval_point.y), float(aval_point.x)],
1223
+ [float(nearest_geom.y), float(nearest_geom.x)],
1224
+ ],
1225
+ "color": cor,
1226
+ "weight": 2,
1227
+ "opacity": 0.65,
1228
+ "dash_array": "6,6",
1229
+ "tooltip_html": f"Ligação de distância • {escape(str(modelo.get('nome') or 'Modelo'))}",
1230
+ }
1231
+ )
1232
+ except Exception:
1233
+ return shapes
1234
+
1235
+ return shapes
1236
+
1237
+
1238
+ def _build_mapa_modelos_payload(
1239
+ modelos_plotados: list[dict[str, Any]],
1240
+ bounds: list[list[float]],
1241
+ avaliandos_geo: list[dict[str, Any]],
1242
+ modo_exibicao: str,
1243
+ avaliandos_tecnicos_proximos: list[dict[str, Any]] | None = None,
1244
+ trabalhos_tecnicos_raio_m: int | None = None,
1245
+ ) -> dict[str, Any] | None:
1246
+ overlay_layers: list[dict[str, Any]] = []
1247
+ avaliando_unico = avaliandos_geo[0] if len(avaliandos_geo) == 1 else None
1248
+ aval_lat = avaliando_unico.get("lat") if avaliando_unico is not None else None
1249
+ aval_lon = avaliando_unico.get("lon") if avaliando_unico is not None else None
1250
+
1251
+ for modelo in modelos_plotados:
1252
+ layer: dict[str, Any] = {
1253
+ "id": str(modelo.get("id") or ""),
1254
+ "label": str(modelo.get("nome") or "Modelo"),
1255
+ "show": True,
1256
+ "markers": build_trabalhos_tecnicos_marker_payloads(modelo.get("avaliandos_tecnicos") or []),
1257
+ }
1258
+ if modo_exibicao == "pontos":
1259
+ tooltip_html = _tooltip_mapa_modelo_html(modelo)
1260
+ layer["points"] = [
1261
+ {
1262
+ "lat": float(ponto["lat"]),
1263
+ "lon": float(ponto["lon"]),
1264
+ "color": str(modelo.get("cor") or "#1f77b4"),
1265
+ "base_radius": 3.0,
1266
+ "stroke_color": str(modelo.get("cor") or "#1f77b4"),
1267
+ "stroke_weight": 1.0,
1268
+ "fill_opacity": 0.72,
1269
+ "tooltip_html": tooltip_html,
1270
+ }
1271
+ for ponto in (modelo.get("pontos") or [])
1272
+ ]
1273
+ else:
1274
+ layer["shapes"] = _shapes_modelo_payload(modelo, aval_lat, aval_lon)
1275
+ overlay_layers.append(layer)
1276
+
1277
+ if avaliandos_tecnicos_proximos is not None:
1278
+ label_raio = f" (até {int(trabalhos_tecnicos_raio_m or 0)} m)" if trabalhos_tecnicos_raio_m is not None else ""
1279
+ proximos_layer: dict[str, Any] = {
1280
+ "id": "trabalhos-tecnicos-proximos",
1281
+ "label": f"Trabalhos técnicos próximos{label_raio}",
1282
+ "show": True,
1283
+ "markers": build_trabalhos_tecnicos_marker_payloads(
1284
+ avaliandos_tecnicos_proximos or [],
1285
+ origem="pesquisa_mapa",
1286
+ marker_style="estrela",
1287
+ ignore_bounds=False,
1288
+ ),
1289
+ "shapes": [],
1290
+ }
1291
+ if int(trabalhos_tecnicos_raio_m or 0) > 0:
1292
+ for idx, avaliando in enumerate(avaliandos_geo):
1293
+ lat_item, lon_item = _normalizar_coordenadas_avaliando(avaliando.get("lat"), avaliando.get("lon"))
1294
+ if lat_item is None or lon_item is None:
1295
+ continue
1296
+ label = str(avaliando.get("label") or f"A{idx + 1}").strip() or f"A{idx + 1}"
1297
+ tooltip_raio = (
1298
+ f"Raio de busca: até {int(trabalhos_tecnicos_raio_m)} m"
1299
+ if len(avaliandos_geo) == 1
1300
+ else f"Raio de busca • {label}: até {int(trabalhos_tecnicos_raio_m)} m"
1301
+ )
1302
+ proximos_layer["shapes"].append(
1303
+ {
1304
+ "type": "circle",
1305
+ "center": [float(lat_item), float(lon_item)],
1306
+ "radius_m": float(trabalhos_tecnicos_raio_m),
1307
+ "color": "#c62828",
1308
+ "weight": 2,
1309
+ "opacity": 0.75,
1310
+ "fill": True,
1311
+ "fill_color": "#c62828",
1312
+ "fill_opacity": 0.06,
1313
+ "dash_array": "6,6",
1314
+ "tooltip_html": tooltip_raio,
1315
+ }
1316
+ )
1317
+ overlay_layers.append(proximos_layer)
1318
+
1319
+ avaliandos_markers = _marker_payloads_avaliandos(avaliandos_geo)
1320
+ if avaliandos_markers:
1321
+ overlay_layers.append(
1322
+ {
1323
+ "id": "avaliandos",
1324
+ "label": "Avaliando" if len(avaliandos_geo) == 1 else "Avaliandos",
1325
+ "show": True,
1326
+ "markers": avaliandos_markers,
1327
+ }
1328
+ )
1329
+
1330
+ return build_leaflet_payload(bounds=bounds, overlay_layers=overlay_layers, show_bairros=True)
1331
+
1332
+
1333
  def _renderizar_mapa_modelos(
1334
  modelos_plotados: list[dict[str, Any]],
1335
  bounds: list[list[float]],
backend/app/services/trabalhos_tecnicos_service.py CHANGED
@@ -16,9 +16,11 @@ from folium import plugins
16
  from app.core.map_layers import (
17
  add_bairros_layer,
18
  add_popup_pagination_handlers,
 
19
  add_trabalhos_tecnicos_markers,
20
  add_zoom_responsive_circle_markers,
21
  )
 
22
  from app.services import model_repository, trabalhos_tecnicos_repository
23
  from app.services.serializers import sanitize_value
24
 
@@ -872,6 +874,129 @@ def _normalizar_modo_mapa_trabalhos(value: Any) -> str:
872
  return MAPA_TRABALHOS_CLUSTERIZADO
873
 
874
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
875
  def gerar_mapa_trabalhos(
876
  trabalhos_ids: list[str] | None = None,
877
  *,
@@ -901,6 +1026,7 @@ def gerar_mapa_trabalhos(
901
  return sanitize_value(
902
  {
903
  "mapa_html": "",
 
904
  "total_trabalhos": 0,
905
  "total_imoveis": 0,
906
  "status": "Nenhum trabalho técnico georreferenciado foi encontrado para o mapa.",
@@ -977,9 +1103,11 @@ def gerar_mapa_trabalhos(
977
 
978
  total_trabalhos = len({str(item.get("trabalho_id") or "").strip() for item in pontos if str(item.get("trabalho_id") or "").strip()})
979
  total_imoveis = len(pontos)
 
980
  return sanitize_value(
981
  {
982
  "mapa_html": mapa.get_root().render(),
 
983
  "total_trabalhos": total_trabalhos,
984
  "total_imoveis": total_imoveis,
985
  "modo_exibicao": modo_exibicao_norm,
@@ -1166,6 +1294,7 @@ def detalhar_trabalho(trabalho_id: str) -> dict[str, Any]:
1166
  "trabalho": {
1167
  **trabalho,
1168
  "mapa_html": _criar_mapa_trabalho(str(trabalho.get("nome") or chave), imoveis),
 
1169
  "fonte": _source_payload(resolved, meta),
1170
  }
1171
  }
 
16
  from app.core.map_layers import (
17
  add_bairros_layer,
18
  add_popup_pagination_handlers,
19
+ build_trabalhos_tecnicos_marker_payloads,
20
  add_trabalhos_tecnicos_markers,
21
  add_zoom_responsive_circle_markers,
22
  )
23
+ from app.core.visualizacao.map_payload import build_leaflet_payload
24
  from app.services import model_repository, trabalhos_tecnicos_repository
25
  from app.services.serializers import sanitize_value
26
 
 
874
  return MAPA_TRABALHOS_CLUSTERIZADO
875
 
876
 
877
+ def _marker_payload_avaliando(lat: float, lon: float) -> dict[str, Any]:
878
+ return {
879
+ "lat": float(lat),
880
+ "lon": float(lon),
881
+ "tooltip_html": (
882
+ '<div style="font-family:\'Segoe UI\',Arial,sans-serif; font-size:13px; line-height:1.45;">'
883
+ '<strong>Avaliando</strong>'
884
+ '</div>'
885
+ ),
886
+ "popup_html": (
887
+ '<div style="font-family:\'Segoe UI\',Arial,sans-serif; font-size:13px; line-height:1.45;">'
888
+ '<strong>Avaliando</strong>'
889
+ '</div>'
890
+ ),
891
+ "marker_html": (
892
+ "<div style='display:flex;align-items:center;justify-content:center;width:18px;height:18px;'>"
893
+ "<svg width='18' height='18' viewBox='0 0 24 24' aria-hidden='true'>"
894
+ "<path d='M12 3 4 9v11h5v-6h6v6h5V9l-8-6Z' fill='#d7263d' stroke='#8f1522' stroke-width='1.5' stroke-linejoin='round'/>"
895
+ "</svg></div>"
896
+ ),
897
+ "icon_size": [18, 18],
898
+ "icon_anchor": [9, 9],
899
+ "class_name": "mesa-trabalho-tecnico-marker",
900
+ "ignore_bounds": False,
901
+ }
902
+
903
+
904
+ def _build_mapa_trabalhos_payload(
905
+ pontos: list[dict[str, Any]],
906
+ *,
907
+ avaliando_coords: tuple[float | None, float | None] | None = None,
908
+ ) -> dict[str, Any] | None:
909
+ bounds: list[list[float]] = [
910
+ [float(item["coord_lat"]), float(item["coord_lon"])]
911
+ for item in pontos
912
+ ]
913
+
914
+ overlay_layers: list[dict[str, Any]] = [
915
+ {
916
+ "id": "trabalhos_tecnicos",
917
+ "label": "Trabalhos técnicos",
918
+ "show": True,
919
+ "markers": build_trabalhos_tecnicos_marker_payloads(
920
+ pontos,
921
+ origem="trabalhos_tecnicos_mapa",
922
+ marker_style="ponto",
923
+ ignore_bounds=False,
924
+ ),
925
+ }
926
+ ]
927
+
928
+ aval_lat, aval_lon = avaliando_coords or (None, None)
929
+ if aval_lat is not None and aval_lon is not None:
930
+ bounds.append([float(aval_lat), float(aval_lon)])
931
+ overlay_layers.append(
932
+ {
933
+ "id": "avaliando",
934
+ "label": "Avaliando",
935
+ "show": True,
936
+ "markers": [_marker_payload_avaliando(float(aval_lat), float(aval_lon))],
937
+ }
938
+ )
939
+
940
+ return build_leaflet_payload(bounds=bounds, overlay_layers=overlay_layers, show_bairros=True)
941
+
942
+
943
+ def _build_mapa_trabalho_payload(nome_trabalho: str, imoveis: list[dict[str, Any]]) -> dict[str, Any] | None:
944
+ pontos = [
945
+ item for item in imoveis
946
+ if _coordenada_valida(item.get("coord_x")) and _coordenada_valida(item.get("coord_y"))
947
+ ]
948
+ if not pontos:
949
+ return None
950
+
951
+ bounds = [[float(item["coord_y"]), float(item["coord_x"])] for item in pontos]
952
+ markers: list[dict[str, Any]] = []
953
+ for index, item in enumerate(pontos, start=1):
954
+ label = str(item.get("label") or f"Imóvel {index}").strip()
955
+ endereco = str(item.get("endereco") or "").strip()
956
+ numero = str(item.get("numero") or "").strip()
957
+ endereco_texto = ", ".join([value for value in [endereco, numero] if value]) or "Endereço não informado"
958
+ modelos = [str(valor).strip() for valor in (item.get("modelos") or []) if str(valor).strip()]
959
+ modelos_texto = ", ".join(modelos) or "Sem modelo informado"
960
+ popup_html = (
961
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.5; min-width:240px;'>"
962
+ f"<div style='margin-bottom:6px; font-weight:700; color:#24405b;'>{label}</div>"
963
+ f"<div><span style='color:#666;'>Trabalho:</span> {str(nome_trabalho)}</div>"
964
+ f"<div><span style='color:#666;'>Endereço:</span> {endereco_texto}</div>"
965
+ f"<div><span style='color:#666;'>Modelos:</span> {modelos_texto}</div>"
966
+ "</div>"
967
+ )
968
+ markers.append(
969
+ {
970
+ "lat": float(item["coord_y"]),
971
+ "lon": float(item["coord_x"]),
972
+ "tooltip_html": popup_html,
973
+ "popup_html": popup_html,
974
+ "marker_html": (
975
+ "<div style='display:flex;align-items:center;justify-content:center;"
976
+ "width:8px;height:8px;border-radius:999px;background:#1f6fb2;"
977
+ "border:1px solid #ffffff;box-shadow:0 0 0 1px rgba(20,42,66,0.20);'></div>"
978
+ ),
979
+ "icon_size": [8, 8],
980
+ "icon_anchor": [4, 4],
981
+ "class_name": "mesa-trabalho-tecnico-marker mesa-trabalho-tecnico-marker-dot",
982
+ "ignore_bounds": False,
983
+ }
984
+ )
985
+
986
+ return build_leaflet_payload(
987
+ bounds=bounds,
988
+ overlay_layers=[
989
+ {
990
+ "id": "trabalho",
991
+ "label": str(nome_trabalho or "Imóveis do trabalho"),
992
+ "show": True,
993
+ "markers": markers,
994
+ }
995
+ ],
996
+ show_bairros=True,
997
+ )
998
+
999
+
1000
  def gerar_mapa_trabalhos(
1001
  trabalhos_ids: list[str] | None = None,
1002
  *,
 
1026
  return sanitize_value(
1027
  {
1028
  "mapa_html": "",
1029
+ "mapa_payload": None,
1030
  "total_trabalhos": 0,
1031
  "total_imoveis": 0,
1032
  "status": "Nenhum trabalho técnico georreferenciado foi encontrado para o mapa.",
 
1103
 
1104
  total_trabalhos = len({str(item.get("trabalho_id") or "").strip() for item in pontos if str(item.get("trabalho_id") or "").strip()})
1105
  total_imoveis = len(pontos)
1106
+ mapa_payload = _build_mapa_trabalhos_payload(pontos, avaliando_coords=avaliando_coords)
1107
  return sanitize_value(
1108
  {
1109
  "mapa_html": mapa.get_root().render(),
1110
+ "mapa_payload": mapa_payload,
1111
  "total_trabalhos": total_trabalhos,
1112
  "total_imoveis": total_imoveis,
1113
  "modo_exibicao": modo_exibicao_norm,
 
1294
  "trabalho": {
1295
  **trabalho,
1296
  "mapa_html": _criar_mapa_trabalho(str(trabalho.get("nome") or chave), imoveis),
1297
+ "mapa_payload": _build_mapa_trabalho_payload(str(trabalho.get("nome") or chave), imoveis),
1298
  "fonte": _source_payload(resolved, meta),
1299
  }
1300
  }
backend/app/services/visualizacao_service.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  from pathlib import Path
 
4
  from typing import Any
5
 
6
  import folium
@@ -11,7 +12,12 @@ from folium import plugins
11
  from joblib import load
12
 
13
  from app.core.visualizacao import app as viz_app
14
- from app.core.map_layers import add_bairros_layer, add_popup_pagination_handlers, add_zoom_responsive_circle_markers
 
 
 
 
 
15
  from app.core.elaboracao.core import (
16
  PERCENTUAL_RUIDO_TOL,
17
  _migrar_pacote_v1_para_v2,
@@ -23,6 +29,7 @@ from app.core.elaboracao.core import (
23
  normalizar_observacao_modelo,
24
  )
25
  from app.core.elaboracao.formatadores import formatar_avaliacao_html
 
26
  from app.models.session import SessionState
27
  from app.services import model_repository, pesquisa_service, trabalhos_tecnicos_service
28
  from app.services.equacao_service import build_equacoes_payload, exportar_planilha_equacao
@@ -35,6 +42,8 @@ BASE_COMPARACAO_SEM_BASE = "__none__"
35
  COORD_LAT_NAMES = {"lat", "latitude", "siat_latitude"}
36
  COORD_LON_NAMES = {"lon", "long", "longitude", "siat_longitude"}
37
  TRABALHOS_TECNICOS_MODELOS_MODO_PADRAO = pesquisa_service.TRABALHOS_TECNICOS_MODELOS_SELECIONADOS_E_OUTRAS_VERSOES
 
 
38
 
39
 
40
  def _to_dataframe(value: Any) -> pd.DataFrame | None:
@@ -284,6 +293,104 @@ def _criar_mapa_knn_destaque(
284
  return mapa.get_root().render()
285
 
286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  def _resolver_indice_base(
288
  indice_base_raw: str | None,
289
  total_avaliacoes: int,
@@ -314,6 +421,31 @@ def listar_modelos_repositorio() -> dict[str, Any]:
314
  return sanitize_value(model_repository.list_repository_models())
315
 
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  def carregar_modelo_repositorio(session: SessionState, modelo_id: str) -> dict[str, Any]:
318
  caminho = model_repository.resolve_model_file(modelo_id)
319
  session.uploaded_file_path = str(caminho)
@@ -341,6 +473,7 @@ def carregar_modelo(session: SessionState, caminho_arquivo: str) -> dict[str, An
341
  session.dados_visualizacao = None
342
  session.avaliacoes_visualizacao = []
343
  session.visualizacao_cache = {}
 
344
 
345
  nome_modelo = Path(caminho_arquivo).stem
346
  badge_html = viz_app._formatar_badge_completo(pacote, nome_modelo=nome_modelo)
@@ -569,9 +702,7 @@ def _aliases_modelo_visualizacao_para_trabalhos_tecnicos(
569
  modelo_id, caminho, nome_modelo = _resolver_referencias_modelo_visualizacao(session)
570
 
571
  try:
572
- familias_versoes = pesquisa_service._montar_familias_versoes_modelos(
573
- list(pesquisa_service.ensure_modelos_dir().glob("*.dai"))
574
- )
575
  except Exception:
576
  familias_versoes = {}
577
 
@@ -616,10 +747,15 @@ def _equacoes_do_modelo(pacote: dict[str, Any], info: dict[str, Any]) -> dict[st
616
 
617
 
618
  def _preparar_dados_visualizacao(pacote: dict[str, Any]) -> pd.DataFrame:
619
- dados = pacote["dados"]["df"].reset_index()
620
- for col in dados.columns:
621
- if pd.api.types.is_numeric_dtype(dados[col]) and str(col).lower() not in ["lat", "latitude", "lon", "longitude", "long", "siat_latitude", "siat_longitude"]:
622
- dados[col] = dados[col].round(2)
 
 
 
 
 
623
  dados["__mesa_row_id__"] = np.arange(len(dados), dtype=int)
624
  return dados
625
 
@@ -825,17 +961,31 @@ def _payload_modelo_mapa(
825
  session,
826
  trabalhos_tecnicos_modelos_modo,
827
  )
828
- popup_endpoint = f"{api_base_url.rstrip('/')}/api/visualizacao/map/popup" if api_base_url else "/api/visualizacao/map/popup"
829
- mapa_html = viz_app.criar_mapa(
 
 
 
 
830
  core["dados"],
831
  col_y=info["nome_y"],
832
- session_id=session.session_id,
833
- popup_endpoint=popup_endpoint,
834
- popup_auth_token=popup_auth_token,
835
  avaliandos_tecnicos=avaliandos_tecnicos,
 
836
  )
 
 
 
 
 
 
 
 
 
 
 
837
  return {
838
  "mapa_html": mapa_html,
 
839
  "mapa_choices": core["mapa_choices"],
840
  "trabalhos_tecnicos": trabalhos_tecnicos,
841
  "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
@@ -944,18 +1094,33 @@ def atualizar_mapa(
944
  session,
945
  trabalhos_tecnicos_modelos_modo,
946
  )
947
- popup_endpoint = f"{api_base_url.rstrip('/')}/api/visualizacao/map/popup" if api_base_url else "/api/visualizacao/map/popup"
948
- mapa_html = viz_app.criar_mapa(
 
 
 
 
949
  dados,
950
  tamanho_col=tamanho_col,
951
  col_y=info["nome_y"],
952
- session_id=session.session_id,
953
- popup_endpoint=popup_endpoint,
954
- popup_auth_token=popup_auth_token,
955
  avaliandos_tecnicos=avaliandos_tecnicos,
 
956
  )
 
 
 
 
 
 
 
 
 
 
 
 
957
  return {
958
  "mapa_html": mapa_html,
 
959
  "trabalhos_tecnicos": trabalhos_tecnicos,
960
  "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
961
  }
@@ -1274,13 +1439,22 @@ def detalhes_knn_avaliacao(
1274
  df_vizinhos.insert(1, "__distancia_knn__", distancias_validas)
1275
  tabela_payload = dataframe_to_payload(df_vizinhos, decimals=4, max_rows=None)
1276
 
1277
- mapa_html = _criar_mapa_knn_destaque(
1278
  df_knn,
1279
  posicoes_vizinhos,
1280
  info["nome_y"],
1281
  avaliando_lat=aval_lat,
1282
  avaliando_lon=aval_lon,
1283
  )
 
 
 
 
 
 
 
 
 
1284
  avaliando = [{"variavel": str(col), "valor": entradas.get(col)} for col in colunas_x]
1285
  if info.get("coluna_area") and info.get("coluna_area") not in colunas_x:
1286
  avaliando.append({"variavel": f"{info['coluna_area']} (area)", "valor": valor_area})
@@ -1293,6 +1467,7 @@ def detalhes_knn_avaliacao(
1293
  return sanitize_value(
1294
  {
1295
  "mapa_html": mapa_html,
 
1296
  "avaliando": avaliando,
1297
  "vizinhos_tabela": tabela_payload,
1298
  "knn": resultado_knn,
 
1
  from __future__ import annotations
2
 
3
  from pathlib import Path
4
+ from threading import Lock, Thread
5
  from typing import Any
6
 
7
  import folium
 
12
  from joblib import load
13
 
14
  from app.core.visualizacao import app as viz_app
15
+ from app.core.map_layers import (
16
+ add_bairros_layer,
17
+ add_popup_pagination_handlers,
18
+ add_zoom_responsive_circle_markers,
19
+ get_bairros_geojson,
20
+ )
21
  from app.core.elaboracao.core import (
22
  PERCENTUAL_RUIDO_TOL,
23
  _migrar_pacote_v1_para_v2,
 
29
  normalizar_observacao_modelo,
30
  )
31
  from app.core.elaboracao.formatadores import formatar_avaliacao_html
32
+ from app.core.visualizacao.map_payload import build_leaflet_payload, build_visualizacao_map_payload
33
  from app.models.session import SessionState
34
  from app.services import model_repository, pesquisa_service, trabalhos_tecnicos_service
35
  from app.services.equacao_service import build_equacoes_payload, exportar_planilha_equacao
 
42
  COORD_LAT_NAMES = {"lat", "latitude", "siat_latitude"}
43
  COORD_LON_NAMES = {"lon", "long", "longitude", "siat_longitude"}
44
  TRABALHOS_TECNICOS_MODELOS_MODO_PADRAO = pesquisa_service.TRABALHOS_TECNICOS_MODELOS_SELECIONADOS_E_OUTRAS_VERSOES
45
+ _MAP_SUPPORT_WARMUP_LOCK = Lock()
46
+ _MAP_SUPPORT_WARMUP_SIGNATURE: str | None = None
47
 
48
 
49
  def _to_dataframe(value: Any) -> pd.DataFrame | None:
 
293
  return mapa.get_root().render()
294
 
295
 
296
+ def _criar_payload_knn_destaque(
297
+ df_base: pd.DataFrame,
298
+ posicoes_knn: list[int],
299
+ coluna_y: str,
300
+ avaliando_lat: float | None = None,
301
+ avaliando_lon: float | None = None,
302
+ ) -> dict[str, Any] | None:
303
+ if df_base is None or df_base.empty:
304
+ return None
305
+
306
+ lat_col = _detectar_coluna_coord(df_base, COORD_LAT_NAMES)
307
+ lon_col = _detectar_coluna_coord(df_base, COORD_LON_NAMES)
308
+ if not lat_col or not lon_col:
309
+ return None
310
+
311
+ lat_serie = _primeira_serie_por_nome(df_base, lat_col)
312
+ lon_serie = _primeira_serie_por_nome(df_base, lon_col)
313
+ if lat_serie is None or lon_serie is None:
314
+ return None
315
+
316
+ dados = df_base.copy()
317
+ dados["__pos_base__"] = np.arange(len(dados), dtype=int)
318
+ dados["__indice_base__"] = [str(v) for v in dados.index]
319
+ dados["__lat__"] = pd.to_numeric(lat_serie, errors="coerce")
320
+ dados["__lon__"] = pd.to_numeric(lon_serie, errors="coerce")
321
+ dados = dados[
322
+ np.isfinite(dados["__lat__"])
323
+ & np.isfinite(dados["__lon__"])
324
+ & (np.abs(dados["__lat__"]) <= 90.0)
325
+ & (np.abs(dados["__lon__"]) <= 180.0)
326
+ ].copy()
327
+ if dados.empty:
328
+ return None
329
+
330
+ aval_lat, aval_lon = _normalizar_coordenadas_avaliando(avaliando_lat, avaliando_lon)
331
+ posicoes_set = {int(v) for v in (posicoes_knn or [])}
332
+ points_mercado: list[dict[str, Any]] = []
333
+ points_knn: list[dict[str, Any]] = []
334
+ bounds: list[list[float]] = []
335
+
336
+ for _, row in dados.iterrows():
337
+ lat = float(row["__lat__"])
338
+ lon = float(row["__lon__"])
339
+ pos = int(row["__pos_base__"])
340
+ selecionado = pos in posicoes_set
341
+ col_y_val = row.get(coluna_y)
342
+ valor_tooltip = _formatar_tooltip_valor(coluna_y, col_y_val)
343
+ point_payload = {
344
+ "lat": lat,
345
+ "lon": lon,
346
+ "color": "#d7263d" if selecionado else "#4f6d8a",
347
+ "base_radius": 8.0 if selecionado else 5.0,
348
+ "stroke_color": "#ffffff",
349
+ "stroke_weight": 0.9,
350
+ "fill_opacity": 0.92 if selecionado else 0.52,
351
+ "tooltip_html": (
352
+ "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.5;'>"
353
+ f"<b>Índice {escape(str(row['__indice_base__']))}</b>"
354
+ f"<br><span style='color:#555;'>{escape(str(coluna_y))}:</span> <b>{escape(valor_tooltip)}</b>"
355
+ "</div>"
356
+ ),
357
+ }
358
+ (points_knn if selecionado else points_mercado).append(point_payload)
359
+ bounds.append([lat, lon])
360
+
361
+ overlay_layers: list[dict[str, Any]] = [
362
+ {"id": "mercado", "label": "Mercado", "show": True, "points": points_mercado},
363
+ {"id": "knn", "label": "Selecionados KNN", "show": True, "points": points_knn},
364
+ ]
365
+
366
+ if aval_lat is not None and aval_lon is not None:
367
+ overlay_layers.append(
368
+ {
369
+ "id": "avaliando",
370
+ "label": "Avaliando",
371
+ "show": True,
372
+ "markers": [
373
+ {
374
+ "lat": float(aval_lat),
375
+ "lon": float(aval_lon),
376
+ "tooltip_html": "Avaliando",
377
+ "marker_html": (
378
+ "<div style='display:flex;align-items:center;justify-content:center;"
379
+ "width:18px;height:18px;border-radius:999px;background:#dc3545;"
380
+ "border:2px solid rgba(255,255,255,0.95);box-shadow:0 1px 4px rgba(0,0,0,0.2);'></div>"
381
+ ),
382
+ "icon_size": [18, 18],
383
+ "icon_anchor": [9, 9],
384
+ "class_name": "mesa-avaliando-marker",
385
+ }
386
+ ],
387
+ }
388
+ )
389
+ bounds.append([float(aval_lat), float(aval_lon)])
390
+
391
+ return build_leaflet_payload(bounds=bounds, overlay_layers=overlay_layers, show_bairros=True)
392
+
393
+
394
  def _resolver_indice_base(
395
  indice_base_raw: str | None,
396
  total_avaliacoes: int,
 
421
  return sanitize_value(model_repository.list_repository_models())
422
 
423
 
424
+ def _warm_visualizacao_support_caches_async() -> None:
425
+ global _MAP_SUPPORT_WARMUP_SIGNATURE
426
+ try:
427
+ signature = model_repository.resolve_model_repository().signature
428
+ except Exception:
429
+ signature = "__unknown__"
430
+
431
+ with _MAP_SUPPORT_WARMUP_LOCK:
432
+ if _MAP_SUPPORT_WARMUP_SIGNATURE == signature:
433
+ return
434
+ _MAP_SUPPORT_WARMUP_SIGNATURE = signature
435
+
436
+ def _worker() -> None:
437
+ try:
438
+ get_bairros_geojson()
439
+ except Exception:
440
+ pass
441
+ try:
442
+ pesquisa_service.obter_familias_versoes_modelos_cache()
443
+ except Exception:
444
+ pass
445
+
446
+ Thread(target=_worker, name="mesa-visualizacao-map-warmup", daemon=True).start()
447
+
448
+
449
  def carregar_modelo_repositorio(session: SessionState, modelo_id: str) -> dict[str, Any]:
450
  caminho = model_repository.resolve_model_file(modelo_id)
451
  session.uploaded_file_path = str(caminho)
 
473
  session.dados_visualizacao = None
474
  session.avaliacoes_visualizacao = []
475
  session.visualizacao_cache = {}
476
+ _warm_visualizacao_support_caches_async()
477
 
478
  nome_modelo = Path(caminho_arquivo).stem
479
  badge_html = viz_app._formatar_badge_completo(pacote, nome_modelo=nome_modelo)
 
702
  modelo_id, caminho, nome_modelo = _resolver_referencias_modelo_visualizacao(session)
703
 
704
  try:
705
+ familias_versoes = pesquisa_service.obter_familias_versoes_modelos_cache()
 
 
706
  except Exception:
707
  familias_versoes = {}
708
 
 
747
 
748
 
749
  def _preparar_dados_visualizacao(pacote: dict[str, Any]) -> pd.DataFrame:
750
+ dados = pacote["dados"]["df"].reset_index().copy()
751
+ colunas_numericas = [
752
+ str(col)
753
+ for col in dados.columns
754
+ if pd.api.types.is_numeric_dtype(dados[col])
755
+ and str(col).lower() not in ["lat", "latitude", "lon", "longitude", "long", "siat_latitude", "siat_longitude"]
756
+ ]
757
+ if colunas_numericas:
758
+ dados.loc[:, colunas_numericas] = dados.loc[:, colunas_numericas].round(2)
759
  dados["__mesa_row_id__"] = np.arange(len(dados), dtype=int)
760
  return dados
761
 
 
961
  session,
962
  trabalhos_tecnicos_modelos_modo,
963
  )
964
+ bairros_geojson_url = (
965
+ f"{api_base_url.rstrip('/')}/api/visualizacao/map/bairros.geojson"
966
+ if api_base_url
967
+ else "/api/visualizacao/map/bairros.geojson"
968
+ )
969
+ mapa_payload = build_visualizacao_map_payload(
970
  core["dados"],
971
  col_y=info["nome_y"],
 
 
 
972
  avaliandos_tecnicos=avaliandos_tecnicos,
973
+ bairros_geojson_url=bairros_geojson_url,
974
  )
975
+ mapa_html = ""
976
+ if mapa_payload is None:
977
+ popup_endpoint = f"{api_base_url.rstrip('/')}/api/visualizacao/map/popup" if api_base_url else "/api/visualizacao/map/popup"
978
+ mapa_html = viz_app.criar_mapa(
979
+ core["dados"],
980
+ col_y=info["nome_y"],
981
+ session_id=session.session_id,
982
+ popup_endpoint=popup_endpoint,
983
+ popup_auth_token=popup_auth_token,
984
+ avaliandos_tecnicos=avaliandos_tecnicos,
985
+ )
986
  return {
987
  "mapa_html": mapa_html,
988
+ "mapa_payload": mapa_payload,
989
  "mapa_choices": core["mapa_choices"],
990
  "trabalhos_tecnicos": trabalhos_tecnicos,
991
  "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
 
1094
  session,
1095
  trabalhos_tecnicos_modelos_modo,
1096
  )
1097
+ bairros_geojson_url = (
1098
+ f"{api_base_url.rstrip('/')}/api/visualizacao/map/bairros.geojson"
1099
+ if api_base_url
1100
+ else "/api/visualizacao/map/bairros.geojson"
1101
+ )
1102
+ mapa_payload = build_visualizacao_map_payload(
1103
  dados,
1104
  tamanho_col=tamanho_col,
1105
  col_y=info["nome_y"],
 
 
 
1106
  avaliandos_tecnicos=avaliandos_tecnicos,
1107
+ bairros_geojson_url=bairros_geojson_url,
1108
  )
1109
+ mapa_html = ""
1110
+ if mapa_payload is None:
1111
+ popup_endpoint = f"{api_base_url.rstrip('/')}/api/visualizacao/map/popup" if api_base_url else "/api/visualizacao/map/popup"
1112
+ mapa_html = viz_app.criar_mapa(
1113
+ dados,
1114
+ tamanho_col=tamanho_col,
1115
+ col_y=info["nome_y"],
1116
+ session_id=session.session_id,
1117
+ popup_endpoint=popup_endpoint,
1118
+ popup_auth_token=popup_auth_token,
1119
+ avaliandos_tecnicos=avaliandos_tecnicos,
1120
+ )
1121
  return {
1122
  "mapa_html": mapa_html,
1123
+ "mapa_payload": mapa_payload,
1124
  "trabalhos_tecnicos": trabalhos_tecnicos,
1125
  "trabalhos_tecnicos_modelos_modo": trabalhos_tecnicos_modelos_modo_norm,
1126
  }
 
1439
  df_vizinhos.insert(1, "__distancia_knn__", distancias_validas)
1440
  tabela_payload = dataframe_to_payload(df_vizinhos, decimals=4, max_rows=None)
1441
 
1442
+ mapa_payload = _criar_payload_knn_destaque(
1443
  df_knn,
1444
  posicoes_vizinhos,
1445
  info["nome_y"],
1446
  avaliando_lat=aval_lat,
1447
  avaliando_lon=aval_lon,
1448
  )
1449
+ mapa_html = ""
1450
+ if mapa_payload is None:
1451
+ mapa_html = _criar_mapa_knn_destaque(
1452
+ df_knn,
1453
+ posicoes_vizinhos,
1454
+ info["nome_y"],
1455
+ avaliando_lat=aval_lat,
1456
+ avaliando_lon=aval_lon,
1457
+ )
1458
  avaliando = [{"variavel": str(col), "valor": entradas.get(col)} for col in colunas_x]
1459
  if info.get("coluna_area") and info.get("coluna_area") not in colunas_x:
1460
  avaliando.append({"variavel": f"{info['coluna_area']} (area)", "valor": valor_area})
 
1467
  return sanitize_value(
1468
  {
1469
  "mapa_html": mapa_html,
1470
+ "mapa_payload": mapa_payload,
1471
  "avaliando": avaliando,
1472
  "vizinhos_tabela": tabela_payload,
1473
  "knn": resultado_knn,
frontend/package-lock.json CHANGED
@@ -8,6 +8,10 @@
8
  "name": "mesa-frame-frontend",
9
  "version": "1.0.0",
10
  "dependencies": {
 
 
 
 
11
  "plotly.js-dist-min": "^2.35.2",
12
  "react": "^18.3.1",
13
  "react-dom": "^18.3.1",
@@ -1367,6 +1371,22 @@
1367
  "url": "https://opencollective.com/turf"
1368
  }
1369
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1370
  "node_modules/@turf/helpers": {
1371
  "version": "7.3.4",
1372
  "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.4.tgz",
@@ -1381,6 +1401,47 @@
1381
  "url": "https://opencollective.com/turf"
1382
  }
1383
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1384
  "node_modules/@turf/meta": {
1385
  "version": "7.3.4",
1386
  "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.4.tgz",
@@ -3102,6 +3163,71 @@
3102
  "node": ">=0.10.0"
3103
  }
3104
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3105
  "node_modules/lodash.merge": {
3106
  "version": "4.6.2",
3107
  "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
 
8
  "name": "mesa-frame-frontend",
9
  "version": "1.0.0",
10
  "dependencies": {
11
+ "leaflet": "^1.9.4",
12
+ "leaflet-measure": "^3.1.0",
13
+ "leaflet.fullscreen": "^5.3.1",
14
+ "leaflet.heat": "^0.2.0",
15
  "plotly.js-dist-min": "^2.35.2",
16
  "react": "^18.3.1",
17
  "react-dom": "^18.3.1",
 
1371
  "url": "https://opencollective.com/turf"
1372
  }
1373
  },
1374
+ "node_modules/@turf/distance": {
1375
+ "version": "5.1.5",
1376
+ "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-5.1.5.tgz",
1377
+ "integrity": "sha512-sYCAgYZ2MjNKMtx17EijHlK9qHwpA0MuuQWbR4P30LTCl52UlG/reBfV899wKyF3HuDL9ux78IbILwOfeQ4zgA==",
1378
+ "license": "MIT",
1379
+ "dependencies": {
1380
+ "@turf/helpers": "^5.1.5",
1381
+ "@turf/invariant": "^5.1.5"
1382
+ }
1383
+ },
1384
+ "node_modules/@turf/distance/node_modules/@turf/helpers": {
1385
+ "version": "5.1.5",
1386
+ "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz",
1387
+ "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==",
1388
+ "license": "MIT"
1389
+ },
1390
  "node_modules/@turf/helpers": {
1391
  "version": "7.3.4",
1392
  "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.4.tgz",
 
1401
  "url": "https://opencollective.com/turf"
1402
  }
1403
  },
1404
+ "node_modules/@turf/invariant": {
1405
+ "version": "5.2.0",
1406
+ "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-5.2.0.tgz",
1407
+ "integrity": "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA==",
1408
+ "license": "MIT",
1409
+ "dependencies": {
1410
+ "@turf/helpers": "^5.1.5"
1411
+ }
1412
+ },
1413
+ "node_modules/@turf/invariant/node_modules/@turf/helpers": {
1414
+ "version": "5.1.5",
1415
+ "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz",
1416
+ "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==",
1417
+ "license": "MIT"
1418
+ },
1419
+ "node_modules/@turf/length": {
1420
+ "version": "5.1.5",
1421
+ "resolved": "https://registry.npmjs.org/@turf/length/-/length-5.1.5.tgz",
1422
+ "integrity": "sha512-0ryx68h512wCoNfwyksLdabxEfwkGNTPg61/QiY+QfGFUOUNhHbP+QimViFpwF5hyX7qmroaSHVclLUqyLGRbg==",
1423
+ "license": "MIT",
1424
+ "dependencies": {
1425
+ "@turf/distance": "^5.1.5",
1426
+ "@turf/helpers": "^5.1.5",
1427
+ "@turf/meta": "^5.1.5"
1428
+ }
1429
+ },
1430
+ "node_modules/@turf/length/node_modules/@turf/helpers": {
1431
+ "version": "5.1.5",
1432
+ "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz",
1433
+ "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==",
1434
+ "license": "MIT"
1435
+ },
1436
+ "node_modules/@turf/length/node_modules/@turf/meta": {
1437
+ "version": "5.2.0",
1438
+ "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-5.2.0.tgz",
1439
+ "integrity": "sha512-ZjQ3Ii62X9FjnK4hhdsbT+64AYRpaI8XMBMcyftEOGSmPMUVnkbvuv3C9geuElAXfQU7Zk1oWGOcrGOD9zr78Q==",
1440
+ "license": "MIT",
1441
+ "dependencies": {
1442
+ "@turf/helpers": "^5.1.5"
1443
+ }
1444
+ },
1445
  "node_modules/@turf/meta": {
1446
  "version": "7.3.4",
1447
  "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.4.tgz",
 
3163
  "node": ">=0.10.0"
3164
  }
3165
  },
3166
+ "node_modules/leaflet": {
3167
+ "version": "1.9.4",
3168
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
3169
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
3170
+ "license": "BSD-2-Clause"
3171
+ },
3172
+ "node_modules/leaflet-measure": {
3173
+ "version": "3.1.0",
3174
+ "resolved": "https://registry.npmjs.org/leaflet-measure/-/leaflet-measure-3.1.0.tgz",
3175
+ "integrity": "sha512-ln5c9UNaWDEd24tIzDt9hwnpb8OaCPBfSWNBg2H8rb9SA3cbXW9+NqohA6/8TdsNDGJZr36woXMrqRq07Pcl3w==",
3176
+ "license": "MIT",
3177
+ "dependencies": {
3178
+ "@turf/area": "^5.1.5",
3179
+ "@turf/length": "^5.1.5",
3180
+ "lodash": "^4.17.5"
3181
+ },
3182
+ "peerDependencies": {
3183
+ "leaflet": "^1.0.0"
3184
+ }
3185
+ },
3186
+ "node_modules/leaflet-measure/node_modules/@turf/area": {
3187
+ "version": "5.1.5",
3188
+ "resolved": "https://registry.npmjs.org/@turf/area/-/area-5.1.5.tgz",
3189
+ "integrity": "sha512-lz16gqtvoz+j1jD9y3zj0Z5JnGNd3YfS0h+DQY1EcZymvi75Frm9i5YbEyth0RfxYZeOVufY7YIS3LXbJlI57g==",
3190
+ "license": "MIT",
3191
+ "dependencies": {
3192
+ "@turf/helpers": "^5.1.5",
3193
+ "@turf/meta": "^5.1.5"
3194
+ }
3195
+ },
3196
+ "node_modules/leaflet-measure/node_modules/@turf/helpers": {
3197
+ "version": "5.1.5",
3198
+ "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz",
3199
+ "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==",
3200
+ "license": "MIT"
3201
+ },
3202
+ "node_modules/leaflet-measure/node_modules/@turf/meta": {
3203
+ "version": "5.2.0",
3204
+ "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-5.2.0.tgz",
3205
+ "integrity": "sha512-ZjQ3Ii62X9FjnK4hhdsbT+64AYRpaI8XMBMcyftEOGSmPMUVnkbvuv3C9geuElAXfQU7Zk1oWGOcrGOD9zr78Q==",
3206
+ "license": "MIT",
3207
+ "dependencies": {
3208
+ "@turf/helpers": "^5.1.5"
3209
+ }
3210
+ },
3211
+ "node_modules/leaflet.fullscreen": {
3212
+ "version": "5.3.1",
3213
+ "resolved": "https://registry.npmjs.org/leaflet.fullscreen/-/leaflet.fullscreen-5.3.1.tgz",
3214
+ "integrity": "sha512-2IO5WJ5xpQWyn2ZLICwwpyiWJ2KdZMuxAUCsrK7b4dj770GZ/zwPp4uVQNKOxqnz9xRWEN1d2VNpLaX4ptSdnA==",
3215
+ "license": "MIT",
3216
+ "peerDependencies": {
3217
+ "leaflet": "^1.7.0 || >=2.0.0-alpha.1"
3218
+ }
3219
+ },
3220
+ "node_modules/leaflet.heat": {
3221
+ "version": "0.2.0",
3222
+ "resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz",
3223
+ "integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ=="
3224
+ },
3225
+ "node_modules/lodash": {
3226
+ "version": "4.18.1",
3227
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
3228
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
3229
+ "license": "MIT"
3230
+ },
3231
  "node_modules/lodash.merge": {
3232
  "version": "4.6.2",
3233
  "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
frontend/package.json CHANGED
@@ -9,6 +9,10 @@
9
  "preview": "vite preview"
10
  },
11
  "dependencies": {
 
 
 
 
12
  "plotly.js-dist-min": "^2.35.2",
13
  "react": "^18.3.1",
14
  "react-dom": "^18.3.1",
 
9
  "preview": "vite preview"
10
  },
11
  "dependencies": {
12
+ "leaflet": "^1.9.4",
13
+ "leaflet-measure": "^3.1.0",
14
+ "leaflet.fullscreen": "^5.3.1",
15
+ "leaflet.heat": "^0.2.0",
16
  "plotly.js-dist-min": "^2.35.2",
17
  "react": "^18.3.1",
18
  "react-dom": "^18.3.1",
frontend/src/api.js CHANGED
@@ -29,6 +29,10 @@ export function getAuthToken() {
29
  return AUTH_TOKEN
30
  }
31
 
 
 
 
 
32
  function authHeaders(extraHeaders = {}) {
33
  const headers = { ...extraHeaders }
34
  if (AUTH_TOKEN) headers['X-Auth-Token'] = AUTH_TOKEN
 
29
  return AUTH_TOKEN
30
  }
31
 
32
+ export function apiUrl(path) {
33
+ return `${API_BASE}${String(path || '')}`
34
+ }
35
+
36
  function authHeaders(extraHeaders = {}) {
37
  const headers = { ...extraHeaders }
38
  if (AUTH_TOKEN) headers['X-Auth-Token'] = AUTH_TOKEN
frontend/src/components/AvaliacaoTab.jsx CHANGED
@@ -539,6 +539,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
539
  const [knnDetalheErro, setKnnDetalheErro] = useState('')
540
  const [knnDetalheCardTitulo, setKnnDetalheCardTitulo] = useState('')
541
  const [knnDetalheMapaHtml, setKnnDetalheMapaHtml] = useState('')
 
542
  const [knnDetalheAvaliando, setKnnDetalheAvaliando] = useState([])
543
  const [knnDetalheTabela, setKnnDetalheTabela] = useState(null)
544
  const [knnDetalheInfo, setKnnDetalheInfo] = useState(null)
@@ -1078,6 +1079,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
1078
  setKnnDetalheErro('')
1079
  setKnnDetalheCardTitulo(`Aval. ${Number(indice) + 1} — ${String(card?.modelo || 'Modelo')}`)
1080
  setKnnDetalheMapaHtml('')
 
1081
  setKnnDetalheAvaliando([])
1082
  setKnnDetalheTabela(null)
1083
  setKnnDetalheInfo(null)
@@ -1091,6 +1093,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
1091
  }),
1092
  )
1093
  setKnnDetalheMapaHtml(String(resp?.mapa_html || ''))
 
1094
  setKnnDetalheAvaliando(Array.isArray(resp?.avaliando) ? resp.avaliando : [])
1095
  setKnnDetalheTabela(resp?.vizinhos_tabela || null)
1096
  setKnnDetalheInfo(resp?.knn || null)
@@ -1853,7 +1856,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
1853
  </div>
1854
 
1855
  <div className="avaliacao-knn-map-wrap">
1856
- <MapFrame html={knnDetalheMapaHtml} />
1857
  </div>
1858
 
1859
  <div className="subpanel avaliacao-knn-detalhes-box">
 
539
  const [knnDetalheErro, setKnnDetalheErro] = useState('')
540
  const [knnDetalheCardTitulo, setKnnDetalheCardTitulo] = useState('')
541
  const [knnDetalheMapaHtml, setKnnDetalheMapaHtml] = useState('')
542
+ const [knnDetalheMapaPayload, setKnnDetalheMapaPayload] = useState(null)
543
  const [knnDetalheAvaliando, setKnnDetalheAvaliando] = useState([])
544
  const [knnDetalheTabela, setKnnDetalheTabela] = useState(null)
545
  const [knnDetalheInfo, setKnnDetalheInfo] = useState(null)
 
1079
  setKnnDetalheErro('')
1080
  setKnnDetalheCardTitulo(`Aval. ${Number(indice) + 1} — ${String(card?.modelo || 'Modelo')}`)
1081
  setKnnDetalheMapaHtml('')
1082
+ setKnnDetalheMapaPayload(null)
1083
  setKnnDetalheAvaliando([])
1084
  setKnnDetalheTabela(null)
1085
  setKnnDetalheInfo(null)
 
1093
  }),
1094
  )
1095
  setKnnDetalheMapaHtml(String(resp?.mapa_html || ''))
1096
+ setKnnDetalheMapaPayload(resp?.mapa_payload || null)
1097
  setKnnDetalheAvaliando(Array.isArray(resp?.avaliando) ? resp.avaliando : [])
1098
  setKnnDetalheTabela(resp?.vizinhos_tabela || null)
1099
  setKnnDetalheInfo(resp?.knn || null)
 
1856
  </div>
1857
 
1858
  <div className="avaliacao-knn-map-wrap">
1859
+ <MapFrame html={knnDetalheMapaHtml} payload={knnDetalheMapaPayload} sessionId={sessionId} />
1860
  </div>
1861
 
1862
  <div className="subpanel avaliacao-knn-detalhes-box">
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -883,10 +883,12 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
883
 
884
  const [dados, setDados] = useState(null)
885
  const [mapaHtml, setMapaHtml] = useState('')
 
886
  const [mapaVariavel, setMapaVariavel] = useState(MAPA_VARIAVEL_PADRAO)
887
  const [mapaModo, setMapaModo] = useState(MAPA_MODO_PONTOS)
888
  const [mapaGerado, setMapaGerado] = useState(false)
889
  const [mapaResiduosHtml, setMapaResiduosHtml] = useState('')
 
890
  const [mapaResiduosModo, setMapaResiduosModo] = useState(MAPA_MODO_PONTOS)
891
  const [mapaResiduosExtremoAbs, setMapaResiduosExtremoAbs] = useState(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
892
  const [mapaResiduosGerado, setMapaResiduosGerado] = useState(false)
@@ -983,6 +985,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
983
  const [knnDetalheErro, setKnnDetalheErro] = useState('')
984
  const [knnDetalheCardTitulo, setKnnDetalheCardTitulo] = useState('')
985
  const [knnDetalheMapaHtml, setKnnDetalheMapaHtml] = useState('')
 
986
  const [knnDetalheAvaliando, setKnnDetalheAvaliando] = useState([])
987
  const [knnDetalheTabela, setKnnDetalheTabela] = useState(null)
988
  const [knnDetalheInfo, setKnnDetalheInfo] = useState(null)
@@ -1014,6 +1017,22 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
1014
  ? buildElaboracaoModeloLink(repoModeloSelecionado)
1015
  : ''
1016
  const [sideNavDynamicStyle, setSideNavDynamicStyle] = useState({})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1017
 
1018
  const mapaChoices = useMemo(() => [MAPA_VARIAVEL_PADRAO, ...colunasNumericas], [colunasNumericas])
1019
  const mapaModoDisponivel = mapaVariavel !== MAPA_VARIAVEL_PADRAO
@@ -2106,7 +2125,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2106
  }
2107
  setDataMercadoError('')
2108
  if (resp.dados) setDados(resp.dados)
2109
- if (resp.mapa_html) setMapaHtml(resp.mapa_html)
2110
  if (resp.colunas_numericas) setColunasNumericas(resp.colunas_numericas)
2111
  if (Array.isArray(resp.colunas_data_mercado)) {
2112
  setColunasDataMercado(resp.colunas_data_mercado.map((item) => String(item)))
@@ -2290,8 +2309,8 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2290
  syncPeriodoDataMercadoFromContext(resp.contexto)
2291
  }
2292
 
2293
- if (resp.mapa_html) {
2294
- setMapaHtml(resp.mapa_html)
2295
  }
2296
  }
2297
 
@@ -2313,7 +2332,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2313
  setSecao13InterativoEixoYColuna('')
2314
  setMapaResiduosModo(MAPA_MODO_PONTOS)
2315
  setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
2316
- setMapaResiduosHtml('')
2317
  setMapaResiduosGerado(false)
2318
  const transformacaoYAplicada = resp.transformacao_y || transformacaoY
2319
  const transformacoesXAplicadas = resp.transformacoes_x || transformacoesX
@@ -2466,11 +2485,11 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2466
  setImportacaoErro('')
2467
  await withBusy(async () => {
2468
  setMapaGerado(false)
2469
- setMapaHtml('')
2470
  setMapaVariavel(MAPA_VARIAVEL_PADRAO)
2471
  setMapaModo(MAPA_MODO_PONTOS)
2472
  setMapaResiduosGerado(false)
2473
- setMapaResiduosHtml('')
2474
  setMapaResiduosModo(MAPA_MODO_PONTOS)
2475
  setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
2476
  setGeoAuto200(true)
@@ -2505,11 +2524,11 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2505
  setArquivoCarregadoInfo(null)
2506
  await withBusy(async () => {
2507
  setMapaGerado(false)
2508
- setMapaHtml('')
2509
  setMapaVariavel(MAPA_VARIAVEL_PADRAO)
2510
  setMapaModo(MAPA_MODO_PONTOS)
2511
  setMapaResiduosGerado(false)
2512
- setMapaResiduosHtml('')
2513
  setMapaResiduosModo(MAPA_MODO_PONTOS)
2514
  setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
2515
  setGeoAuto200(true)
@@ -2583,11 +2602,11 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2583
  setImportacaoErro('')
2584
  await withBusy(async () => {
2585
  setMapaGerado(false)
2586
- setMapaHtml('')
2587
  setMapaVariavel(MAPA_VARIAVEL_PADRAO)
2588
  setMapaModo(MAPA_MODO_PONTOS)
2589
  setMapaResiduosGerado(false)
2590
- setMapaResiduosHtml('')
2591
  setMapaResiduosModo(MAPA_MODO_PONTOS)
2592
  setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
2593
  setGeoAuto200(true)
@@ -2733,7 +2752,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2733
  setGeoProcessError('')
2734
  try {
2735
  const resp = await api.mapCoords(sessionId, manualLat, manualLon)
2736
- setMapaHtml(resp.mapa_html)
2737
  setDados(resp.dados)
2738
  setCoordsInfo(resp.coords)
2739
  setGeoStatusHtml('')
@@ -2757,7 +2776,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2757
  setGeoStatusHtml(resp.status_html || '')
2758
  setGeoFalhasHtml(resp.falhas_html || '')
2759
  setGeoCorrecoes(parseCorrecoes(resp.falhas_para_correcao))
2760
- setMapaHtml(resp.mapa_html || '')
2761
  setDados(resp.dados || null)
2762
  setCoordsInfo(resp.coords || null)
2763
  setCoordsMode('geocodificar')
@@ -2779,7 +2798,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2779
  setGeoStatusHtml(resp.status_html || '')
2780
  setGeoFalhasHtml(resp.falhas_html || '')
2781
  setGeoCorrecoes(parseCorrecoes(resp.falhas_para_correcao))
2782
- setMapaHtml(resp.mapa_html || '')
2783
  setDados(resp.dados || null)
2784
  setCoordsInfo(resp.coords || null)
2785
  setCoordsMode('geocodificar')
@@ -2797,7 +2816,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2797
  setGeoStatusHtml(resp.status_html || '')
2798
  setGeoFalhasHtml(resp.falhas_html || '')
2799
  setGeoCorrecoes(parseCorrecoes(resp.falhas_para_correcao))
2800
- setMapaHtml(resp.mapa_html || '')
2801
  setDados(resp.dados || null)
2802
  setCoordsInfo(resp.coords || null)
2803
  setGeoCdlog(escolherColunaCdlogPadrao(resp.coords || null))
@@ -2817,7 +2836,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2817
  setGeoStatusHtml(resp.status_html || '')
2818
  setGeoFalhasHtml(resp.falhas_html || '')
2819
  setGeoCorrecoes(parseCorrecoes(resp.falhas_para_correcao))
2820
- setMapaHtml(resp.mapa_html || '')
2821
  setDados(resp.dados || null)
2822
  setCoordsInfo(resp.coords || null)
2823
  setManualLat(resp.coords?.colunas_disponiveis?.[0] || '')
@@ -3201,7 +3220,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3201
  await withBusy(async () => {
3202
  const resp = await api.clearOutlierHistory(sessionId)
3203
  setDados(resp.dados)
3204
- setMapaHtml(resp.mapa_html)
3205
  setResumoOutliers(resp.resumo_outliers || 'Excluidos: 0 | A excluir: 0 | A reincluir: 0 | Total: 0')
3206
  setOutliersAnteriores([])
3207
  setIteracao(1)
@@ -3287,6 +3306,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3287
  setKnnDetalheErro('')
3288
  setKnnDetalheCardTitulo(`Aval. ${idx + 1}`)
3289
  setKnnDetalheMapaHtml('')
 
3290
  setKnnDetalheAvaliando([])
3291
  setKnnDetalheTabela(null)
3292
  setKnnDetalheInfo(null)
@@ -3294,6 +3314,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3294
  try {
3295
  const resp = await api.evaluationKnnDetailsElab(sessionId, construirPayloadKnnAvaliacao(avaliacao))
3296
  setKnnDetalheMapaHtml(String(resp?.mapa_html || ''))
 
3297
  setKnnDetalheAvaliando(Array.isArray(resp?.avaliando) ? resp.avaliando : [])
3298
  setKnnDetalheTabela(resp?.vizinhos_tabela || null)
3299
  setKnnDetalheInfo(resp?.knn || null)
@@ -3474,7 +3495,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3474
  if (!sessionId || !mapaGerado) return
3475
  await withBusy(async () => {
3476
  const resp = await api.updateElaboracaoMap(sessionId, value, nextModo)
3477
- setMapaHtml(resp.mapa_html || '')
3478
  })
3479
  }
3480
 
@@ -3483,7 +3504,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3483
  if (!sessionId || !mapaGerado) return
3484
  await withBusy(async () => {
3485
  const resp = await api.updateElaboracaoMap(sessionId, mapaVariavel, value)
3486
- setMapaHtml(resp.mapa_html || '')
3487
  })
3488
  }
3489
 
@@ -3495,7 +3516,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3495
  }
3496
  await withBusy(async () => {
3497
  const resp = await api.updateElaboracaoMap(sessionId, mapaVariavel, modoAtual)
3498
- setMapaHtml(resp.mapa_html || '')
3499
  setMapaGerado(true)
3500
  })
3501
  }
@@ -3506,7 +3527,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3506
  const extremoAbs = parseMapaResiduosExtremoAbs(mapaResiduosExtremoAbs)
3507
  await withBusy(async () => {
3508
  const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, value, extremoAbs)
3509
- setMapaResiduosHtml(resp.mapa_html || '')
3510
  const extremoResp = Number(resp?.escala_extremo_abs)
3511
  if (Number.isFinite(extremoResp) && extremoResp > 0) {
3512
  setMapaResiduosExtremoAbs(String(Number(extremoResp)))
@@ -3523,7 +3544,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3523
  if (!sessionId || !mapaResiduosGerado) return
3524
  await withBusy(async () => {
3525
  const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, mapaResiduosModo, extremoAbs)
3526
- setMapaResiduosHtml(resp.mapa_html || '')
3527
  const extremoResp = Number(resp?.escala_extremo_abs)
3528
  if (Number.isFinite(extremoResp) && extremoResp > 0) {
3529
  setMapaResiduosExtremoAbs(String(Number(extremoResp)))
@@ -3538,7 +3559,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
3538
  const extremoAbs = parseMapaResiduosExtremoAbs(mapaResiduosExtremoAbs)
3539
  await withBusy(async () => {
3540
  const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, mapaResiduosModo, extremoAbs)
3541
- setMapaResiduosHtml(resp.mapa_html || '')
3542
  const extremoResp = Number(resp?.escala_extremo_abs)
3543
  if (Number.isFinite(extremoResp) && extremoResp > 0) {
3544
  setMapaResiduosExtremoAbs(String(Number(extremoResp)))
@@ -4525,7 +4546,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4525
  Fazer download
4526
  </button>
4527
  </div>
4528
- <MapFrame html={mapaHtml} />
4529
  </details>
4530
  )}
4531
  </div>
@@ -5870,7 +5891,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
5870
  Fazer download
5871
  </button>
5872
  </div>
5873
- <MapFrame html={mapaResiduosHtml} />
5874
  </>
5875
  )}
5876
  </div>
@@ -6312,7 +6333,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
6312
  </div>
6313
 
6314
  <div className="avaliacao-knn-map-wrap">
6315
- <MapFrame html={knnDetalheMapaHtml} />
6316
  </div>
6317
 
6318
  <div className="subpanel avaliacao-knn-detalhes-box">
 
883
 
884
  const [dados, setDados] = useState(null)
885
  const [mapaHtml, setMapaHtml] = useState('')
886
+ const [mapaPayload, setMapaPayload] = useState(null)
887
  const [mapaVariavel, setMapaVariavel] = useState(MAPA_VARIAVEL_PADRAO)
888
  const [mapaModo, setMapaModo] = useState(MAPA_MODO_PONTOS)
889
  const [mapaGerado, setMapaGerado] = useState(false)
890
  const [mapaResiduosHtml, setMapaResiduosHtml] = useState('')
891
+ const [mapaResiduosPayload, setMapaResiduosPayload] = useState(null)
892
  const [mapaResiduosModo, setMapaResiduosModo] = useState(MAPA_MODO_PONTOS)
893
  const [mapaResiduosExtremoAbs, setMapaResiduosExtremoAbs] = useState(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
894
  const [mapaResiduosGerado, setMapaResiduosGerado] = useState(false)
 
985
  const [knnDetalheErro, setKnnDetalheErro] = useState('')
986
  const [knnDetalheCardTitulo, setKnnDetalheCardTitulo] = useState('')
987
  const [knnDetalheMapaHtml, setKnnDetalheMapaHtml] = useState('')
988
+ const [knnDetalheMapaPayload, setKnnDetalheMapaPayload] = useState(null)
989
  const [knnDetalheAvaliando, setKnnDetalheAvaliando] = useState([])
990
  const [knnDetalheTabela, setKnnDetalheTabela] = useState(null)
991
  const [knnDetalheInfo, setKnnDetalheInfo] = useState(null)
 
1017
  ? buildElaboracaoModeloLink(repoModeloSelecionado)
1018
  : ''
1019
  const [sideNavDynamicStyle, setSideNavDynamicStyle] = useState({})
1020
+ const applyMapaResponse = useCallback((resp) => {
1021
+ setMapaHtml(String(resp?.mapa_html || ''))
1022
+ setMapaPayload(resp?.mapa_payload || null)
1023
+ }, [])
1024
+ const clearMapaResponse = useCallback(() => {
1025
+ setMapaHtml('')
1026
+ setMapaPayload(null)
1027
+ }, [])
1028
+ const applyMapaResiduosResponse = useCallback((resp) => {
1029
+ setMapaResiduosHtml(String(resp?.mapa_html || ''))
1030
+ setMapaResiduosPayload(resp?.mapa_payload || null)
1031
+ }, [])
1032
+ const clearMapaResiduosResponse = useCallback(() => {
1033
+ setMapaResiduosHtml('')
1034
+ setMapaResiduosPayload(null)
1035
+ }, [])
1036
 
1037
  const mapaChoices = useMemo(() => [MAPA_VARIAVEL_PADRAO, ...colunasNumericas], [colunasNumericas])
1038
  const mapaModoDisponivel = mapaVariavel !== MAPA_VARIAVEL_PADRAO
 
2125
  }
2126
  setDataMercadoError('')
2127
  if (resp.dados) setDados(resp.dados)
2128
+ if (resp?.mapa_html || resp?.mapa_payload) applyMapaResponse(resp)
2129
  if (resp.colunas_numericas) setColunasNumericas(resp.colunas_numericas)
2130
  if (Array.isArray(resp.colunas_data_mercado)) {
2131
  setColunasDataMercado(resp.colunas_data_mercado.map((item) => String(item)))
 
2309
  syncPeriodoDataMercadoFromContext(resp.contexto)
2310
  }
2311
 
2312
+ if (resp?.mapa_html || resp?.mapa_payload) {
2313
+ applyMapaResponse(resp)
2314
  }
2315
  }
2316
 
 
2332
  setSecao13InterativoEixoYColuna('')
2333
  setMapaResiduosModo(MAPA_MODO_PONTOS)
2334
  setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
2335
+ clearMapaResiduosResponse()
2336
  setMapaResiduosGerado(false)
2337
  const transformacaoYAplicada = resp.transformacao_y || transformacaoY
2338
  const transformacoesXAplicadas = resp.transformacoes_x || transformacoesX
 
2485
  setImportacaoErro('')
2486
  await withBusy(async () => {
2487
  setMapaGerado(false)
2488
+ clearMapaResponse()
2489
  setMapaVariavel(MAPA_VARIAVEL_PADRAO)
2490
  setMapaModo(MAPA_MODO_PONTOS)
2491
  setMapaResiduosGerado(false)
2492
+ clearMapaResiduosResponse()
2493
  setMapaResiduosModo(MAPA_MODO_PONTOS)
2494
  setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
2495
  setGeoAuto200(true)
 
2524
  setArquivoCarregadoInfo(null)
2525
  await withBusy(async () => {
2526
  setMapaGerado(false)
2527
+ clearMapaResponse()
2528
  setMapaVariavel(MAPA_VARIAVEL_PADRAO)
2529
  setMapaModo(MAPA_MODO_PONTOS)
2530
  setMapaResiduosGerado(false)
2531
+ clearMapaResiduosResponse()
2532
  setMapaResiduosModo(MAPA_MODO_PONTOS)
2533
  setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
2534
  setGeoAuto200(true)
 
2602
  setImportacaoErro('')
2603
  await withBusy(async () => {
2604
  setMapaGerado(false)
2605
+ clearMapaResponse()
2606
  setMapaVariavel(MAPA_VARIAVEL_PADRAO)
2607
  setMapaModo(MAPA_MODO_PONTOS)
2608
  setMapaResiduosGerado(false)
2609
+ clearMapaResiduosResponse()
2610
  setMapaResiduosModo(MAPA_MODO_PONTOS)
2611
  setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
2612
  setGeoAuto200(true)
 
2752
  setGeoProcessError('')
2753
  try {
2754
  const resp = await api.mapCoords(sessionId, manualLat, manualLon)
2755
+ applyMapaResponse(resp)
2756
  setDados(resp.dados)
2757
  setCoordsInfo(resp.coords)
2758
  setGeoStatusHtml('')
 
2776
  setGeoStatusHtml(resp.status_html || '')
2777
  setGeoFalhasHtml(resp.falhas_html || '')
2778
  setGeoCorrecoes(parseCorrecoes(resp.falhas_para_correcao))
2779
+ applyMapaResponse(resp)
2780
  setDados(resp.dados || null)
2781
  setCoordsInfo(resp.coords || null)
2782
  setCoordsMode('geocodificar')
 
2798
  setGeoStatusHtml(resp.status_html || '')
2799
  setGeoFalhasHtml(resp.falhas_html || '')
2800
  setGeoCorrecoes(parseCorrecoes(resp.falhas_para_correcao))
2801
+ applyMapaResponse(resp)
2802
  setDados(resp.dados || null)
2803
  setCoordsInfo(resp.coords || null)
2804
  setCoordsMode('geocodificar')
 
2816
  setGeoStatusHtml(resp.status_html || '')
2817
  setGeoFalhasHtml(resp.falhas_html || '')
2818
  setGeoCorrecoes(parseCorrecoes(resp.falhas_para_correcao))
2819
+ applyMapaResponse(resp)
2820
  setDados(resp.dados || null)
2821
  setCoordsInfo(resp.coords || null)
2822
  setGeoCdlog(escolherColunaCdlogPadrao(resp.coords || null))
 
2836
  setGeoStatusHtml(resp.status_html || '')
2837
  setGeoFalhasHtml(resp.falhas_html || '')
2838
  setGeoCorrecoes(parseCorrecoes(resp.falhas_para_correcao))
2839
+ applyMapaResponse(resp)
2840
  setDados(resp.dados || null)
2841
  setCoordsInfo(resp.coords || null)
2842
  setManualLat(resp.coords?.colunas_disponiveis?.[0] || '')
 
3220
  await withBusy(async () => {
3221
  const resp = await api.clearOutlierHistory(sessionId)
3222
  setDados(resp.dados)
3223
+ applyMapaResponse(resp)
3224
  setResumoOutliers(resp.resumo_outliers || 'Excluidos: 0 | A excluir: 0 | A reincluir: 0 | Total: 0')
3225
  setOutliersAnteriores([])
3226
  setIteracao(1)
 
3306
  setKnnDetalheErro('')
3307
  setKnnDetalheCardTitulo(`Aval. ${idx + 1}`)
3308
  setKnnDetalheMapaHtml('')
3309
+ setKnnDetalheMapaPayload(null)
3310
  setKnnDetalheAvaliando([])
3311
  setKnnDetalheTabela(null)
3312
  setKnnDetalheInfo(null)
 
3314
  try {
3315
  const resp = await api.evaluationKnnDetailsElab(sessionId, construirPayloadKnnAvaliacao(avaliacao))
3316
  setKnnDetalheMapaHtml(String(resp?.mapa_html || ''))
3317
+ setKnnDetalheMapaPayload(resp?.mapa_payload || null)
3318
  setKnnDetalheAvaliando(Array.isArray(resp?.avaliando) ? resp.avaliando : [])
3319
  setKnnDetalheTabela(resp?.vizinhos_tabela || null)
3320
  setKnnDetalheInfo(resp?.knn || null)
 
3495
  if (!sessionId || !mapaGerado) return
3496
  await withBusy(async () => {
3497
  const resp = await api.updateElaboracaoMap(sessionId, value, nextModo)
3498
+ applyMapaResponse(resp)
3499
  })
3500
  }
3501
 
 
3504
  if (!sessionId || !mapaGerado) return
3505
  await withBusy(async () => {
3506
  const resp = await api.updateElaboracaoMap(sessionId, mapaVariavel, value)
3507
+ applyMapaResponse(resp)
3508
  })
3509
  }
3510
 
 
3516
  }
3517
  await withBusy(async () => {
3518
  const resp = await api.updateElaboracaoMap(sessionId, mapaVariavel, modoAtual)
3519
+ applyMapaResponse(resp)
3520
  setMapaGerado(true)
3521
  })
3522
  }
 
3527
  const extremoAbs = parseMapaResiduosExtremoAbs(mapaResiduosExtremoAbs)
3528
  await withBusy(async () => {
3529
  const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, value, extremoAbs)
3530
+ applyMapaResiduosResponse(resp)
3531
  const extremoResp = Number(resp?.escala_extremo_abs)
3532
  if (Number.isFinite(extremoResp) && extremoResp > 0) {
3533
  setMapaResiduosExtremoAbs(String(Number(extremoResp)))
 
3544
  if (!sessionId || !mapaResiduosGerado) return
3545
  await withBusy(async () => {
3546
  const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, mapaResiduosModo, extremoAbs)
3547
+ applyMapaResiduosResponse(resp)
3548
  const extremoResp = Number(resp?.escala_extremo_abs)
3549
  if (Number.isFinite(extremoResp) && extremoResp > 0) {
3550
  setMapaResiduosExtremoAbs(String(Number(extremoResp)))
 
3559
  const extremoAbs = parseMapaResiduosExtremoAbs(mapaResiduosExtremoAbs)
3560
  await withBusy(async () => {
3561
  const resp = await api.updateElaboracaoResiduosMap(sessionId, MAPA_RESIDUOS_VARIAVEL, mapaResiduosModo, extremoAbs)
3562
+ applyMapaResiduosResponse(resp)
3563
  const extremoResp = Number(resp?.escala_extremo_abs)
3564
  if (Number.isFinite(extremoResp) && extremoResp > 0) {
3565
  setMapaResiduosExtremoAbs(String(Number(extremoResp)))
 
4546
  Fazer download
4547
  </button>
4548
  </div>
4549
+ <MapFrame html={mapaHtml} payload={mapaPayload} sessionId={sessionId} />
4550
  </details>
4551
  )}
4552
  </div>
 
5891
  Fazer download
5892
  </button>
5893
  </div>
5894
+ <MapFrame html={mapaResiduosHtml} payload={mapaResiduosPayload} sessionId={sessionId} />
5895
  </>
5896
  )}
5897
  </div>
 
6333
  </div>
6334
 
6335
  <div className="avaliacao-knn-map-wrap">
6336
+ <MapFrame html={knnDetalheMapaHtml} payload={knnDetalheMapaPayload} sessionId={sessionId} />
6337
  </div>
6338
 
6339
  <div className="subpanel avaliacao-knn-detalhes-box">
frontend/src/components/LeafletMapFrame.jsx ADDED
@@ -0,0 +1,1004 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+ import L from 'leaflet'
3
+ import 'leaflet.fullscreen'
4
+ import 'leaflet.heat'
5
+ import { apiUrl, getAuthToken } from '../api'
6
+
7
+ let bairrosGeojsonCache = null
8
+ let bairrosGeojsonPromise = null
9
+
10
+ function escapeHtml(value) {
11
+ return String(value ?? '')
12
+ .replaceAll('&', '&amp;')
13
+ .replaceAll('<', '&lt;')
14
+ .replaceAll('>', '&gt;')
15
+ .replaceAll('"', '&quot;')
16
+ }
17
+
18
+ function buildTooltipHtml(tooltip) {
19
+ const title = String(tooltip?.title || '').trim()
20
+ const label = String(tooltip?.label || '').trim()
21
+ const value = String(tooltip?.value || '').trim()
22
+ return [
23
+ '<div style="font-family:\'Segoe UI\',Arial,sans-serif; font-size:14px; line-height:1.7; padding:2px 4px;">',
24
+ title ? `<b>${escapeHtml(title)}</b>` : '',
25
+ label && value ? `<br><span style="color:#555;">${escapeHtml(label)}:</span> <b>${escapeHtml(value)}</b>` : '',
26
+ '</div>',
27
+ ].join('')
28
+ }
29
+
30
+ function buildPopupErrorHtml(message) {
31
+ const text = escapeHtml(message || 'Falha ao carregar os dados do registro.')
32
+ return (
33
+ '<div style="font-family:\'Segoe UI\'; border-radius:8px; overflow:hidden; width:340px; max-width:340px;">'
34
+ + '<div style="background:#6c757d; color:white; padding:10px 15px; font-weight:600;">Dados do Registro</div>'
35
+ + `<div style="padding:12px 15px; background:#f8f9fa; color:#b42318; font-size:12px;">${text}</div>`
36
+ + '</div>'
37
+ )
38
+ }
39
+
40
+ function ensurePopupPager(root) {
41
+ if (!root || root.dataset.mesaPagerBound === '1') return
42
+ root.dataset.mesaPagerBound = '1'
43
+
44
+ const pages = Array.from(root.querySelectorAll('.mesa-popup-page'))
45
+ if (!pages.length) return
46
+
47
+ const numberWrap = root.querySelector('[data-page-number-wrap]')
48
+ if (numberWrap) {
49
+ numberWrap.innerHTML = ''
50
+ pages.forEach((_, index) => {
51
+ const button = document.createElement('button')
52
+ button.type = 'button'
53
+ button.dataset.pageNumber = String(index + 1)
54
+ button.textContent = String(index + 1)
55
+ button.style.border = '1px solid #ced8e2'
56
+ button.style.background = '#fff'
57
+ button.style.borderRadius = '6px'
58
+ button.style.padding = '2px 7px'
59
+ button.style.fontSize = '11px'
60
+ button.style.cursor = 'pointer'
61
+ button.style.color = '#4e6479'
62
+ button.style.display = 'inline-flex'
63
+ button.style.alignItems = 'center'
64
+ button.style.justifyContent = 'center'
65
+ numberWrap.appendChild(button)
66
+ })
67
+ }
68
+
69
+ function updatePager(targetPage) {
70
+ const total = pages.length
71
+ let current = Number.parseInt(String(targetPage || root.dataset.currentPage || '1'), 10)
72
+ if (!Number.isFinite(current) || current < 1) current = 1
73
+ if (current > total) current = total
74
+ root.dataset.currentPage = String(current)
75
+
76
+ pages.forEach((pageEl, index) => {
77
+ pageEl.style.display = index + 1 === current ? 'block' : 'none'
78
+ })
79
+
80
+ Array.from(root.querySelectorAll('[data-page-number]')).forEach((button) => {
81
+ const value = Number.parseInt(String(button.dataset.pageNumber || '1'), 10)
82
+ const ativo = value === current
83
+ button.style.background = ativo ? '#eaf1f7' : '#fff'
84
+ button.style.borderColor = ativo ? '#9fb4c8' : '#ced8e2'
85
+ button.style.color = ativo ? '#2f4b66' : '#4e6479'
86
+ })
87
+
88
+ const onFirst = current <= 1
89
+ const onLast = current >= total
90
+ const navState = [
91
+ ['first', onFirst],
92
+ ['prev', onFirst],
93
+ ['next', onLast],
94
+ ['last', onLast],
95
+ ]
96
+ navState.forEach(([action, disabled]) => {
97
+ const button = root.querySelector(`[data-a="${action}"]`)
98
+ if (!button) return
99
+ button.disabled = disabled
100
+ button.style.opacity = disabled ? '0.45' : '1'
101
+ button.style.cursor = disabled ? 'not-allowed' : 'pointer'
102
+ })
103
+ }
104
+
105
+ root.addEventListener('click', (event) => {
106
+ const button = event.target.closest('[data-a], [data-page-number]')
107
+ if (!button) return
108
+ event.preventDefault()
109
+ event.stopPropagation()
110
+
111
+ const current = Number.parseInt(String(root.dataset.currentPage || '1'), 10) || 1
112
+ if (button.dataset.pageNumber) {
113
+ updatePager(Number.parseInt(String(button.dataset.pageNumber), 10) || current)
114
+ return
115
+ }
116
+
117
+ const action = String(button.dataset.a || '').trim()
118
+ if (action === 'first') updatePager(1)
119
+ if (action === 'prev') updatePager(current - 1)
120
+ if (action === 'next') updatePager(current + 1)
121
+ if (action === 'last') updatePager(pages.length)
122
+ })
123
+
124
+ updatePager(1)
125
+ }
126
+
127
+ async function carregarBairrosGeojson(url) {
128
+ if (bairrosGeojsonCache) return bairrosGeojsonCache
129
+ if (bairrosGeojsonPromise) return bairrosGeojsonPromise
130
+
131
+ const endpoint = String(url || '').trim()
132
+ if (!endpoint) return null
133
+
134
+ bairrosGeojsonPromise = fetch(endpoint, {
135
+ headers: (() => {
136
+ const token = getAuthToken()
137
+ return token ? { 'X-Auth-Token': token } : {}
138
+ })(),
139
+ })
140
+ .then(async (response) => {
141
+ if (!response.ok) {
142
+ throw new Error('Falha ao carregar camada de bairros.')
143
+ }
144
+ return response.json()
145
+ })
146
+ .then((payload) => {
147
+ bairrosGeojsonCache = payload
148
+ return payload
149
+ })
150
+ .finally(() => {
151
+ bairrosGeojsonPromise = null
152
+ })
153
+
154
+ return bairrosGeojsonPromise
155
+ }
156
+
157
+ function construirLegendaControl(legend) {
158
+ if (!legend) return null
159
+
160
+ return L.control({ position: 'bottomright' })
161
+ }
162
+
163
+ function formatLegendNumber(value) {
164
+ const number = Number(value)
165
+ if (!Number.isFinite(number)) return ''
166
+ return number.toLocaleString('pt-BR', { maximumFractionDigits: 2 })
167
+ }
168
+
169
+ function buildLegendHtml(legend) {
170
+ const title = escapeHtml(String(legend?.title || '').trim())
171
+ const colors = Array.isArray(legend?.colors) ? legend.colors.filter(Boolean) : []
172
+ const gradient = colors.length ? colors.join(', ') : '#2ecc71, #e74c3c'
173
+ const tickValues = Array.isArray(legend?.tick_values) ? legend.tick_values : []
174
+ const tickLabels = Array.isArray(legend?.tick_labels) ? legend.tick_labels : []
175
+ const hasTicks = tickValues.length > 1 && tickValues.length === tickLabels.length
176
+
177
+ const ticksHtml = hasTicks
178
+ ? (
179
+ '<div class="mesa-leaflet-legend-ticks">'
180
+ + tickValues.map((tickValue, index) => {
181
+ const rawMin = Number(legend?.vmin)
182
+ const rawMax = Number(legend?.vmax)
183
+ const ratio = Number.isFinite(rawMin) && Number.isFinite(rawMax) && rawMax > rawMin
184
+ ? (Number(tickValue) - rawMin) / (rawMax - rawMin)
185
+ : index / Math.max(1, tickValues.length - 1)
186
+ const left = Math.max(0, Math.min(100, ratio * 100))
187
+ return (
188
+ `<span class="mesa-leaflet-legend-tick" style="left:${left.toFixed(4)}%;">`
189
+ + `${escapeHtml(String(tickLabels[index] || ''))}`
190
+ + '</span>'
191
+ )
192
+ }).join('')
193
+ + '</div>'
194
+ )
195
+ : ''
196
+
197
+ const scaleHtml = hasTicks
198
+ ? ''
199
+ : (
200
+ '<div class="mesa-leaflet-legend-scale">'
201
+ + `<span>${escapeHtml(formatLegendNumber(legend?.vmin))}</span>`
202
+ + `<span>${escapeHtml(formatLegendNumber(legend?.vmax))}</span>`
203
+ + '</div>'
204
+ )
205
+
206
+ return [
207
+ title ? `<strong>${title}</strong>` : '',
208
+ `<div class="mesa-leaflet-legend-bar" style="background: linear-gradient(90deg, ${gradient});"></div>`,
209
+ ticksHtml,
210
+ scaleHtml,
211
+ ].join('')
212
+ }
213
+
214
+ function addNoticeControl(map, notice) {
215
+ const message = String(notice?.message || '').trim()
216
+ if (!message) return null
217
+ const control = L.control({ position: notice?.position || 'topright' })
218
+ control.onAdd = () => {
219
+ const div = L.DomUtil.create('div', 'mesa-leaflet-notice')
220
+ div.innerHTML = escapeHtml(message)
221
+ return div
222
+ }
223
+ control.addTo(map)
224
+ return control
225
+ }
226
+
227
+ function buildIndiceBadgeHtml(indice) {
228
+ return (
229
+ '<div style="transform: translate(10px, -14px);display:inline-block;background: rgba(255, 255, 255, 0.9);'
230
+ + 'border: 1px solid rgba(28, 45, 66, 0.45);border-radius: 10px;padding: 1px 6px;font-size: 11px;'
231
+ + 'font-weight: 700;line-height: 1.2;color: #1f2f44;white-space: nowrap;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);'
232
+ + 'pointer-events: none;">'
233
+ + `${escapeHtml(indice)}`
234
+ + '</div>'
235
+ )
236
+ }
237
+
238
+ function formatCoordinate(value, positiveLabel, negativeLabel) {
239
+ const numeric = Number(value)
240
+ if (!Number.isFinite(numeric)) return '-'
241
+ const absolute = Math.abs(numeric)
242
+ const degrees = Math.floor(absolute)
243
+ const minutesFloat = (absolute - degrees) * 60
244
+ const minutes = Math.floor(minutesFloat)
245
+ const seconds = ((minutesFloat - minutes) * 60).toFixed(2)
246
+ const direction = numeric >= 0 ? positiveLabel : negativeLabel
247
+ return `${degrees}° ${minutes.toString().padStart(2, '0')}' ${seconds.padStart(5, '0')}" ${direction}`
248
+ }
249
+
250
+ function formatLatLng(latlng) {
251
+ if (!latlng) return ''
252
+ return `${formatCoordinate(latlng.lat, 'N', 'S')} / ${formatCoordinate(latlng.lng, 'L', 'W')}`
253
+ }
254
+
255
+ function formatDecimalLatLng(latlng) {
256
+ if (!latlng) return ''
257
+ return `${Number(latlng.lat).toFixed(6)} / ${Number(latlng.lng).toFixed(6)}`
258
+ }
259
+
260
+ function formatDistance(distanceMeters) {
261
+ const value = Number(distanceMeters)
262
+ if (!Number.isFinite(value) || value <= 0) return '0 m'
263
+ if (value >= 1000) {
264
+ return `${(value / 1000).toLocaleString('pt-BR', { maximumFractionDigits: 2 })} km`
265
+ }
266
+ return `${value.toLocaleString('pt-BR', { maximumFractionDigits: 1 })} m`
267
+ }
268
+
269
+ function computePolygonArea(latlngs) {
270
+ if (!Array.isArray(latlngs) || latlngs.length < 3) return 0
271
+ const d2r = Math.PI / 180
272
+ const radius = 6378137
273
+ let area = 0
274
+ for (let index = 0; index < latlngs.length; index += 1) {
275
+ const current = latlngs[index]
276
+ const next = latlngs[(index + 1) % latlngs.length]
277
+ area += (
278
+ (Number(next.lng) - Number(current.lng)) * d2r
279
+ * (2 + Math.sin(Number(current.lat) * d2r) + Math.sin(Number(next.lat) * d2r))
280
+ )
281
+ }
282
+ return Math.abs(area * radius * radius / 2)
283
+ }
284
+
285
+ function formatArea(areaSquareMeters) {
286
+ const value = Number(areaSquareMeters)
287
+ if (!Number.isFinite(value) || value <= 0) return ''
288
+ if (value >= 10000) {
289
+ return `${(value / 10000).toLocaleString('pt-BR', { maximumFractionDigits: 2 })} ha`
290
+ }
291
+ return `${value.toLocaleString('pt-BR', { maximumFractionDigits: 0 })} m²`
292
+ }
293
+
294
+ function buildLegacyOverlayLayers(payload) {
295
+ const overlays = []
296
+
297
+ if (payload?.show_bairros) {
298
+ overlays.push({
299
+ id: 'bairros',
300
+ label: 'Bairros',
301
+ show: true,
302
+ geojson_url: payload?.bairros_geojson_url || '',
303
+ geojson_style: {
304
+ color: '#4c6882',
305
+ weight: 1.0,
306
+ fillColor: '#f39c12',
307
+ fillOpacity: 0.04,
308
+ },
309
+ geojson_tooltip_properties: ['NOME', 'BAIRRO', 'NME_BAI', 'NOME_BAIRRO'],
310
+ geojson_tooltip_label: 'Bairro',
311
+ })
312
+ }
313
+
314
+ if (Array.isArray(payload?.market_points) && payload.market_points.length) {
315
+ overlays.push({
316
+ id: 'mercado',
317
+ label: 'Mercado',
318
+ show: true,
319
+ points: payload.market_points.map((item) => ({
320
+ ...item,
321
+ popup_request: Number.isFinite(Number(item?.row_id))
322
+ ? { kind: 'visualizacao_row', row_id: Number(item.row_id) }
323
+ : null,
324
+ })),
325
+ })
326
+
327
+ overlays.push({
328
+ id: 'indices',
329
+ label: 'Índices',
330
+ show: Boolean(payload?.show_indices),
331
+ markers: payload.market_points.map((item) => ({
332
+ lat: Number(item?.lat),
333
+ lon: Number(item?.lon),
334
+ marker_html: buildIndiceBadgeHtml(item?.indice),
335
+ icon_size: [72, 24],
336
+ icon_anchor: [0, 0],
337
+ class_name: 'mesa-indice-label',
338
+ interactive: false,
339
+ keyboard: false,
340
+ })),
341
+ })
342
+ }
343
+
344
+ if (Array.isArray(payload?.trabalhos_tecnicos_points) && payload.trabalhos_tecnicos_points.length) {
345
+ overlays.push({
346
+ id: 'avaliandos',
347
+ label: 'Avaliandos',
348
+ show: true,
349
+ markers: payload.trabalhos_tecnicos_points,
350
+ })
351
+ }
352
+
353
+ return overlays
354
+ }
355
+
356
+ async function readJsonSafely(response) {
357
+ const raw = await response.text()
358
+ if (!raw || !raw.trim()) return null
359
+ try {
360
+ return JSON.parse(raw)
361
+ } catch {
362
+ return null
363
+ }
364
+ }
365
+
366
+ export default function LeafletMapFrame({ payload, sessionId }) {
367
+ const hostRef = useRef(null)
368
+ const popupCacheRef = useRef(new Map())
369
+ const [runtimeError, setRuntimeError] = useState('')
370
+
371
+ useEffect(() => {
372
+ if (!hostRef.current || !payload) return undefined
373
+ let disposed = false
374
+ setRuntimeError('')
375
+ hostRef.current.innerHTML = ''
376
+
377
+ const map = L.map(hostRef.current, {
378
+ zoomControl: true,
379
+ preferCanvas: true,
380
+ })
381
+ let restoreMapInteractions = null
382
+ const bairrosPane = map.createPane('mesa-bairros-pane')
383
+ const marketPane = map.createPane('mesa-market-pane')
384
+ const trabalhosPane = map.createPane('mesa-trabalhos-pane')
385
+ const indicesPane = map.createPane('mesa-indices-pane')
386
+ bairrosPane.style.zIndex = '360'
387
+ marketPane.style.zIndex = '420'
388
+ trabalhosPane.style.zIndex = '430'
389
+ indicesPane.style.zIndex = '440'
390
+
391
+ const canvasRenderer = L.canvas({ padding: 0.5, pane: 'mesa-market-pane' })
392
+ const baseLayers = {}
393
+ const overlayLayers = {}
394
+
395
+ ;(payload.tile_layers || []).forEach((layerDef, index) => {
396
+ const tileLayer = L.tileLayer(String(layerDef?.url || ''), {
397
+ attribution: index === 0
398
+ ? '&copy; OpenStreetMap contributors'
399
+ : '&copy; OpenStreetMap contributors &copy; CARTO',
400
+ })
401
+ const label = String(layerDef?.label || layerDef?.id || `Base ${index + 1}`)
402
+ baseLayers[label] = tileLayer
403
+ if (index === 0) {
404
+ tileLayer.addTo(map)
405
+ }
406
+ })
407
+ const overlaySpecs = Array.isArray(payload.overlay_layers) && payload.overlay_layers.length
408
+ ? payload.overlay_layers
409
+ : buildLegacyOverlayLayers(payload)
410
+ const responsivePointContainers = []
411
+
412
+ const radiusBehavior = payload.radius_behavior || {}
413
+ const minRadius = Number(radiusBehavior.min_radius) || 1.6
414
+ const maxRadius = Number(radiusBehavior.max_radius) || 52.0
415
+ const referenceZoom = Number(radiusBehavior.reference_zoom) || 12.0
416
+ const growthFactor = Number(radiusBehavior.growth_factor) || 0.2
417
+
418
+ function clamp(value, min, max) {
419
+ return Math.max(min, Math.min(max, value))
420
+ }
421
+
422
+ function applyResponsiveRadius() {
423
+ const zoom = typeof map.getZoom === 'function' ? map.getZoom() : referenceZoom
424
+ const zoomDelta = zoom - referenceZoom
425
+ const expFactor = 2 ** (zoomDelta * growthFactor)
426
+ let floorScale = 1.0
427
+ if (zoomDelta >= 0) {
428
+ floorScale = 1 + zoomDelta * 0.22
429
+ if (zoom >= 15) {
430
+ floorScale += (zoom - 14) * 0.30
431
+ }
432
+ } else {
433
+ floorScale = Math.max(0.28, 1 + zoomDelta * 0.20)
434
+ }
435
+
436
+ responsivePointContainers.forEach((container) => {
437
+ if (!container || typeof container.eachLayer !== 'function') return
438
+ container.eachLayer((layer) => {
439
+ if (typeof layer.setRadius !== 'function') return
440
+ const base = Number(layer.options?.mesaBaseRadius || layer.options?.radius || 4)
441
+ const dynamicMin = Math.max(minRadius, base * floorScale)
442
+ const dynamicMax = Math.max(dynamicMin + 0.1, Math.min(maxRadius, base * 8.0))
443
+ layer.setRadius(clamp(base * expFactor, dynamicMin, dynamicMax))
444
+ })
445
+ })
446
+ }
447
+
448
+ async function carregarPopupVisualizacao(rowId, layer) {
449
+ const cacheKey = String(rowId)
450
+ const cached = popupCacheRef.current.get(cacheKey)
451
+ if (cached) {
452
+ const cachedHtml = typeof cached === 'string' ? cached : String(cached?.html || '')
453
+ const cachedWidth = typeof cached === 'string' ? 420 : (Number(cached?.width) || 420)
454
+ layer.unbindPopup()
455
+ layer.bindPopup(cachedHtml, { maxWidth: cachedWidth }).openPopup()
456
+ window.setTimeout(() => {
457
+ const root = layer.getPopup()?.getElement()?.querySelector('[data-pager]')
458
+ if (root) ensurePopupPager(root)
459
+ }, 0)
460
+ return
461
+ }
462
+
463
+ layer.bindPopup(
464
+ '<div style="font-family:\'Segoe UI\'; color:#6c757d; font-size:12px;">Carregando detalhes...</div>',
465
+ { maxWidth: 360 },
466
+ ).openPopup()
467
+
468
+ try {
469
+ const response = await fetch(apiUrl('/api/visualizacao/map/popup'), {
470
+ method: 'POST',
471
+ headers: {
472
+ 'Content-Type': 'application/json',
473
+ ...(getAuthToken() ? { 'X-Auth-Token': getAuthToken() } : {}),
474
+ },
475
+ body: JSON.stringify({
476
+ session_id: sessionId,
477
+ row_id: rowId,
478
+ }),
479
+ })
480
+ const payloadResp = await readJsonSafely(response)
481
+ if (!response.ok) {
482
+ throw new Error(String(payloadResp?.detail || 'Falha ao carregar os dados do registro.'))
483
+ }
484
+ if (!payloadResp || typeof payloadResp !== 'object') {
485
+ throw new Error('Resposta vazia ao carregar os dados do registro.')
486
+ }
487
+ const popupHtml = String(payloadResp?.popup_html || '')
488
+ const popupWidth = Number(payloadResp?.popup_width) || 420
489
+ if (!popupHtml.trim()) {
490
+ throw new Error('Detalhes do registro indisponíveis para este ponto.')
491
+ }
492
+ popupCacheRef.current.set(cacheKey, { html: popupHtml, width: popupWidth })
493
+ layer.unbindPopup()
494
+ layer.bindPopup(popupHtml, { maxWidth: popupWidth }).openPopup()
495
+ window.setTimeout(() => {
496
+ const root = layer.getPopup()?.getElement()?.querySelector('[data-pager]')
497
+ if (root) ensurePopupPager(root)
498
+ }, 0)
499
+ } catch (error) {
500
+ layer.unbindPopup()
501
+ layer.bindPopup(buildPopupErrorHtml(error?.message || 'Falha ao carregar os dados do registro.'), { maxWidth: 360 }).openPopup()
502
+ }
503
+ }
504
+
505
+ function bindPointPopup(marker, item) {
506
+ const popupHtml = String(item?.popup_html || '').trim()
507
+ if (popupHtml) {
508
+ marker.bindPopup(popupHtml, { maxWidth: Number(item?.popup_max_width) || 420 })
509
+ return
510
+ }
511
+
512
+ const popupRequest = item?.popup_request
513
+ if (popupRequest?.kind === 'visualizacao_row' && Number.isFinite(Number(popupRequest?.row_id)) && sessionId) {
514
+ marker.on('click', () => {
515
+ void carregarPopupVisualizacao(Number(popupRequest.row_id), marker)
516
+ })
517
+ }
518
+ }
519
+
520
+ function addPointMarkers(layerGroup, items) {
521
+ if (!Array.isArray(items) || !items.length) return
522
+ responsivePointContainers.push(layerGroup)
523
+ items.forEach((item) => {
524
+ const marker = L.circleMarker([Number(item.lat), Number(item.lon)], {
525
+ renderer: canvasRenderer,
526
+ pane: String(item?.pane || 'mesa-market-pane'),
527
+ radius: Number(item?.base_radius) || 4,
528
+ color: String(item?.stroke_color || '#000000'),
529
+ weight: Number.isFinite(Number(item?.stroke_weight))
530
+ ? Number(item.stroke_weight)
531
+ : (Number(item?.base_radius) > 4 ? 1 : 0.8),
532
+ fill: item?.fill !== false,
533
+ fillColor: String(item?.color || item?.fill_color || '#FF8C00'),
534
+ fillOpacity: Number.isFinite(Number(item?.fill_opacity)) ? Number(item.fill_opacity) : 0.68,
535
+ interactive: item?.interactive !== false,
536
+ bubblingMouseEvents: false,
537
+ })
538
+ marker.options.mesaBaseRadius = Number(item?.base_radius) || 4
539
+ const tooltipHtml = String(item?.tooltip_html || '').trim()
540
+ if (tooltipHtml) {
541
+ marker.bindTooltip(tooltipHtml, { sticky: item?.tooltip_sticky !== false })
542
+ } else if (item?.tooltip) {
543
+ marker.bindTooltip(buildTooltipHtml(item.tooltip), { sticky: item?.tooltip_sticky !== false })
544
+ }
545
+ bindPointPopup(marker, item)
546
+ layerGroup.addLayer(marker)
547
+ })
548
+ }
549
+
550
+ function addMarkerOverlays(layerGroup, items) {
551
+ if (!Array.isArray(items) || !items.length) return
552
+ items.forEach((item) => {
553
+ const icon = L.divIcon({
554
+ html: String(item?.marker_html || ''),
555
+ iconSize: Array.isArray(item?.icon_size) ? item.icon_size : [14, 14],
556
+ iconAnchor: Array.isArray(item?.icon_anchor) ? item.icon_anchor : [7, 7],
557
+ className: String(item?.class_name || 'mesa-map-marker'),
558
+ })
559
+ const marker = L.marker([Number(item.lat), Number(item.lon)], {
560
+ icon,
561
+ pane: String(item?.pane || (String(item?.class_name || '').includes('indice') ? 'mesa-indices-pane' : 'mesa-trabalhos-pane')),
562
+ bubblingMouseEvents: item?.bubbling_mouse_events === true,
563
+ interactive: item?.interactive !== false,
564
+ keyboard: item?.keyboard !== false,
565
+ })
566
+ if (item?.ignore_bounds) {
567
+ marker.options.mesaIgnoreBounds = true
568
+ }
569
+ if (item?.tooltip_html) {
570
+ marker.bindTooltip(String(item.tooltip_html), { sticky: item?.tooltip_sticky !== false })
571
+ }
572
+ if (item?.popup_html) {
573
+ marker.bindPopup(String(item.popup_html), { maxWidth: Number(item?.popup_max_width) || 360 })
574
+ }
575
+ layerGroup.addLayer(marker)
576
+ })
577
+ }
578
+
579
+ function addShapeOverlays(layerGroup, shapes) {
580
+ if (!Array.isArray(shapes) || !shapes.length) return
581
+ shapes.forEach((shape) => {
582
+ const shapeType = String(shape?.type || shape?.shape_type || '').trim().toLowerCase()
583
+ const style = {
584
+ color: String(shape?.color || '#1f6fb2'),
585
+ weight: Number.isFinite(Number(shape?.weight)) ? Number(shape.weight) : 2,
586
+ opacity: Number.isFinite(Number(shape?.opacity)) ? Number(shape.opacity) : 0.8,
587
+ fill: shape?.fill === true,
588
+ fillColor: String(shape?.fill_color || shape?.color || '#1f6fb2'),
589
+ fillOpacity: Number.isFinite(Number(shape?.fill_opacity)) ? Number(shape.fill_opacity) : 0.12,
590
+ dashArray: shape?.dash_array ? String(shape.dash_array) : undefined,
591
+ pane: String(shape?.pane || 'mesa-bairros-pane'),
592
+ }
593
+ let layer = null
594
+
595
+ if (shapeType === 'circle' && Array.isArray(shape?.center) && shape.center.length === 2) {
596
+ layer = L.circle(shape.center, { ...style, radius: Number(shape?.radius_m) || 0 })
597
+ } else if (shapeType === 'polyline' && Array.isArray(shape?.coords) && shape.coords.length) {
598
+ layer = L.polyline(shape.coords, style)
599
+ } else if (shapeType === 'polygon' && Array.isArray(shape?.coords) && shape.coords.length) {
600
+ layer = L.polygon(shape.coords, style)
601
+ } else if (shapeType === 'circlemarker' && Array.isArray(shape?.center) && shape.center.length === 2) {
602
+ layer = L.circleMarker(shape.center, { ...style, radius: Number(shape?.radius) || 6 })
603
+ }
604
+
605
+ if (!layer) return
606
+ if (shape?.ignore_bounds) {
607
+ layer.options.mesaIgnoreBounds = true
608
+ }
609
+ if (shape?.tooltip_html) {
610
+ layer.bindTooltip(String(shape.tooltip_html), { sticky: shape?.tooltip_sticky !== false })
611
+ }
612
+ if (shape?.popup_html) {
613
+ layer.bindPopup(String(shape.popup_html), { maxWidth: Number(shape?.popup_max_width) || 360 })
614
+ }
615
+ layerGroup.addLayer(layer)
616
+ })
617
+ }
618
+
619
+ function addGeoJsonOverlay(layerGroup, spec) {
620
+ const geojsonLayer = L.geoJSON(null, {
621
+ pane: String(spec?.geojson_pane || 'mesa-bairros-pane'),
622
+ style: () => ({
623
+ color: String(spec?.geojson_style?.color || '#4c6882'),
624
+ weight: Number.isFinite(Number(spec?.geojson_style?.weight)) ? Number(spec.geojson_style.weight) : 1.0,
625
+ fillColor: String(spec?.geojson_style?.fillColor || '#f39c12'),
626
+ fillOpacity: Number.isFinite(Number(spec?.geojson_style?.fillOpacity))
627
+ ? Number(spec.geojson_style.fillOpacity)
628
+ : 0.04,
629
+ }),
630
+ onEachFeature: (feature, layer) => {
631
+ const props = feature?.properties || {}
632
+ const candidates = Array.isArray(spec?.geojson_tooltip_properties) ? spec.geojson_tooltip_properties : []
633
+ const value = candidates.map((key) => props?.[key]).find((entry) => String(entry || '').trim())
634
+ if (value) {
635
+ const prefix = String(spec?.geojson_tooltip_label || '').trim()
636
+ layer.bindTooltip(prefix ? `${prefix}: ${String(value)}` : String(value), { sticky: false })
637
+ }
638
+ },
639
+ })
640
+ layerGroup.addLayer(geojsonLayer)
641
+
642
+ if (spec?.geojson_data) {
643
+ geojsonLayer.addData(spec.geojson_data)
644
+ return
645
+ }
646
+ if (!spec?.geojson_url) return
647
+ void carregarBairrosGeojson(spec.geojson_url)
648
+ .then((geojson) => {
649
+ if (disposed || !geojson) return
650
+ geojsonLayer.addData(geojson)
651
+ })
652
+ .catch(() => {
653
+ // camada opcional; manter o mapa funcional sem ela
654
+ })
655
+ }
656
+
657
+ function addHeatmapOverlay(layerGroup, heatmapSpec) {
658
+ if (!heatmapSpec || typeof L.heatLayer !== 'function') return
659
+ const points = Array.isArray(heatmapSpec?.points)
660
+ ? heatmapSpec.points
661
+ .map((item) => {
662
+ const lat = Number(item?.lat)
663
+ const lon = Number(item?.lon)
664
+ const weight = Number(item?.weight)
665
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
666
+ if (Number.isFinite(weight)) return [lat, lon, weight]
667
+ return [lat, lon]
668
+ })
669
+ .filter(Boolean)
670
+ : []
671
+ if (!points.length) return
672
+ const layer = L.heatLayer(points, {
673
+ radius: Number(heatmapSpec?.radius) || 20,
674
+ blur: Number(heatmapSpec?.blur) || 18,
675
+ minOpacity: Number.isFinite(Number(heatmapSpec?.min_opacity)) ? Number(heatmapSpec.min_opacity) : 0.28,
676
+ maxZoom: Number(heatmapSpec?.max_zoom) || 17,
677
+ gradient: heatmapSpec?.gradient || undefined,
678
+ })
679
+ layerGroup.addLayer(layer)
680
+ }
681
+
682
+ overlaySpecs.forEach((spec, index) => {
683
+ const label = String(spec?.label || spec?.id || `Camada ${index + 1}`)
684
+ const layerGroup = L.layerGroup()
685
+ overlayLayers[label] = layerGroup
686
+ if (spec?.show !== false) {
687
+ layerGroup.addTo(map)
688
+ }
689
+ if (spec?.geojson_url || spec?.geojson_data) {
690
+ addGeoJsonOverlay(layerGroup, spec)
691
+ }
692
+ addHeatmapOverlay(layerGroup, spec?.heatmap)
693
+ addShapeOverlays(layerGroup, spec?.shapes)
694
+ addPointMarkers(layerGroup, spec?.points)
695
+ addMarkerOverlays(layerGroup, spec?.markers)
696
+ })
697
+
698
+ if (payload.controls?.layer_control) {
699
+ L.control.layers(baseLayers, overlayLayers, { collapsed: true }).addTo(map)
700
+ }
701
+ if (payload.controls?.fullscreen && typeof L.control.fullscreen === 'function') {
702
+ L.control.fullscreen({ position: 'topleft' }).addTo(map)
703
+ }
704
+ if (payload.controls?.measure) {
705
+ const disableInteractionsForMeasure = () => {
706
+ if (restoreMapInteractions) return
707
+ const states = {
708
+ dragging: !!map.dragging?.enabled?.(),
709
+ doubleClickZoom: !!map.doubleClickZoom?.enabled?.(),
710
+ boxZoom: !!map.boxZoom?.enabled?.(),
711
+ keyboard: !!map.keyboard?.enabled?.(),
712
+ touchZoom: !!map.touchZoom?.enabled?.(),
713
+ scrollWheelZoom: !!map.scrollWheelZoom?.enabled?.(),
714
+ }
715
+ map.dragging?.disable?.()
716
+ map.doubleClickZoom?.disable?.()
717
+ map.boxZoom?.disable?.()
718
+ map.keyboard?.disable?.()
719
+ map.touchZoom?.disable?.()
720
+ map.scrollWheelZoom?.disable?.()
721
+ if (map.getContainer()) {
722
+ map.getContainer().style.cursor = 'crosshair'
723
+ }
724
+ restoreMapInteractions = () => {
725
+ if (states.dragging) map.dragging?.enable?.()
726
+ if (states.doubleClickZoom) map.doubleClickZoom?.enable?.()
727
+ if (states.boxZoom) map.boxZoom?.enable?.()
728
+ if (states.keyboard) map.keyboard?.enable?.()
729
+ if (states.touchZoom) map.touchZoom?.enable?.()
730
+ if (states.scrollWheelZoom) map.scrollWheelZoom?.enable?.()
731
+ if (map.getContainer()) {
732
+ map.getContainer().style.cursor = ''
733
+ }
734
+ restoreMapInteractions = null
735
+ }
736
+ }
737
+ const measureLayerGroup = L.layerGroup().addTo(map)
738
+ const measureState = {
739
+ panelOpen: false,
740
+ active: false,
741
+ points: [],
742
+ }
743
+
744
+ let measureRoot = null
745
+ let measureInteraction = null
746
+ let measureHint = null
747
+ let measureResults = null
748
+ let measureStartButton = null
749
+ let measureCancelButton = null
750
+ let measureFinishButton = null
751
+ let measureCloseButton = null
752
+
753
+ function renderMeasureGeometry() {
754
+ measureLayerGroup.clearLayers()
755
+ if (!measureState.points.length) return
756
+
757
+ if (measureState.points.length >= 2) {
758
+ L.polyline(measureState.points, {
759
+ color: '#1f6fb2',
760
+ weight: 3,
761
+ opacity: 0.9,
762
+ dashArray: measureState.active ? '8 6' : undefined,
763
+ }).addTo(measureLayerGroup)
764
+ }
765
+
766
+ if (!measureState.active && measureState.points.length >= 3) {
767
+ L.polygon(measureState.points, {
768
+ color: '#1f6fb2',
769
+ weight: 2,
770
+ opacity: 0.88,
771
+ fillColor: '#1f6fb2',
772
+ fillOpacity: 0.12,
773
+ }).addTo(measureLayerGroup)
774
+ }
775
+
776
+ measureState.points.forEach((latlng) => {
777
+ L.circleMarker(latlng, {
778
+ radius: 5,
779
+ color: '#ffffff',
780
+ weight: 2,
781
+ fillColor: '#1f6fb2',
782
+ fillOpacity: 1,
783
+ pane: 'mesa-indices-pane',
784
+ }).addTo(measureLayerGroup)
785
+ })
786
+ }
787
+
788
+ function renderMeasurePanel() {
789
+ if (!measureRoot || !measureInteraction || !measureHint || !measureResults) return
790
+ measureRoot.classList.toggle('leaflet-control-measure-expanded', measureState.panelOpen)
791
+ measureInteraction.style.display = measureState.panelOpen ? 'block' : 'none'
792
+ if (!measureState.panelOpen) return
793
+
794
+ const pointCount = measureState.points.length
795
+ const lastPoint = pointCount ? measureState.points[pointCount - 1] : null
796
+ const distance = pointCount >= 2
797
+ ? measureState.points.reduce((total, point, index, list) => {
798
+ if (index === 0) return total
799
+ return total + map.distance(list[index - 1], point)
800
+ }, 0)
801
+ : 0
802
+ const area = !measureState.active && pointCount >= 3 ? computePolygonArea(measureState.points) : 0
803
+
804
+ if (measureState.active) {
805
+ measureHint.textContent = pointCount
806
+ ? 'Clique no mapa para adicionar pontos. Dê duplo clique ou finalize para encerrar.'
807
+ : 'Clique no mapa para iniciar a medição.'
808
+ } else if (pointCount) {
809
+ measureHint.textContent = 'Medição concluída.'
810
+ } else {
811
+ measureHint.textContent = 'Inicie uma nova medição.'
812
+ }
813
+
814
+ const parts = []
815
+ if (lastPoint) {
816
+ parts.push(
817
+ '<div class="mesa-leaflet-measure-row">'
818
+ + '<strong>Último ponto</strong>'
819
+ + `<span>${escapeHtml(formatLatLng(lastPoint))}</span>`
820
+ + `<span>${escapeHtml(formatDecimalLatLng(lastPoint))}</span>`
821
+ + '</div>',
822
+ )
823
+ }
824
+ if (distance > 0) {
825
+ parts.push(
826
+ '<div class="mesa-leaflet-measure-row">'
827
+ + '<strong>Distância total</strong>'
828
+ + `<span>${escapeHtml(formatDistance(distance))}</span>`
829
+ + '</div>',
830
+ )
831
+ }
832
+ if (area > 0) {
833
+ parts.push(
834
+ '<div class="mesa-leaflet-measure-row">'
835
+ + '<strong>Área</strong>'
836
+ + `<span>${escapeHtml(formatArea(area))}</span>`
837
+ + '</div>',
838
+ )
839
+ }
840
+ measureResults.innerHTML = parts.join('')
841
+ measureResults.style.display = parts.length ? 'block' : 'none'
842
+ measureStartButton.style.display = !measureState.active ? 'inline-flex' : 'none'
843
+ measureStartButton.textContent = measureState.points.length ? 'Nova medição' : 'Criar nova medição'
844
+ measureCancelButton.style.display = measureState.active || measureState.points.length ? 'inline-flex' : 'none'
845
+ measureFinishButton.style.display = measureState.active && measureState.points.length >= 2 ? 'inline-flex' : 'none'
846
+ measureCloseButton.style.display = !measureState.active ? 'inline-flex' : 'none'
847
+ }
848
+
849
+ function resetMeasureState({ closePanel = false } = {}) {
850
+ measureState.active = false
851
+ measureState.points = []
852
+ measureLayerGroup.clearLayers()
853
+ restoreMapInteractions?.()
854
+ if (closePanel) {
855
+ measureState.panelOpen = false
856
+ }
857
+ renderMeasurePanel()
858
+ }
859
+
860
+ function startMeasurement() {
861
+ measureState.panelOpen = true
862
+ measureState.active = true
863
+ measureState.points = []
864
+ measureLayerGroup.clearLayers()
865
+ disableInteractionsForMeasure()
866
+ renderMeasurePanel()
867
+ }
868
+
869
+ function finishMeasurement() {
870
+ if (!measureState.active) return
871
+ measureState.active = false
872
+ restoreMapInteractions?.()
873
+ renderMeasureGeometry()
874
+ renderMeasurePanel()
875
+ }
876
+
877
+ function onMeasureMapClick(event) {
878
+ if (!measureState.active || !event?.latlng) return
879
+ const lastPoint = measureState.points[measureState.points.length - 1]
880
+ if (lastPoint && typeof event.latlng.equals === 'function' && event.latlng.equals(lastPoint)) return
881
+ measureState.points = [...measureState.points, event.latlng]
882
+ renderMeasureGeometry()
883
+ renderMeasurePanel()
884
+ }
885
+
886
+ function onMeasureMapDblClick(event) {
887
+ if (!measureState.active) return
888
+ L.DomEvent.stop(event)
889
+ finishMeasurement()
890
+ }
891
+
892
+ map.on('click', onMeasureMapClick)
893
+ map.on('dblclick', onMeasureMapDblClick)
894
+
895
+ const measureControl = L.control({ position: 'topright' })
896
+ measureControl.onAdd = () => {
897
+ measureRoot = L.DomUtil.create('div', 'leaflet-control-measure mesa-leaflet-measure leaflet-bar leaflet-control')
898
+ measureRoot.setAttribute('aria-haspopup', 'true')
899
+
900
+ const toggle = L.DomUtil.create('a', 'leaflet-control-measure-toggle mesa-leaflet-measure-toggle', measureRoot)
901
+ toggle.href = '#'
902
+ toggle.title = 'Medir distâncias'
903
+ toggle.setAttribute('aria-label', 'Medir distâncias')
904
+ toggle.innerHTML = '<span class="mesa-sr-only">Medir distâncias</span>'
905
+
906
+ measureInteraction = L.DomUtil.create('div', 'leaflet-control-measure-interaction mesa-leaflet-measure-interaction', measureRoot)
907
+ measureInteraction.style.display = 'none'
908
+
909
+ const title = L.DomUtil.create('h3', 'mesa-leaflet-measure-title', measureInteraction)
910
+ title.textContent = 'Medir distâncias'
911
+
912
+ measureHint = L.DomUtil.create('p', 'mesa-leaflet-measure-hint', measureInteraction)
913
+ measureResults = L.DomUtil.create('div', 'mesa-leaflet-measure-results', measureInteraction)
914
+
915
+ const actions = L.DomUtil.create('div', 'mesa-leaflet-measure-actions', measureInteraction)
916
+ measureStartButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn is-primary', actions)
917
+ measureStartButton.type = 'button'
918
+ measureStartButton.textContent = 'Criar nova medição'
919
+
920
+ measureCancelButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn', actions)
921
+ measureCancelButton.type = 'button'
922
+ measureCancelButton.textContent = 'Cancelar'
923
+
924
+ measureFinishButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn', actions)
925
+ measureFinishButton.type = 'button'
926
+ measureFinishButton.textContent = 'Finalizar'
927
+
928
+ measureCloseButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn', actions)
929
+ measureCloseButton.type = 'button'
930
+ measureCloseButton.textContent = 'Fechar'
931
+
932
+ L.DomEvent.disableClickPropagation(measureRoot)
933
+ L.DomEvent.disableScrollPropagation(measureRoot)
934
+ L.DomEvent.on(toggle, 'click', (event) => {
935
+ L.DomEvent.stop(event)
936
+ if (measureState.active) return
937
+ measureState.panelOpen = !measureState.panelOpen
938
+ renderMeasurePanel()
939
+ })
940
+ L.DomEvent.on(measureStartButton, 'click', (event) => {
941
+ L.DomEvent.stop(event)
942
+ startMeasurement()
943
+ })
944
+ L.DomEvent.on(measureCancelButton, 'click', (event) => {
945
+ L.DomEvent.stop(event)
946
+ resetMeasureState()
947
+ })
948
+ L.DomEvent.on(measureFinishButton, 'click', (event) => {
949
+ L.DomEvent.stop(event)
950
+ finishMeasurement()
951
+ })
952
+ L.DomEvent.on(measureCloseButton, 'click', (event) => {
953
+ L.DomEvent.stop(event)
954
+ if (measureState.active) return
955
+ measureState.panelOpen = false
956
+ renderMeasurePanel()
957
+ })
958
+
959
+ renderMeasurePanel()
960
+ return measureRoot
961
+ }
962
+ measureControl.addTo(map)
963
+ }
964
+
965
+ if (payload.legend?.title) {
966
+ const legendControl = construirLegendaControl(payload.legend)
967
+ if (legendControl) {
968
+ legendControl.onAdd = () => {
969
+ const div = L.DomUtil.create('div', 'mesa-leaflet-legend')
970
+ div.innerHTML = buildLegendHtml(payload.legend)
971
+ return div
972
+ }
973
+ legendControl.addTo(map)
974
+ }
975
+ }
976
+
977
+ addNoticeControl(map, payload.notice)
978
+
979
+ map.on('zoomend overlayadd overlayremove', applyResponsiveRadius)
980
+ applyResponsiveRadius()
981
+
982
+ const bounds = Array.isArray(payload.bounds) ? payload.bounds : null
983
+ if (bounds && bounds.length === 2) {
984
+ map.fitBounds(bounds, { padding: [48, 48], maxZoom: 18, animate: false })
985
+ } else if (Array.isArray(payload.center) && payload.center.length === 2) {
986
+ map.setView(payload.center, 12)
987
+ } else {
988
+ setRuntimeError('Falha ao montar mapa interativo.')
989
+ }
990
+
991
+ return () => {
992
+ disposed = true
993
+ restoreMapInteractions?.()
994
+ map.remove()
995
+ }
996
+ }, [payload, sessionId])
997
+
998
+ return (
999
+ <div className="map-frame leaflet-map-host">
1000
+ <div ref={hostRef} className="leaflet-map-canvas" />
1001
+ {runtimeError ? <div className="leaflet-map-runtime-error">{runtimeError}</div> : null}
1002
+ </div>
1003
+ )
1004
+ }
frontend/src/components/MapFrame.jsx CHANGED
@@ -1,4 +1,5 @@
1
  import React, { useCallback, useEffect, useMemo, useRef } from 'react'
 
2
 
3
  function hashHtml(value) {
4
  let hash = 0
@@ -10,7 +11,7 @@ function hashHtml(value) {
10
  return `${value.length}-${Math.abs(hash)}`
11
  }
12
 
13
- export default function MapFrame({ html }) {
14
  const iframeRef = useRef(null)
15
  const timersRef = useRef([])
16
  const frameKey = useMemo(() => hashHtml(html || ''), [html])
@@ -129,6 +130,10 @@ export default function MapFrame({ html }) {
129
  }
130
  }, [clearTimers])
131
 
 
 
 
 
132
  if (!html) {
133
  return <div className="empty-box">Mapa indisponivel.</div>
134
  }
 
1
  import React, { useCallback, useEffect, useMemo, useRef } from 'react'
2
+ import LeafletMapFrame from './LeafletMapFrame'
3
 
4
  function hashHtml(value) {
5
  let hash = 0
 
11
  return `${value.length}-${Math.abs(hash)}`
12
  }
13
 
14
+ export default function MapFrame({ html, payload = null, sessionId = '' }) {
15
  const iframeRef = useRef(null)
16
  const timersRef = useRef([])
17
  const frameKey = useMemo(() => hashHtml(html || ''), [html])
 
130
  }
131
  }, [clearTimers])
132
 
133
+ if (payload && payload.type === 'mesa_leaflet_payload') {
134
+ return <LeafletMapFrame payload={payload} sessionId={sessionId} />
135
+ }
136
+
137
  if (!html) {
138
  return <div className="empty-box">Mapa indisponivel.</div>
139
  }
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -980,6 +980,7 @@ export default function PesquisaTab({
980
  const [mapaError, setMapaError] = useState('')
981
  const [mapaStatus, setMapaStatus] = useState('')
982
  const [mapaHtmls, setMapaHtmls] = useState({ pontos: '', cobertura: '' })
 
983
  const [mapaModoExibicao, setMapaModoExibicao] = useState('pontos')
984
  const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState('selecionados_e_outras_versoes')
985
  const [mapaTrabalhosTecnicosProximidadeModo, setMapaTrabalhosTecnicosProximidadeModo] = useState('sem_proximidade')
@@ -999,6 +1000,7 @@ export default function PesquisaTab({
999
  const [modeloAbertoCoeficientes, setModeloAbertoCoeficientes] = useState(null)
1000
  const [modeloAbertoObsCalc, setModeloAbertoObsCalc] = useState(null)
1001
  const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
 
1002
  const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
1003
  const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
1004
  const [modeloAbertoTrabalhosTecnicosModelosModo, setModeloAbertoTrabalhosTecnicosModelosModo] = useState(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
@@ -1008,6 +1010,8 @@ export default function PesquisaTab({
1008
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
1009
  const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
1010
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
 
 
1011
  const sectionResultadosRef = useRef(null)
1012
  const sectionMapaRef = useRef(null)
1013
  const scrollMapaHandledRef = useRef('')
@@ -1015,6 +1019,8 @@ export default function PesquisaTab({
1015
  const resultRequestSeqRef = useRef(0)
1016
  const searchRequestSeqRef = useRef(0)
1017
  const contextRequestSeqRef = useRef(0)
 
 
1018
 
1019
  const sugestoes = result.sugestoes || {}
1020
  const opcoesTipoModelo = useMemo(
@@ -1038,7 +1044,8 @@ export default function PesquisaTab({
1038
  const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
1039
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
1040
  const mapaHtmlAtual = mapaHtmls[mapaModoExibicao] || ''
1041
- const mapaFoiGerado = Boolean(mapaHtmls.pontos || mapaHtmls.cobertura)
 
1042
  const pesquisaShareAvaliando = !localizacaoMultipla ? (avaliandosGeoPayload[0] || null) : null
1043
  const pesquisaShareHref = buildPesquisaLink(filters, pesquisaShareAvaliando)
1044
  const pesquisaShareDisabled = localizacaoMultipla
@@ -1089,6 +1096,7 @@ export default function PesquisaTab({
1089
 
1090
  function resetMapaPesquisa() {
1091
  setMapaHtmls({ pontos: '', cobertura: '' })
 
1092
  setMapaStatus('')
1093
  setMapaError('')
1094
  setMapaModoExibicao('pontos')
@@ -1126,6 +1134,7 @@ export default function PesquisaTab({
1126
 
1127
  if (!idsValidos.length) {
1128
  setMapaHtmls({ pontos: '', cobertura: '' })
 
1129
  setMapaStatus('Nenhum modelo marcado para exibição no mapa.')
1130
  setMapaError('')
1131
  mapaTrabalhosTecnicosConfigRef.current = ''
@@ -1149,10 +1158,17 @@ export default function PesquisaTab({
1149
  || (modoExibicaoSolicitado === 'cobertura' ? response.mapa_html_cobertura : response.mapa_html_pontos)
1150
  || '',
1151
  )
 
 
 
1152
  setMapaHtmls({
1153
  pontos: modoExibicaoSolicitado === 'pontos' ? mapaHtmlSolicitado : '',
1154
  cobertura: modoExibicaoSolicitado === 'cobertura' ? mapaHtmlSolicitado : '',
1155
  })
 
 
 
 
1156
  setMapaStatus(response.status || '')
1157
  mapaTrabalhosTecnicosConfigRef.current = buildMapaTrabalhosTecnicosConfigKey(trabalhosTecnicosConfig)
1158
  } catch (err) {
@@ -1422,25 +1438,125 @@ export default function PesquisaTab({
1422
  }
1423
  }
1424
 
1425
- function preencherModeloAberto(resp) {
1426
- setModeloAbertoDados(resp?.dados || null)
1427
- setModeloAbertoEstatisticas(resp?.estatisticas || null)
1428
- setModeloAbertoEscalasHtml(resp?.escalas_html || '')
1429
- setModeloAbertoDadosTransformados(resp?.dados_transformados || null)
1430
- setModeloAbertoResumoHtml(resp?.resumo_html || '')
1431
- setModeloAbertoEquacoes(resp?.equacoes || null)
1432
- setModeloAbertoCoeficientes(resp?.coeficientes || null)
1433
- setModeloAbertoObsCalc(resp?.obs_calc || null)
1434
- setModeloAbertoMapaHtml(resp?.mapa_html || '')
1435
- setModeloAbertoMapaChoices(resp?.mapa_choices || ['Visualização Padrão'])
 
 
1436
  setModeloAbertoMapaVar('Visualização Padrão')
1437
- setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
1438
- setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
1439
- setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
1440
- setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
1441
- setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
1442
- setModeloAbertoPlotCook(resp?.grafico_cook || null)
1443
- setModeloAbertoPlotCorr(resp?.grafico_correlacao || null)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1444
  }
1445
 
1446
  async function onAbrirModelo(modelo) {
@@ -1456,17 +1572,29 @@ export default function PesquisaTab({
1456
  setError('Sessao indisponivel no momento. Aguarde e tente novamente.')
1457
  return
1458
  }
 
 
 
1459
  setModeloAbertoLoading(true)
1460
  setModeloAbertoError('')
 
 
 
 
 
1461
  try {
1462
- await api.visualizacaoRepositorioCarregar(sessionId, modelo.id)
1463
- const resp = await api.exibirVisualizacao(sessionId, MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
1464
- preencherModeloAberto(resp)
1465
  setModeloAbertoActiveTab('mapa')
1466
  setModeloAbertoMeta({
1467
  id: modelo.id,
1468
  nome: modelo.nome_modelo || modelo.arquivo || modelo.id,
1469
- observacao: String(resp?.meta_modelo?.observacao_modelo || '').trim(),
 
 
 
 
 
1470
  })
1471
  window.requestAnimationFrame(() => {
1472
  scrollParaTopoDaPagina()
@@ -1479,9 +1607,12 @@ export default function PesquisaTab({
1479
  }
1480
 
1481
  function onVoltarPesquisa() {
 
1482
  setModeloAbertoMeta(null)
1483
  setModeloAbertoError('')
1484
  setModeloAbertoActiveTab('mapa')
 
 
1485
  scrollParaResultadosNoTopo()
1486
  }
1487
 
@@ -1490,10 +1621,17 @@ export default function PesquisaTab({
1490
  nextTrabalhosTecnicosModo = modeloAbertoTrabalhosTecnicosModelosModo,
1491
  ) {
1492
  if (!sessionId) return
1493
- const resp = await api.updateVisualizacaoMap(sessionId, nextVar, nextTrabalhosTecnicosModo)
1494
- setModeloAbertoMapaHtml(resp?.mapa_html || '')
1495
- setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
1496
- setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || nextTrabalhosTecnicosModo)
 
 
 
 
 
 
 
1497
  }
1498
 
1499
  async function onModeloAbertoMapChange(nextVar) {
@@ -1528,6 +1666,11 @@ export default function PesquisaTab({
1528
  }
1529
  }
1530
 
 
 
 
 
 
1531
  async function onGerarMapaSelecionados() {
1532
  if (!selectedIds.length) {
1533
  setMapaError('Selecione ao menos um modelo para plotar no mapa.')
@@ -1540,7 +1683,7 @@ export default function PesquisaTab({
1540
  function onMapaModoExibicaoChange(event) {
1541
  const nextModo = String(event?.target?.value || 'pontos')
1542
  setMapaModoExibicao(nextModo)
1543
- if (!mapaFoiGerado || mapaLoading || !selectedIds.length || mapaHtmls[nextModo]) return
1544
  void carregarMapaPesquisa(selectedIds, { modoExibicao: nextModo })
1545
  }
1546
 
@@ -1657,7 +1800,7 @@ export default function PesquisaTab({
1657
  key={tab.key}
1658
  type="button"
1659
  className={modeloAbertoActiveTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
1660
- onClick={() => setModeloAbertoActiveTab(tab.key)}
1661
  >
1662
  {tab.label}
1663
  </button>
@@ -1666,7 +1809,10 @@ export default function PesquisaTab({
1666
 
1667
  <div className="inner-tab-panel">
1668
  {modeloAbertoActiveTab === 'mapa' ? (
1669
- <>
 
 
 
1670
  <div className="row compact visualizacao-mapa-controls pesquisa-mapa-controls-row">
1671
  <label className="pesquisa-field pesquisa-mapa-modo-field">
1672
  Variável no mapa
@@ -1694,27 +1840,39 @@ export default function PesquisaTab({
1694
  </select>
1695
  </label>
1696
  </div>
1697
- <MapFrame html={modeloAbertoMapaHtml} />
1698
  </>
 
1699
  ) : null}
1700
 
1701
  {modeloAbertoActiveTab === 'trabalhos_tecnicos' ? (
1702
- <ModeloTrabalhosTecnicosPanel trabalhos={modeloAbertoTrabalhosTecnicos} />
 
 
1703
  ) : null}
1704
 
1705
- {modeloAbertoActiveTab === 'dados_mercado' ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : null}
1706
- {modeloAbertoActiveTab === 'metricas' ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : null}
 
 
 
 
1707
 
1708
  {modeloAbertoActiveTab === 'transformacoes' ? (
1709
- <>
 
1710
  <div dangerouslySetInnerHTML={{ __html: modeloAbertoEscalasHtml }} />
1711
  <h4 className="visualizacao-table-title">Dados com variáveis transformadas</h4>
1712
  <DataTable table={modeloAbertoDadosTransformados} />
1713
  </>
 
 
 
1714
  ) : null}
1715
 
1716
  {modeloAbertoActiveTab === 'resumo' ? (
1717
- <>
 
1718
  <div className="equation-formats-section">
1719
  <h4>Equações do Modelo</h4>
1720
  <EquationFormatsPanel
@@ -1725,13 +1883,21 @@ export default function PesquisaTab({
1725
  </div>
1726
  <div dangerouslySetInnerHTML={{ __html: modeloAbertoResumoHtml }} />
1727
  </>
 
 
 
1728
  ) : null}
1729
 
1730
- {modeloAbertoActiveTab === 'coeficientes' ? <DataTable table={modeloAbertoCoeficientes} maxHeight={620} /> : null}
1731
- {modeloAbertoActiveTab === 'obs_calc' ? <DataTable table={modeloAbertoObsCalc} maxHeight={620} /> : null}
 
 
 
 
1732
 
1733
  {modeloAbertoActiveTab === 'graficos' ? (
1734
- <>
 
1735
  <div className="plot-grid-2-fixed">
1736
  <PlotFigure figure={modeloAbertoPlotObsCalc} title="Obs x Calc" />
1737
  <PlotFigure figure={modeloAbertoPlotResiduos} title="Resíduos" />
@@ -1742,12 +1908,18 @@ export default function PesquisaTab({
1742
  <PlotFigure figure={modeloAbertoPlotCorr} title="Correlação" className="plot-correlation-card" />
1743
  </div>
1744
  </>
 
 
 
1745
  ) : null}
1746
  </div>
1747
 
1748
  {modeloAbertoError ? <div className="error-line inline-error">{modeloAbertoError}</div> : null}
1749
  </div>
1750
- <LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
 
 
 
1751
  </div>
1752
  )
1753
  }
@@ -2291,7 +2463,9 @@ export default function PesquisaTab({
2291
  </div>
2292
  ) : null}
2293
 
2294
- {mapaHtmlAtual ? <MapFrame html={mapaHtmlAtual} /> : <div className="empty-box">Nenhum mapa gerado ainda.</div>}
 
 
2295
  </SectionBlock>
2296
  </div>
2297
 
 
980
  const [mapaError, setMapaError] = useState('')
981
  const [mapaStatus, setMapaStatus] = useState('')
982
  const [mapaHtmls, setMapaHtmls] = useState({ pontos: '', cobertura: '' })
983
+ const [mapaPayloads, setMapaPayloads] = useState({ pontos: null, cobertura: null })
984
  const [mapaModoExibicao, setMapaModoExibicao] = useState('pontos')
985
  const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState('selecionados_e_outras_versoes')
986
  const [mapaTrabalhosTecnicosProximidadeModo, setMapaTrabalhosTecnicosProximidadeModo] = useState('sem_proximidade')
 
1000
  const [modeloAbertoCoeficientes, setModeloAbertoCoeficientes] = useState(null)
1001
  const [modeloAbertoObsCalc, setModeloAbertoObsCalc] = useState(null)
1002
  const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
1003
+ const [modeloAbertoMapaPayload, setModeloAbertoMapaPayload] = useState(null)
1004
  const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
1005
  const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
1006
  const [modeloAbertoTrabalhosTecnicosModelosModo, setModeloAbertoTrabalhosTecnicosModelosModo] = useState(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
 
1010
  const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
1011
  const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
1012
  const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
1013
+ const [modeloAbertoLoadedTabs, setModeloAbertoLoadedTabs] = useState({})
1014
+ const [modeloAbertoLoadingTabs, setModeloAbertoLoadingTabs] = useState({})
1015
  const sectionResultadosRef = useRef(null)
1016
  const sectionMapaRef = useRef(null)
1017
  const scrollMapaHandledRef = useRef('')
 
1019
  const resultRequestSeqRef = useRef(0)
1020
  const searchRequestSeqRef = useRef(0)
1021
  const contextRequestSeqRef = useRef(0)
1022
+ const modeloAbertoPendingRequestsRef = useRef({})
1023
+ const modeloAbertoOpenVersionRef = useRef(0)
1024
 
1025
  const sugestoes = result.sugestoes || {}
1026
  const opcoesTipoModelo = useMemo(
 
1044
  const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
1045
  const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
1046
  const mapaHtmlAtual = mapaHtmls[mapaModoExibicao] || ''
1047
+ const mapaPayloadAtual = mapaPayloads[mapaModoExibicao] || null
1048
+ const mapaFoiGerado = Boolean(mapaHtmls.pontos || mapaHtmls.cobertura || mapaPayloads.pontos || mapaPayloads.cobertura)
1049
  const pesquisaShareAvaliando = !localizacaoMultipla ? (avaliandosGeoPayload[0] || null) : null
1050
  const pesquisaShareHref = buildPesquisaLink(filters, pesquisaShareAvaliando)
1051
  const pesquisaShareDisabled = localizacaoMultipla
 
1096
 
1097
  function resetMapaPesquisa() {
1098
  setMapaHtmls({ pontos: '', cobertura: '' })
1099
+ setMapaPayloads({ pontos: null, cobertura: null })
1100
  setMapaStatus('')
1101
  setMapaError('')
1102
  setMapaModoExibicao('pontos')
 
1134
 
1135
  if (!idsValidos.length) {
1136
  setMapaHtmls({ pontos: '', cobertura: '' })
1137
+ setMapaPayloads({ pontos: null, cobertura: null })
1138
  setMapaStatus('Nenhum modelo marcado para exibição no mapa.')
1139
  setMapaError('')
1140
  mapaTrabalhosTecnicosConfigRef.current = ''
 
1158
  || (modoExibicaoSolicitado === 'cobertura' ? response.mapa_html_cobertura : response.mapa_html_pontos)
1159
  || '',
1160
  )
1161
+ const mapaPayloadSolicitado = response.mapa_payload
1162
+ || (modoExibicaoSolicitado === 'cobertura' ? response.mapa_payload_cobertura : response.mapa_payload_pontos)
1163
+ || null
1164
  setMapaHtmls({
1165
  pontos: modoExibicaoSolicitado === 'pontos' ? mapaHtmlSolicitado : '',
1166
  cobertura: modoExibicaoSolicitado === 'cobertura' ? mapaHtmlSolicitado : '',
1167
  })
1168
+ setMapaPayloads({
1169
+ pontos: modoExibicaoSolicitado === 'pontos' ? mapaPayloadSolicitado : null,
1170
+ cobertura: modoExibicaoSolicitado === 'cobertura' ? mapaPayloadSolicitado : null,
1171
+ })
1172
  setMapaStatus(response.status || '')
1173
  mapaTrabalhosTecnicosConfigRef.current = buildMapaTrabalhosTecnicosConfigKey(trabalhosTecnicosConfig)
1174
  } catch (err) {
 
1438
  }
1439
  }
1440
 
1441
+ function limparConteudoModeloAberto() {
1442
+ modeloAbertoPendingRequestsRef.current = {}
1443
+ setModeloAbertoDados(null)
1444
+ setModeloAbertoEstatisticas(null)
1445
+ setModeloAbertoEscalasHtml('')
1446
+ setModeloAbertoDadosTransformados(null)
1447
+ setModeloAbertoResumoHtml('')
1448
+ setModeloAbertoEquacoes(null)
1449
+ setModeloAbertoCoeficientes(null)
1450
+ setModeloAbertoObsCalc(null)
1451
+ setModeloAbertoMapaHtml('')
1452
+ setModeloAbertoMapaPayload(null)
1453
+ setModeloAbertoMapaChoices(['Visualização Padrão'])
1454
  setModeloAbertoMapaVar('Visualização Padrão')
1455
+ setModeloAbertoTrabalhosTecnicosModelosModo(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
1456
+ setModeloAbertoTrabalhosTecnicos([])
1457
+ setModeloAbertoPlotObsCalc(null)
1458
+ setModeloAbertoPlotResiduos(null)
1459
+ setModeloAbertoPlotHistograma(null)
1460
+ setModeloAbertoPlotCook(null)
1461
+ setModeloAbertoPlotCorr(null)
1462
+ setModeloAbertoLoadedTabs({})
1463
+ setModeloAbertoLoadingTabs({})
1464
+ }
1465
+
1466
+ function aplicarSecaoModeloAberto(secao, resp) {
1467
+ const key = String(secao || '').trim()
1468
+ if (key === 'dados_mercado') {
1469
+ setModeloAbertoDados(resp?.dados || null)
1470
+ return
1471
+ }
1472
+ if (key === 'metricas') {
1473
+ setModeloAbertoEstatisticas(resp?.estatisticas || null)
1474
+ return
1475
+ }
1476
+ if (key === 'transformacoes') {
1477
+ setModeloAbertoEscalasHtml(resp?.escalas_html || '')
1478
+ setModeloAbertoDadosTransformados(resp?.dados_transformados || null)
1479
+ return
1480
+ }
1481
+ if (key === 'resumo') {
1482
+ setModeloAbertoResumoHtml(resp?.resumo_html || '')
1483
+ setModeloAbertoEquacoes(resp?.equacoes || null)
1484
+ return
1485
+ }
1486
+ if (key === 'coeficientes') {
1487
+ setModeloAbertoCoeficientes(resp?.coeficientes || null)
1488
+ return
1489
+ }
1490
+ if (key === 'obs_calc') {
1491
+ setModeloAbertoObsCalc(resp?.obs_calc || null)
1492
+ return
1493
+ }
1494
+ if (key === 'graficos') {
1495
+ setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
1496
+ setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
1497
+ setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
1498
+ setModeloAbertoPlotCook(resp?.grafico_cook || null)
1499
+ setModeloAbertoPlotCorr(resp?.grafico_correlacao || null)
1500
+ return
1501
+ }
1502
+ if (key === 'trabalhos_tecnicos') {
1503
+ setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
1504
+ setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
1505
+ return
1506
+ }
1507
+ if (key === 'mapa') {
1508
+ const nextChoices = Array.isArray(resp?.mapa_choices) && resp.mapa_choices.length
1509
+ ? resp.mapa_choices
1510
+ : ['Visualização Padrão']
1511
+ setModeloAbertoMapaHtml(resp?.mapa_html || '')
1512
+ setModeloAbertoMapaPayload(resp?.mapa_payload || null)
1513
+ setModeloAbertoMapaChoices(nextChoices)
1514
+ setModeloAbertoMapaVar((current) => (nextChoices.includes(current) ? current : 'Visualização Padrão'))
1515
+ setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
1516
+ setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
1517
+ }
1518
+ }
1519
+
1520
+ async function garantirSecaoModeloAberto(secao, options = {}) {
1521
+ const secaoNormalizada = String(secao || '').trim()
1522
+ if (!sessionId || !secaoNormalizada) return
1523
+ if (!options.force && modeloAbertoLoadedTabs[secaoNormalizada]) return
1524
+ if (modeloAbertoPendingRequestsRef.current[secaoNormalizada]) {
1525
+ await modeloAbertoPendingRequestsRef.current[secaoNormalizada]
1526
+ return
1527
+ }
1528
+
1529
+ const expectedVersion = options.expectedVersion ?? modeloAbertoOpenVersionRef.current
1530
+ const trabalhosTecnicosModo = options.trabalhosTecnicosModelosModo || modeloAbertoTrabalhosTecnicosModelosModo
1531
+ setModeloAbertoLoadingTabs((prev) => ({ ...prev, [secaoNormalizada]: true }))
1532
+
1533
+ const request = (async () => {
1534
+ try {
1535
+ const resp = await api.visualizacaoSection(sessionId, secaoNormalizada, trabalhosTecnicosModo)
1536
+ if (modeloAbertoOpenVersionRef.current !== expectedVersion) return
1537
+ aplicarSecaoModeloAberto(secaoNormalizada, resp)
1538
+ setModeloAbertoLoadedTabs((prev) => ({
1539
+ ...prev,
1540
+ [secaoNormalizada]: true,
1541
+ ...(secaoNormalizada === 'mapa' ? { trabalhos_tecnicos: true } : {}),
1542
+ }))
1543
+ } catch (err) {
1544
+ if (modeloAbertoOpenVersionRef.current !== expectedVersion) return
1545
+ setModeloAbertoError(err.message || 'Falha ao abrir modelo.')
1546
+ } finally {
1547
+ if (modeloAbertoOpenVersionRef.current !== expectedVersion) return
1548
+ setModeloAbertoLoadingTabs((prev) => ({ ...prev, [secaoNormalizada]: false }))
1549
+ }
1550
+ })()
1551
+
1552
+ modeloAbertoPendingRequestsRef.current[secaoNormalizada] = request
1553
+ try {
1554
+ await request
1555
+ } finally {
1556
+ if (modeloAbertoPendingRequestsRef.current[secaoNormalizada] === request) {
1557
+ delete modeloAbertoPendingRequestsRef.current[secaoNormalizada]
1558
+ }
1559
+ }
1560
  }
1561
 
1562
  async function onAbrirModelo(modelo) {
 
1572
  setError('Sessao indisponivel no momento. Aguarde e tente novamente.')
1573
  return
1574
  }
1575
+ modeloAbertoOpenVersionRef.current += 1
1576
+ const openVersion = modeloAbertoOpenVersionRef.current
1577
+ limparConteudoModeloAberto()
1578
  setModeloAbertoLoading(true)
1579
  setModeloAbertoError('')
1580
+ setModeloAbertoMeta({
1581
+ id: modelo.id,
1582
+ nome: modelo.nome_modelo || modelo.arquivo || modelo.id,
1583
+ observacao: '',
1584
+ })
1585
  try {
1586
+ const resp = await api.visualizacaoRepositorioCarregar(sessionId, modelo.id)
1587
+ if (modeloAbertoOpenVersionRef.current !== openVersion) return
 
1588
  setModeloAbertoActiveTab('mapa')
1589
  setModeloAbertoMeta({
1590
  id: modelo.id,
1591
  nome: modelo.nome_modelo || modelo.arquivo || modelo.id,
1592
+ observacao: String(resp?.observacao_modelo || '').trim(),
1593
+ })
1594
+ await garantirSecaoModeloAberto('mapa', {
1595
+ force: true,
1596
+ expectedVersion: openVersion,
1597
+ trabalhosTecnicosModelosModo: MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO,
1598
  })
1599
  window.requestAnimationFrame(() => {
1600
  scrollParaTopoDaPagina()
 
1607
  }
1608
 
1609
  function onVoltarPesquisa() {
1610
+ modeloAbertoOpenVersionRef.current += 1
1611
  setModeloAbertoMeta(null)
1612
  setModeloAbertoError('')
1613
  setModeloAbertoActiveTab('mapa')
1614
+ setModeloAbertoLoading(false)
1615
+ limparConteudoModeloAberto()
1616
  scrollParaResultadosNoTopo()
1617
  }
1618
 
 
1621
  nextTrabalhosTecnicosModo = modeloAbertoTrabalhosTecnicosModelosModo,
1622
  ) {
1623
  if (!sessionId) return
1624
+ setModeloAbertoLoadingTabs((prev) => ({ ...prev, mapa: true }))
1625
+ try {
1626
+ const resp = await api.updateVisualizacaoMap(sessionId, nextVar, nextTrabalhosTecnicosModo)
1627
+ setModeloAbertoMapaHtml(resp?.mapa_html || '')
1628
+ setModeloAbertoMapaPayload(resp?.mapa_payload || null)
1629
+ setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
1630
+ setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || nextTrabalhosTecnicosModo)
1631
+ setModeloAbertoLoadedTabs((prev) => ({ ...prev, mapa: true, trabalhos_tecnicos: true }))
1632
+ } finally {
1633
+ setModeloAbertoLoadingTabs((prev) => ({ ...prev, mapa: false }))
1634
+ }
1635
  }
1636
 
1637
  async function onModeloAbertoMapChange(nextVar) {
 
1666
  }
1667
  }
1668
 
1669
+ function onModeloAbertoTabSelect(nextTab) {
1670
+ setModeloAbertoActiveTab(nextTab)
1671
+ void garantirSecaoModeloAberto(nextTab)
1672
+ }
1673
+
1674
  async function onGerarMapaSelecionados() {
1675
  if (!selectedIds.length) {
1676
  setMapaError('Selecione ao menos um modelo para plotar no mapa.')
 
1683
  function onMapaModoExibicaoChange(event) {
1684
  const nextModo = String(event?.target?.value || 'pontos')
1685
  setMapaModoExibicao(nextModo)
1686
+ if (!mapaFoiGerado || mapaLoading || !selectedIds.length || mapaHtmls[nextModo] || mapaPayloads[nextModo]) return
1687
  void carregarMapaPesquisa(selectedIds, { modoExibicao: nextModo })
1688
  }
1689
 
 
1800
  key={tab.key}
1801
  type="button"
1802
  className={modeloAbertoActiveTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
1803
+ onClick={() => onModeloAbertoTabSelect(tab.key)}
1804
  >
1805
  {tab.label}
1806
  </button>
 
1809
 
1810
  <div className="inner-tab-panel">
1811
  {modeloAbertoActiveTab === 'mapa' ? (
1812
+ !modeloAbertoLoadedTabs.mapa ? (
1813
+ <div className="empty-box">Carregando mapa do modelo...</div>
1814
+ ) : (
1815
+ <>
1816
  <div className="row compact visualizacao-mapa-controls pesquisa-mapa-controls-row">
1817
  <label className="pesquisa-field pesquisa-mapa-modo-field">
1818
  Variável no mapa
 
1840
  </select>
1841
  </label>
1842
  </div>
1843
+ <MapFrame html={modeloAbertoMapaHtml} payload={modeloAbertoMapaPayload} sessionId={sessionId} />
1844
  </>
1845
+ )
1846
  ) : null}
1847
 
1848
  {modeloAbertoActiveTab === 'trabalhos_tecnicos' ? (
1849
+ modeloAbertoLoadedTabs.trabalhos_tecnicos
1850
+ ? <ModeloTrabalhosTecnicosPanel trabalhos={modeloAbertoTrabalhosTecnicos} />
1851
+ : <div className="empty-box">Carregando trabalhos técnicos do modelo...</div>
1852
  ) : null}
1853
 
1854
+ {modeloAbertoActiveTab === 'dados_mercado'
1855
+ ? (modeloAbertoLoadedTabs.dados_mercado ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : <div className="empty-box">Carregando dados de mercado...</div>)
1856
+ : null}
1857
+ {modeloAbertoActiveTab === 'metricas'
1858
+ ? (modeloAbertoLoadedTabs.metricas ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : <div className="empty-box">Carregando métricas do modelo...</div>)
1859
+ : null}
1860
 
1861
  {modeloAbertoActiveTab === 'transformacoes' ? (
1862
+ modeloAbertoLoadedTabs.transformacoes ? (
1863
+ <>
1864
  <div dangerouslySetInnerHTML={{ __html: modeloAbertoEscalasHtml }} />
1865
  <h4 className="visualizacao-table-title">Dados com variáveis transformadas</h4>
1866
  <DataTable table={modeloAbertoDadosTransformados} />
1867
  </>
1868
+ ) : (
1869
+ <div className="empty-box">Carregando transformações do modelo...</div>
1870
+ )
1871
  ) : null}
1872
 
1873
  {modeloAbertoActiveTab === 'resumo' ? (
1874
+ modeloAbertoLoadedTabs.resumo ? (
1875
+ <>
1876
  <div className="equation-formats-section">
1877
  <h4>Equações do Modelo</h4>
1878
  <EquationFormatsPanel
 
1883
  </div>
1884
  <div dangerouslySetInnerHTML={{ __html: modeloAbertoResumoHtml }} />
1885
  </>
1886
+ ) : (
1887
+ <div className="empty-box">Carregando resumo do modelo...</div>
1888
+ )
1889
  ) : null}
1890
 
1891
+ {modeloAbertoActiveTab === 'coeficientes'
1892
+ ? (modeloAbertoLoadedTabs.coeficientes ? <DataTable table={modeloAbertoCoeficientes} maxHeight={620} /> : <div className="empty-box">Carregando coeficientes do modelo...</div>)
1893
+ : null}
1894
+ {modeloAbertoActiveTab === 'obs_calc'
1895
+ ? (modeloAbertoLoadedTabs.obs_calc ? <DataTable table={modeloAbertoObsCalc} maxHeight={620} /> : <div className="empty-box">Carregando observados x calculados...</div>)
1896
+ : null}
1897
 
1898
  {modeloAbertoActiveTab === 'graficos' ? (
1899
+ modeloAbertoLoadedTabs.graficos ? (
1900
+ <>
1901
  <div className="plot-grid-2-fixed">
1902
  <PlotFigure figure={modeloAbertoPlotObsCalc} title="Obs x Calc" />
1903
  <PlotFigure figure={modeloAbertoPlotResiduos} title="Resíduos" />
 
1908
  <PlotFigure figure={modeloAbertoPlotCorr} title="Correlação" className="plot-correlation-card" />
1909
  </div>
1910
  </>
1911
+ ) : (
1912
+ <div className="empty-box">Carregando gráficos do modelo...</div>
1913
+ )
1914
  ) : null}
1915
  </div>
1916
 
1917
  {modeloAbertoError ? <div className="error-line inline-error">{modeloAbertoError}</div> : null}
1918
  </div>
1919
+ <LoadingOverlay
1920
+ show={modeloAbertoLoading || Boolean(modeloAbertoLoadingTabs[modeloAbertoActiveTab])}
1921
+ label={modeloAbertoLoading ? 'Carregando modelo...' : 'Carregando seção do modelo...'}
1922
+ />
1923
  </div>
1924
  )
1925
  }
 
2463
  </div>
2464
  ) : null}
2465
 
2466
+ {(mapaHtmlAtual || mapaPayloadAtual)
2467
+ ? <MapFrame html={mapaHtmlAtual} payload={mapaPayloadAtual} sessionId={sessionId} />
2468
+ : <div className="empty-box">Nenhum mapa gerado ainda.</div>}
2469
  </SectionBlock>
2470
  </div>
2471
 
frontend/src/components/RepositorioTab.jsx CHANGED
@@ -174,6 +174,7 @@ export default function RepositorioTab({
174
  const [modeloAbertoCoeficientes, setModeloAbertoCoeficientes] = useState(null)
175
  const [modeloAbertoObsCalc, setModeloAbertoObsCalc] = useState(null)
176
  const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
 
177
  const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
178
  const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
179
  const [modeloAbertoTrabalhosTecnicosModelosModo, setModeloAbertoTrabalhosTecnicosModelosModo] = useState(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
@@ -349,6 +350,7 @@ export default function RepositorioTab({
349
  setModeloAbertoCoeficientes(null)
350
  setModeloAbertoObsCalc(null)
351
  setModeloAbertoMapaHtml('')
 
352
  setModeloAbertoMapaChoices(['Visualização Padrão'])
353
  setModeloAbertoMapaVar('Visualização Padrão')
354
  setModeloAbertoTrabalhosTecnicosModelosModo(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
@@ -408,6 +410,7 @@ export default function RepositorioTab({
408
  ? resp.mapa_choices
409
  : ['Visualização Padrão']
410
  setModeloAbertoMapaHtml(resp?.mapa_html || '')
 
411
  setModeloAbertoMapaChoices(nextChoices)
412
  setModeloAbertoMapaVar((current) => (nextChoices.includes(current) ? current : 'Visualização Padrão'))
413
  setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
@@ -551,6 +554,7 @@ export default function RepositorioTab({
551
  try {
552
  const resp = await api.updateVisualizacaoMap(sessionId, nextVar, nextTrabalhosTecnicosModo)
553
  setModeloAbertoMapaHtml(resp?.mapa_html || '')
 
554
  setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
555
  setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || nextTrabalhosTecnicosModo)
556
  setModeloAbertoLoadedTabs((prev) => ({ ...prev, mapa: true, trabalhos_tecnicos: true }))
@@ -692,7 +696,7 @@ export default function RepositorioTab({
692
  </select>
693
  </label>
694
  </div>
695
- <MapFrame html={modeloAbertoMapaHtml} />
696
  </>
697
  )
698
  ) : null}
 
174
  const [modeloAbertoCoeficientes, setModeloAbertoCoeficientes] = useState(null)
175
  const [modeloAbertoObsCalc, setModeloAbertoObsCalc] = useState(null)
176
  const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
177
+ const [modeloAbertoMapaPayload, setModeloAbertoMapaPayload] = useState(null)
178
  const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
179
  const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
180
  const [modeloAbertoTrabalhosTecnicosModelosModo, setModeloAbertoTrabalhosTecnicosModelosModo] = useState(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
 
350
  setModeloAbertoCoeficientes(null)
351
  setModeloAbertoObsCalc(null)
352
  setModeloAbertoMapaHtml('')
353
+ setModeloAbertoMapaPayload(null)
354
  setModeloAbertoMapaChoices(['Visualização Padrão'])
355
  setModeloAbertoMapaVar('Visualização Padrão')
356
  setModeloAbertoTrabalhosTecnicosModelosModo(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
 
410
  ? resp.mapa_choices
411
  : ['Visualização Padrão']
412
  setModeloAbertoMapaHtml(resp?.mapa_html || '')
413
+ setModeloAbertoMapaPayload(resp?.mapa_payload || null)
414
  setModeloAbertoMapaChoices(nextChoices)
415
  setModeloAbertoMapaVar((current) => (nextChoices.includes(current) ? current : 'Visualização Padrão'))
416
  setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
 
554
  try {
555
  const resp = await api.updateVisualizacaoMap(sessionId, nextVar, nextTrabalhosTecnicosModo)
556
  setModeloAbertoMapaHtml(resp?.mapa_html || '')
557
+ setModeloAbertoMapaPayload(resp?.mapa_payload || null)
558
  setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
559
  setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || nextTrabalhosTecnicosModo)
560
  setModeloAbertoLoadedTabs((prev) => ({ ...prev, mapa: true, trabalhos_tecnicos: true }))
 
696
  </select>
697
  </label>
698
  </div>
699
+ <MapFrame html={modeloAbertoMapaHtml} payload={modeloAbertoMapaPayload} sessionId={sessionId} />
700
  </>
701
  )
702
  ) : null}
frontend/src/components/TrabalhosTecnicosTab.jsx CHANGED
@@ -167,6 +167,7 @@ export default function TrabalhosTecnicosTab({
167
  const [filtroAno, setFiltroAno] = useState('')
168
  const [mapaModoExibicao, setMapaModoExibicao] = useState('clusterizado')
169
  const [mapaHtml, setMapaHtml] = useState('')
 
170
  const [mapaStatus, setMapaStatus] = useState('')
171
  const [mapaLoading, setMapaLoading] = useState(false)
172
  const [mapaError, setMapaError] = useState('')
@@ -364,6 +365,7 @@ export default function TrabalhosTecnicosTab({
364
 
365
  if (!idsValidos.length) {
366
  setMapaHtml('')
 
367
  setMapaError('')
368
  setMapaStatus('')
369
  mapaRequestKeyRef.current = requestKey
@@ -375,11 +377,13 @@ export default function TrabalhosTecnicosTab({
375
  try {
376
  const resp = await api.trabalhosTecnicosMapa(idsValidos, mapaModoExibicao, avaliandoPayload)
377
  setMapaHtml(resp?.mapa_html || '')
 
378
  setMapaStatus(resp?.status || '')
379
  mapaRequestKeyRef.current = requestKey
380
  } catch (err) {
381
  setMapaError(err.message || 'Falha ao gerar o mapa dos trabalhos técnicos.')
382
  setMapaHtml('')
 
383
  } finally {
384
  setMapaLoading(false)
385
  }
@@ -1402,7 +1406,7 @@ export default function TrabalhosTecnicosTab({
1402
  </div>
1403
  {mapaError ? <div className="error-line inline-error">{mapaError}</div> : null}
1404
  {mapaHtml ? (
1405
- <MapFrame html={mapaHtml} />
1406
  ) : (
1407
  <div className="empty-box">
1408
  {mapaLoading
 
167
  const [filtroAno, setFiltroAno] = useState('')
168
  const [mapaModoExibicao, setMapaModoExibicao] = useState('clusterizado')
169
  const [mapaHtml, setMapaHtml] = useState('')
170
+ const [mapaPayload, setMapaPayload] = useState(null)
171
  const [mapaStatus, setMapaStatus] = useState('')
172
  const [mapaLoading, setMapaLoading] = useState(false)
173
  const [mapaError, setMapaError] = useState('')
 
365
 
366
  if (!idsValidos.length) {
367
  setMapaHtml('')
368
+ setMapaPayload(null)
369
  setMapaError('')
370
  setMapaStatus('')
371
  mapaRequestKeyRef.current = requestKey
 
377
  try {
378
  const resp = await api.trabalhosTecnicosMapa(idsValidos, mapaModoExibicao, avaliandoPayload)
379
  setMapaHtml(resp?.mapa_html || '')
380
+ setMapaPayload(resp?.mapa_payload || null)
381
  setMapaStatus(resp?.status || '')
382
  mapaRequestKeyRef.current = requestKey
383
  } catch (err) {
384
  setMapaError(err.message || 'Falha ao gerar o mapa dos trabalhos técnicos.')
385
  setMapaHtml('')
386
+ setMapaPayload(null)
387
  } finally {
388
  setMapaLoading(false)
389
  }
 
1406
  </div>
1407
  {mapaError ? <div className="error-line inline-error">{mapaError}</div> : null}
1408
  {mapaHtml ? (
1409
+ <MapFrame html={mapaHtml} payload={mapaPayload} />
1410
  ) : (
1411
  <div className="empty-box">
1412
  {mapaLoading
frontend/src/components/VisualizacaoTab.jsx CHANGED
@@ -22,12 +22,46 @@ const INNER_TABS = [
22
  ]
23
  const BASE_COMPARACAO_SEM_BASE = '__none__'
24
  const TRABALHOS_TECNICOS_MODELOS_PADRAO = 'selecionados_e_outras_versoes'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  export default function VisualizacaoTab({ sessionId }) {
27
  const [loading, setLoading] = useState(false)
28
  const [error, setError] = useState('')
29
  const [status, setStatus] = useState('')
30
  const [badgeHtml, setBadgeHtml] = useState('')
 
31
 
32
  const [uploadedFile, setUploadedFile] = useState(null)
33
  const [uploadDragOver, setUploadDragOver] = useState(false)
@@ -53,6 +87,7 @@ export default function VisualizacaoTab({ sessionId }) {
53
  const [plotCorr, setPlotCorr] = useState(null)
54
 
55
  const [mapaHtml, setMapaHtml] = useState('')
 
56
  const [mapaChoices, setMapaChoices] = useState(['Visualização Padrão'])
57
  const [mapaVar, setMapaVar] = useState('Visualização Padrão')
58
  const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState(TRABALHOS_TECNICOS_MODELOS_PADRAO)
@@ -66,8 +101,12 @@ export default function VisualizacaoTab({ sessionId }) {
66
  const [baseValue, setBaseValue] = useState('')
67
 
68
  const [activeInnerTab, setActiveInnerTab] = useState('mapa')
 
 
69
  const deleteConfirmTimersRef = useRef({})
70
  const uploadInputRef = useRef(null)
 
 
71
  const temAvaliacoes = Array.isArray(baseChoices) && baseChoices.length > 0
72
  const repoModeloOptions = useMemo(
73
  () => (repoModelos || []).map((item) => ({
@@ -79,6 +118,8 @@ export default function VisualizacaoTab({ sessionId }) {
79
  )
80
 
81
  function resetConteudoVisualizacao() {
 
 
82
  setDados(null)
83
  setEstatisticas(null)
84
  setEscalasHtml('')
@@ -95,6 +136,7 @@ export default function VisualizacaoTab({ sessionId }) {
95
  setPlotCorr(null)
96
 
97
  setMapaHtml('')
 
98
  setMapaChoices(['Visualização Padrão'])
99
  setMapaVar('Visualização Padrão')
100
  setMapaTrabalhosTecnicosModelosModo(TRABALHOS_TECNICOS_MODELOS_PADRAO)
@@ -108,32 +150,15 @@ export default function VisualizacaoTab({ sessionId }) {
108
  setBaseValue('')
109
 
110
  setActiveInnerTab('mapa')
 
 
111
  }
112
 
113
- function applyExibicaoResponse(resp) {
114
- setDados(resp.dados || null)
115
- setEstatisticas(resp.estatisticas || null)
116
- setEscalasHtml(resp.escalas_html || '')
117
- setDadosTransformados(resp.dados_transformados || null)
118
- setResumoHtml(resp.resumo_html || '')
119
- setEquacoes(resp.equacoes || null)
120
- setCoeficientes(resp.coeficientes || null)
121
- setObsCalc(resp.obs_calc || null)
122
-
123
- setPlotObsCalc(resp.grafico_obs_calc || null)
124
- setPlotResiduos(resp.grafico_residuos || null)
125
- setPlotHistograma(resp.grafico_histograma || null)
126
- setPlotCook(resp.grafico_cook || null)
127
- setPlotCorr(resp.grafico_correlacao || null)
128
-
129
- setMapaHtml(resp.mapa_html || '')
130
- setMapaChoices(resp.mapa_choices || ['Visualização Padrão'])
131
- setMapaVar('Visualização Padrão')
132
- setMapaTrabalhosTecnicosModelosModo(resp.trabalhos_tecnicos_modelos_modo || TRABALHOS_TECNICOS_MODELOS_PADRAO)
133
-
134
- setCamposAvaliacao(resp.campos_avaliacao || [])
135
  const values = {}
136
- ;(resp.campos_avaliacao || []).forEach((campo) => {
137
  values[campo.coluna] = ''
138
  })
139
  valoresAvaliacaoRef.current = values
@@ -142,6 +167,93 @@ export default function VisualizacaoTab({ sessionId }) {
142
  setResultadoAvaliacaoHtml('')
143
  setBaseChoices([])
144
  setBaseValue('')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  }
146
 
147
  async function withBusy(fn) {
@@ -229,12 +341,20 @@ export default function VisualizacaoTab({ sessionId }) {
229
  setModeloLoadSource('upload')
230
  await withBusy(async () => {
231
  resetConteudoVisualizacao()
 
 
232
  const uploadResp = await api.uploadVisualizacaoFile(sessionId, arquivoUpload)
 
233
  setStatus(uploadResp.status || '')
234
  setBadgeHtml(uploadResp.badge_html || '')
235
-
236
- const exibirResp = await api.exibirVisualizacao(sessionId, TRABALHOS_TECNICOS_MODELOS_PADRAO)
237
- applyExibicaoResponse(exibirResp)
 
 
 
 
 
238
  })
239
  }
240
 
@@ -243,12 +363,20 @@ export default function VisualizacaoTab({ sessionId }) {
243
  setModeloLoadSource('repo')
244
  await withBusy(async () => {
245
  resetConteudoVisualizacao()
 
 
246
  const uploadResp = await api.visualizacaoRepositorioCarregar(sessionId, repoModeloSelecionado)
 
247
  setStatus(uploadResp.status || '')
248
  setBadgeHtml(uploadResp.badge_html || '')
249
-
250
- const exibirResp = await api.exibirVisualizacao(sessionId, TRABALHOS_TECNICOS_MODELOS_PADRAO)
251
- applyExibicaoResponse(exibirResp)
 
 
 
 
 
252
  setUploadedFile(null)
253
  })
254
  }
@@ -291,9 +419,16 @@ export default function VisualizacaoTab({ sessionId }) {
291
  trabalhosTecnicosModelosModo = mapaTrabalhosTecnicosModelosModo,
292
  ) {
293
  if (!sessionId) return
294
- const resp = await api.updateVisualizacaoMap(sessionId, variavelMapa, trabalhosTecnicosModelosModo)
295
- setMapaHtml(resp.mapa_html || '')
296
- setMapaTrabalhosTecnicosModelosModo(resp.trabalhos_tecnicos_modelos_modo || trabalhosTecnicosModelosModo)
 
 
 
 
 
 
 
297
  }
298
 
299
  async function onMapChange(value) {
@@ -416,6 +551,13 @@ export default function VisualizacaoTab({ sessionId }) {
416
  })
417
  }
418
 
 
 
 
 
 
 
 
419
  return (
420
  <div className="tab-content">
421
  <SectionBlock step="1" title="Carregar Modelo .dai" subtitle="Carregue o arquivo e o conteúdo será exibido automaticamente.">
@@ -511,7 +653,7 @@ export default function VisualizacaoTab({ sessionId }) {
511
  {badgeHtml ? <div className="upload-badge-block" dangerouslySetInnerHTML={{ __html: badgeHtml }} /> : null}
512
  </SectionBlock>
513
 
514
- {dados ? (
515
  <SectionBlock step="2" title="Conteúdo do Modelo" subtitle="Carregue o modelo no topo e navegue pelas abas internas abaixo.">
516
  <div className="inner-tabs" role="tablist" aria-label="Abas internas de visualização">
517
  {INNER_TABS.map((tab) => (
@@ -519,7 +661,7 @@ export default function VisualizacaoTab({ sessionId }) {
519
  key={tab.key}
520
  type="button"
521
  className={activeInnerTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
522
- onClick={() => setActiveInnerTab(tab.key)}
523
  >
524
  {tab.label}
525
  </button>
@@ -528,7 +670,10 @@ export default function VisualizacaoTab({ sessionId }) {
528
 
529
  <div className="inner-tab-panel">
530
  {activeInnerTab === 'mapa' ? (
531
- <>
 
 
 
532
  <div className="row compact visualizacao-mapa-controls pesquisa-mapa-controls-row">
533
  <label className="pesquisa-field pesquisa-mapa-modo-field">
534
  Variável no mapa
@@ -552,28 +697,34 @@ export default function VisualizacaoTab({ sessionId }) {
552
  </select>
553
  </label>
554
  </div>
555
- <MapFrame html={mapaHtml} />
556
  </>
 
557
  ) : null}
558
 
559
  {activeInnerTab === 'dados_mercado' ? (
560
- <DataTable table={dados} maxHeight={620} />
561
  ) : null}
562
 
563
  {activeInnerTab === 'metricas' ? (
564
- <DataTable table={estatisticas} maxHeight={620} />
565
  ) : null}
566
 
567
  {activeInnerTab === 'transformacoes' ? (
568
- <>
 
569
  <div dangerouslySetInnerHTML={{ __html: escalasHtml }} />
570
  <h4 className="visualizacao-table-title">Dados com variáveis transformadas</h4>
571
  <DataTable table={dadosTransformados} />
572
  </>
 
 
 
573
  ) : null}
574
 
575
  {activeInnerTab === 'resumo' ? (
576
- <>
 
577
  <div className="equation-formats-section">
578
  <h4>Equações do Modelo</h4>
579
  <EquationFormatsPanel
@@ -584,18 +735,22 @@ export default function VisualizacaoTab({ sessionId }) {
584
  </div>
585
  <div dangerouslySetInnerHTML={{ __html: resumoHtml }} />
586
  </>
 
 
 
587
  ) : null}
588
 
589
  {activeInnerTab === 'coeficientes' ? (
590
- <DataTable table={coeficientes} maxHeight={620} />
591
  ) : null}
592
 
593
  {activeInnerTab === 'obs_calc' ? (
594
- <DataTable table={obsCalc} maxHeight={620} />
595
  ) : null}
596
 
597
  {activeInnerTab === 'graficos' ? (
598
- <>
 
599
  <div className="plot-grid-2-fixed">
600
  <PlotFigure figure={plotObsCalc} title="Obs x Calc" />
601
  <PlotFigure figure={plotResiduos} title="Resíduos" />
@@ -606,6 +761,9 @@ export default function VisualizacaoTab({ sessionId }) {
606
  <PlotFigure figure={plotCorr} title="Correlação" className="plot-correlation-card" />
607
  </div>
608
  </>
 
 
 
609
  ) : null}
610
 
611
  {activeInnerTab === 'avaliacao' ? (
@@ -709,7 +867,10 @@ export default function VisualizacaoTab({ sessionId }) {
709
  </SectionBlock>
710
  ) : null}
711
 
712
- <LoadingOverlay show={loading} label="Processando dados..." />
 
 
 
713
  {error ? <div className="error-line">{error}</div> : null}
714
  </div>
715
  )
 
22
  ]
23
  const BASE_COMPARACAO_SEM_BASE = '__none__'
24
  const TRABALHOS_TECNICOS_MODELOS_PADRAO = 'selecionados_e_outras_versoes'
25
+ const SECTION_TABS = new Set([
26
+ 'mapa',
27
+ 'dados_mercado',
28
+ 'metricas',
29
+ 'transformacoes',
30
+ 'resumo',
31
+ 'coeficientes',
32
+ 'obs_calc',
33
+ 'graficos',
34
+ ])
35
+
36
+ function getVisualizacaoLoadingLabel(tabKey) {
37
+ switch (String(tabKey || '').trim()) {
38
+ case 'mapa':
39
+ return 'Carregando mapa do modelo...'
40
+ case 'dados_mercado':
41
+ return 'Carregando dados de mercado...'
42
+ case 'metricas':
43
+ return 'Carregando métricas do modelo...'
44
+ case 'transformacoes':
45
+ return 'Carregando transformações do modelo...'
46
+ case 'resumo':
47
+ return 'Carregando resumo do modelo...'
48
+ case 'coeficientes':
49
+ return 'Carregando coeficientes do modelo...'
50
+ case 'obs_calc':
51
+ return 'Carregando observados x calculados...'
52
+ case 'graficos':
53
+ return 'Carregando gráficos do modelo...'
54
+ default:
55
+ return 'Processando dados...'
56
+ }
57
+ }
58
 
59
  export default function VisualizacaoTab({ sessionId }) {
60
  const [loading, setLoading] = useState(false)
61
  const [error, setError] = useState('')
62
  const [status, setStatus] = useState('')
63
  const [badgeHtml, setBadgeHtml] = useState('')
64
+ const [modeloCarregado, setModeloCarregado] = useState(false)
65
 
66
  const [uploadedFile, setUploadedFile] = useState(null)
67
  const [uploadDragOver, setUploadDragOver] = useState(false)
 
87
  const [plotCorr, setPlotCorr] = useState(null)
88
 
89
  const [mapaHtml, setMapaHtml] = useState('')
90
+ const [mapaPayload, setMapaPayload] = useState(null)
91
  const [mapaChoices, setMapaChoices] = useState(['Visualização Padrão'])
92
  const [mapaVar, setMapaVar] = useState('Visualização Padrão')
93
  const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState(TRABALHOS_TECNICOS_MODELOS_PADRAO)
 
101
  const [baseValue, setBaseValue] = useState('')
102
 
103
  const [activeInnerTab, setActiveInnerTab] = useState('mapa')
104
+ const [loadedTabs, setLoadedTabs] = useState({})
105
+ const [loadingTabs, setLoadingTabs] = useState({})
106
  const deleteConfirmTimersRef = useRef({})
107
  const uploadInputRef = useRef(null)
108
+ const pendingTabRequestsRef = useRef({})
109
+ const modeloLoadVersionRef = useRef(0)
110
  const temAvaliacoes = Array.isArray(baseChoices) && baseChoices.length > 0
111
  const repoModeloOptions = useMemo(
112
  () => (repoModelos || []).map((item) => ({
 
118
  )
119
 
120
  function resetConteudoVisualizacao() {
121
+ pendingTabRequestsRef.current = {}
122
+ setModeloCarregado(false)
123
  setDados(null)
124
  setEstatisticas(null)
125
  setEscalasHtml('')
 
136
  setPlotCorr(null)
137
 
138
  setMapaHtml('')
139
+ setMapaPayload(null)
140
  setMapaChoices(['Visualização Padrão'])
141
  setMapaVar('Visualização Padrão')
142
  setMapaTrabalhosTecnicosModelosModo(TRABALHOS_TECNICOS_MODELOS_PADRAO)
 
150
  setBaseValue('')
151
 
152
  setActiveInnerTab('mapa')
153
+ setLoadedTabs({})
154
+ setLoadingTabs({})
155
  }
156
 
157
+ function applyEvaluationContext(resp) {
158
+ setCamposAvaliacao(resp?.campos_avaliacao || [])
159
+ setEquacoes(resp?.equacoes || null)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  const values = {}
161
+ ;(resp?.campos_avaliacao || []).forEach((campo) => {
162
  values[campo.coluna] = ''
163
  })
164
  valoresAvaliacaoRef.current = values
 
167
  setResultadoAvaliacaoHtml('')
168
  setBaseChoices([])
169
  setBaseValue('')
170
+ setModeloCarregado(true)
171
+ }
172
+
173
+ function applyVisualizacaoSection(secao, resp) {
174
+ const key = String(secao || '').trim()
175
+ if (key === 'dados_mercado') {
176
+ setDados(resp?.dados || null)
177
+ return
178
+ }
179
+ if (key === 'metricas') {
180
+ setEstatisticas(resp?.estatisticas || null)
181
+ return
182
+ }
183
+ if (key === 'transformacoes') {
184
+ setEscalasHtml(resp?.escalas_html || '')
185
+ setDadosTransformados(resp?.dados_transformados || null)
186
+ return
187
+ }
188
+ if (key === 'resumo') {
189
+ setResumoHtml(resp?.resumo_html || '')
190
+ setEquacoes(resp?.equacoes || null)
191
+ return
192
+ }
193
+ if (key === 'coeficientes') {
194
+ setCoeficientes(resp?.coeficientes || null)
195
+ return
196
+ }
197
+ if (key === 'obs_calc') {
198
+ setObsCalc(resp?.obs_calc || null)
199
+ return
200
+ }
201
+ if (key === 'graficos') {
202
+ setPlotObsCalc(resp?.grafico_obs_calc || null)
203
+ setPlotResiduos(resp?.grafico_residuos || null)
204
+ setPlotHistograma(resp?.grafico_histograma || null)
205
+ setPlotCook(resp?.grafico_cook || null)
206
+ setPlotCorr(resp?.grafico_correlacao || null)
207
+ return
208
+ }
209
+ if (key === 'mapa') {
210
+ const nextChoices = Array.isArray(resp?.mapa_choices) && resp.mapa_choices.length
211
+ ? resp.mapa_choices
212
+ : ['Visualização Padrão']
213
+ setMapaHtml(resp?.mapa_html || '')
214
+ setMapaPayload(resp?.mapa_payload || null)
215
+ setMapaChoices(nextChoices)
216
+ setMapaVar((current) => (nextChoices.includes(current) ? current : 'Visualização Padrão'))
217
+ setMapaTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || TRABALHOS_TECNICOS_MODELOS_PADRAO)
218
+ }
219
+ }
220
+
221
+ async function ensureVisualizacaoSection(secao, options = {}) {
222
+ const secaoNormalizada = String(secao || '').trim()
223
+ if (!sessionId || !SECTION_TABS.has(secaoNormalizada)) return
224
+ if (!options.force && loadedTabs[secaoNormalizada]) return
225
+ if (pendingTabRequestsRef.current[secaoNormalizada]) {
226
+ await pendingTabRequestsRef.current[secaoNormalizada]
227
+ return
228
+ }
229
+
230
+ const expectedVersion = options.expectedVersion ?? modeloLoadVersionRef.current
231
+ const trabalhosTecnicosModo = options.trabalhosTecnicosModelosModo || mapaTrabalhosTecnicosModelosModo
232
+ setLoadingTabs((prev) => ({ ...prev, [secaoNormalizada]: true }))
233
+
234
+ const request = (async () => {
235
+ try {
236
+ const resp = await api.visualizacaoSection(sessionId, secaoNormalizada, trabalhosTecnicosModo)
237
+ if (modeloLoadVersionRef.current !== expectedVersion) return
238
+ applyVisualizacaoSection(secaoNormalizada, resp)
239
+ setLoadedTabs((prev) => ({ ...prev, [secaoNormalizada]: true }))
240
+ } catch (err) {
241
+ if (modeloLoadVersionRef.current !== expectedVersion) return
242
+ setError(err.message || 'Falha ao carregar dados do modelo.')
243
+ } finally {
244
+ if (modeloLoadVersionRef.current !== expectedVersion) return
245
+ setLoadingTabs((prev) => ({ ...prev, [secaoNormalizada]: false }))
246
+ }
247
+ })()
248
+
249
+ pendingTabRequestsRef.current[secaoNormalizada] = request
250
+ try {
251
+ await request
252
+ } finally {
253
+ if (pendingTabRequestsRef.current[secaoNormalizada] === request) {
254
+ delete pendingTabRequestsRef.current[secaoNormalizada]
255
+ }
256
+ }
257
  }
258
 
259
  async function withBusy(fn) {
 
341
  setModeloLoadSource('upload')
342
  await withBusy(async () => {
343
  resetConteudoVisualizacao()
344
+ modeloLoadVersionRef.current += 1
345
+ const openVersion = modeloLoadVersionRef.current
346
  const uploadResp = await api.uploadVisualizacaoFile(sessionId, arquivoUpload)
347
+ if (modeloLoadVersionRef.current !== openVersion) return
348
  setStatus(uploadResp.status || '')
349
  setBadgeHtml(uploadResp.badge_html || '')
350
+ const contextoResp = await api.evaluationContextViz(sessionId)
351
+ if (modeloLoadVersionRef.current !== openVersion) return
352
+ applyEvaluationContext(contextoResp)
353
+ await ensureVisualizacaoSection('mapa', {
354
+ force: true,
355
+ expectedVersion: openVersion,
356
+ trabalhosTecnicosModelosModo: TRABALHOS_TECNICOS_MODELOS_PADRAO,
357
+ })
358
  })
359
  }
360
 
 
363
  setModeloLoadSource('repo')
364
  await withBusy(async () => {
365
  resetConteudoVisualizacao()
366
+ modeloLoadVersionRef.current += 1
367
+ const openVersion = modeloLoadVersionRef.current
368
  const uploadResp = await api.visualizacaoRepositorioCarregar(sessionId, repoModeloSelecionado)
369
+ if (modeloLoadVersionRef.current !== openVersion) return
370
  setStatus(uploadResp.status || '')
371
  setBadgeHtml(uploadResp.badge_html || '')
372
+ const contextoResp = await api.evaluationContextViz(sessionId)
373
+ if (modeloLoadVersionRef.current !== openVersion) return
374
+ applyEvaluationContext(contextoResp)
375
+ await ensureVisualizacaoSection('mapa', {
376
+ force: true,
377
+ expectedVersion: openVersion,
378
+ trabalhosTecnicosModelosModo: TRABALHOS_TECNICOS_MODELOS_PADRAO,
379
+ })
380
  setUploadedFile(null)
381
  })
382
  }
 
419
  trabalhosTecnicosModelosModo = mapaTrabalhosTecnicosModelosModo,
420
  ) {
421
  if (!sessionId) return
422
+ setLoadingTabs((prev) => ({ ...prev, mapa: true }))
423
+ try {
424
+ const resp = await api.updateVisualizacaoMap(sessionId, variavelMapa, trabalhosTecnicosModelosModo)
425
+ setMapaHtml(resp?.mapa_html || '')
426
+ setMapaPayload(resp?.mapa_payload || null)
427
+ setMapaTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || trabalhosTecnicosModelosModo)
428
+ setLoadedTabs((prev) => ({ ...prev, mapa: true }))
429
+ } finally {
430
+ setLoadingTabs((prev) => ({ ...prev, mapa: false }))
431
+ }
432
  }
433
 
434
  async function onMapChange(value) {
 
551
  })
552
  }
553
 
554
+ function onInnerTabSelect(nextTab) {
555
+ setActiveInnerTab(nextTab)
556
+ if (SECTION_TABS.has(nextTab)) {
557
+ void ensureVisualizacaoSection(nextTab)
558
+ }
559
+ }
560
+
561
  return (
562
  <div className="tab-content">
563
  <SectionBlock step="1" title="Carregar Modelo .dai" subtitle="Carregue o arquivo e o conteúdo será exibido automaticamente.">
 
653
  {badgeHtml ? <div className="upload-badge-block" dangerouslySetInnerHTML={{ __html: badgeHtml }} /> : null}
654
  </SectionBlock>
655
 
656
+ {modeloCarregado ? (
657
  <SectionBlock step="2" title="Conteúdo do Modelo" subtitle="Carregue o modelo no topo e navegue pelas abas internas abaixo.">
658
  <div className="inner-tabs" role="tablist" aria-label="Abas internas de visualização">
659
  {INNER_TABS.map((tab) => (
 
661
  key={tab.key}
662
  type="button"
663
  className={activeInnerTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
664
+ onClick={() => onInnerTabSelect(tab.key)}
665
  >
666
  {tab.label}
667
  </button>
 
670
 
671
  <div className="inner-tab-panel">
672
  {activeInnerTab === 'mapa' ? (
673
+ !loadedTabs.mapa ? (
674
+ <div className="empty-box">Carregando mapa do modelo...</div>
675
+ ) : (
676
+ <>
677
  <div className="row compact visualizacao-mapa-controls pesquisa-mapa-controls-row">
678
  <label className="pesquisa-field pesquisa-mapa-modo-field">
679
  Variável no mapa
 
697
  </select>
698
  </label>
699
  </div>
700
+ <MapFrame html={mapaHtml} payload={mapaPayload} sessionId={sessionId} />
701
  </>
702
+ )
703
  ) : null}
704
 
705
  {activeInnerTab === 'dados_mercado' ? (
706
+ loadedTabs.dados_mercado ? <DataTable table={dados} maxHeight={620} /> : <div className="empty-box">Carregando dados de mercado...</div>
707
  ) : null}
708
 
709
  {activeInnerTab === 'metricas' ? (
710
+ loadedTabs.metricas ? <DataTable table={estatisticas} maxHeight={620} /> : <div className="empty-box">Carregando métricas do modelo...</div>
711
  ) : null}
712
 
713
  {activeInnerTab === 'transformacoes' ? (
714
+ loadedTabs.transformacoes ? (
715
+ <>
716
  <div dangerouslySetInnerHTML={{ __html: escalasHtml }} />
717
  <h4 className="visualizacao-table-title">Dados com variáveis transformadas</h4>
718
  <DataTable table={dadosTransformados} />
719
  </>
720
+ ) : (
721
+ <div className="empty-box">Carregando transformações do modelo...</div>
722
+ )
723
  ) : null}
724
 
725
  {activeInnerTab === 'resumo' ? (
726
+ loadedTabs.resumo ? (
727
+ <>
728
  <div className="equation-formats-section">
729
  <h4>Equações do Modelo</h4>
730
  <EquationFormatsPanel
 
735
  </div>
736
  <div dangerouslySetInnerHTML={{ __html: resumoHtml }} />
737
  </>
738
+ ) : (
739
+ <div className="empty-box">Carregando resumo do modelo...</div>
740
+ )
741
  ) : null}
742
 
743
  {activeInnerTab === 'coeficientes' ? (
744
+ loadedTabs.coeficientes ? <DataTable table={coeficientes} maxHeight={620} /> : <div className="empty-box">Carregando coeficientes do modelo...</div>
745
  ) : null}
746
 
747
  {activeInnerTab === 'obs_calc' ? (
748
+ loadedTabs.obs_calc ? <DataTable table={obsCalc} maxHeight={620} /> : <div className="empty-box">Carregando observados x calculados...</div>
749
  ) : null}
750
 
751
  {activeInnerTab === 'graficos' ? (
752
+ loadedTabs.graficos ? (
753
+ <>
754
  <div className="plot-grid-2-fixed">
755
  <PlotFigure figure={plotObsCalc} title="Obs x Calc" />
756
  <PlotFigure figure={plotResiduos} title="Resíduos" />
 
761
  <PlotFigure figure={plotCorr} title="Correlação" className="plot-correlation-card" />
762
  </div>
763
  </>
764
+ ) : (
765
+ <div className="empty-box">Carregando gráficos do modelo...</div>
766
+ )
767
  ) : null}
768
 
769
  {activeInnerTab === 'avaliacao' ? (
 
867
  </SectionBlock>
868
  ) : null}
869
 
870
+ <LoadingOverlay
871
+ show={loading || Boolean(loadingTabs[activeInnerTab])}
872
+ label={loading ? 'Processando dados...' : getVisualizacaoLoadingLabel(activeInnerTab)}
873
+ />
874
  {error ? <div className="error-line">{error}</div> : null}
875
  </div>
876
  )
frontend/src/main.jsx CHANGED
@@ -1,5 +1,7 @@
1
  import React from 'react'
2
  import { createRoot } from 'react-dom/client'
 
 
3
  import App from './App'
4
  import './styles.css'
5
 
 
1
  import React from 'react'
2
  import { createRoot } from 'react-dom/client'
3
+ import 'leaflet/dist/leaflet.css'
4
+ import 'leaflet.fullscreen/dist/Control.FullScreen.css'
5
  import App from './App'
6
  import './styles.css'
7
 
frontend/src/styles.css CHANGED
@@ -1464,6 +1464,8 @@ textarea {
1464
  border-radius: 12px;
1465
  background: #fff;
1466
  padding: 14px;
 
 
1467
  }
1468
 
1469
  .trabalhos-mapa-panel {
@@ -3560,8 +3562,8 @@ button.pesquisa-coluna-remove:hover {
3560
  }
3561
 
3562
  .pesquisa-card-values-modal {
3563
- width: fit-content;
3564
- max-width: min(860px, calc(100vw - 40px));
3565
  }
3566
 
3567
  .pesquisa-card-values-modal-actions {
@@ -3572,7 +3574,9 @@ button.pesquisa-coluna-remove:hover {
3572
  }
3573
 
3574
  .pesquisa-card-values-content {
3575
- max-width: min(800px, calc(100vw - 72px));
 
 
3576
  padding: 12px 14px;
3577
  border: 1px solid #d4e0eb;
3578
  border-radius: 12px;
@@ -4929,6 +4933,361 @@ button.btn-upload-select {
4929
  background: #fff;
4930
  }
4931
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4932
  .table-wrapper {
4933
  overflow: auto;
4934
  border: 1px solid #d8e2ec;
 
1464
  border-radius: 12px;
1465
  background: #fff;
1466
  padding: 14px;
1467
+ width: 100%;
1468
+ box-sizing: border-box;
1469
  }
1470
 
1471
  .trabalhos-mapa-panel {
 
3562
  }
3563
 
3564
  .pesquisa-card-values-modal {
3565
+ width: min(920px, calc(100vw - 40px));
3566
+ max-width: min(920px, calc(100vw - 40px));
3567
  }
3568
 
3569
  .pesquisa-card-values-modal-actions {
 
3574
  }
3575
 
3576
  .pesquisa-card-values-content {
3577
+ width: 100%;
3578
+ max-width: 100%;
3579
+ box-sizing: border-box;
3580
  padding: 12px 14px;
3581
  border: 1px solid #d4e0eb;
3582
  border-radius: 12px;
 
4933
  background: #fff;
4934
  }
4935
 
4936
+ .leaflet-map-host {
4937
+ position: relative;
4938
+ overflow: hidden;
4939
+ }
4940
+
4941
+ .leaflet-map-canvas {
4942
+ width: 100%;
4943
+ min-height: 560px;
4944
+ }
4945
+
4946
+ .leaflet-map-host .leaflet-container {
4947
+ width: 100%;
4948
+ min-height: 560px;
4949
+ border-radius: 12px;
4950
+ font-family: 'Sora', sans-serif;
4951
+ }
4952
+
4953
+ .leaflet-map-runtime-error {
4954
+ position: absolute;
4955
+ right: 14px;
4956
+ bottom: 14px;
4957
+ max-width: min(360px, calc(100% - 28px));
4958
+ border-radius: 10px;
4959
+ background: rgba(255, 248, 248, 0.95);
4960
+ border: 1px solid rgba(177, 63, 63, 0.22);
4961
+ color: #8a2d2d;
4962
+ padding: 10px 12px;
4963
+ font-size: 0.82rem;
4964
+ box-shadow: 0 10px 28px rgba(40, 63, 91, 0.12);
4965
+ z-index: 600;
4966
+ }
4967
+
4968
+ .mesa-leaflet-legend {
4969
+ min-width: 168px;
4970
+ border-radius: 12px;
4971
+ padding: 10px 12px;
4972
+ background: rgba(255, 255, 255, 0.94);
4973
+ border: 1px solid rgba(194, 208, 222, 0.9);
4974
+ box-shadow: 0 10px 26px rgba(29, 52, 78, 0.14);
4975
+ color: #24405b;
4976
+ backdrop-filter: blur(8px);
4977
+ }
4978
+
4979
+ .mesa-leaflet-legend strong {
4980
+ display: block;
4981
+ margin-bottom: 8px;
4982
+ font-size: 0.78rem;
4983
+ letter-spacing: 0.03em;
4984
+ text-transform: uppercase;
4985
+ }
4986
+
4987
+ .mesa-leaflet-legend-bar {
4988
+ height: 10px;
4989
+ border-radius: 999px;
4990
+ margin-bottom: 7px;
4991
+ }
4992
+
4993
+ .mesa-leaflet-legend-ticks {
4994
+ position: relative;
4995
+ height: 16px;
4996
+ margin-top: -2px;
4997
+ margin-bottom: 6px;
4998
+ }
4999
+
5000
+ .mesa-leaflet-legend-tick {
5001
+ position: absolute;
5002
+ top: 0;
5003
+ transform: translateX(-50%);
5004
+ font-size: 0.68rem;
5005
+ color: #4a6077;
5006
+ white-space: nowrap;
5007
+ }
5008
+
5009
+ .mesa-leaflet-legend-tick:first-child {
5010
+ transform: translateX(0);
5011
+ }
5012
+
5013
+ .mesa-leaflet-legend-tick:last-child {
5014
+ transform: translateX(-100%);
5015
+ }
5016
+
5017
+ .mesa-leaflet-legend-scale {
5018
+ display: flex;
5019
+ justify-content: space-between;
5020
+ gap: 10px;
5021
+ font-size: 0.76rem;
5022
+ color: #4a6077;
5023
+ }
5024
+
5025
+ .mesa-leaflet-notice {
5026
+ max-width: min(320px, calc(100vw - 48px));
5027
+ border-radius: 10px;
5028
+ padding: 8px 10px;
5029
+ background: rgba(248, 251, 255, 0.94);
5030
+ border: 1px solid rgba(194, 208, 222, 0.92);
5031
+ box-shadow: 0 10px 26px rgba(29, 52, 78, 0.14);
5032
+ color: #35506b;
5033
+ font-size: 0.76rem;
5034
+ line-height: 1.35;
5035
+ }
5036
+
5037
+ .leaflet-map-host .leaflet-control-layers {
5038
+ border: 1px solid rgba(188, 201, 214, 0.92);
5039
+ border-radius: 12px;
5040
+ overflow: hidden;
5041
+ background: rgba(255, 255, 255, 0.96);
5042
+ box-shadow: 0 10px 24px rgba(24, 46, 71, 0.14);
5043
+ backdrop-filter: blur(6px);
5044
+ }
5045
+
5046
+ .leaflet-map-host .leaflet-control-layers-toggle {
5047
+ width: 38px;
5048
+ height: 38px;
5049
+ position: relative;
5050
+ display: block;
5051
+ background-color: rgba(255, 255, 255, 0.95);
5052
+ background-image: none !important;
5053
+ background-size: 0 0;
5054
+ }
5055
+
5056
+ .leaflet-map-host .leaflet-control-layers-toggle::before {
5057
+ content: '';
5058
+ position: absolute;
5059
+ inset: 0;
5060
+ margin: auto;
5061
+ width: 18px;
5062
+ height: 18px;
5063
+ background-repeat: no-repeat;
5064
+ background-position: center;
5065
+ background-size: 18px 18px;
5066
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2324405b' d='M12 2 3 7l9 5 9-5-9-5Zm-7.5 8.6L12 15l7.5-4.4L21 12l-9 5-9-5 1.5-1.4Zm0 4.8L12 19.8l7.5-4.4L21 17l-9 5-9-5 1.5-1.6Z'/%3E%3C/svg%3E");
5067
+ }
5068
+
5069
+ .leaflet-map-host .leaflet-control-layers-toggle:hover {
5070
+ background-color: #eef5fb;
5071
+ }
5072
+
5073
+ .leaflet-map-host .leaflet-control-layers-expanded {
5074
+ padding: 6px 9px;
5075
+ min-width: 220px;
5076
+ max-width: min(260px, calc(100vw - 36px));
5077
+ overflow: visible;
5078
+ }
5079
+
5080
+ .leaflet-map-host .leaflet-control-layers .leaflet-control-layers-list {
5081
+ display: none;
5082
+ }
5083
+
5084
+ .leaflet-map-host .leaflet-control-layers-expanded .leaflet-control-layers-list {
5085
+ display: block;
5086
+ position: relative;
5087
+ }
5088
+
5089
+ .leaflet-map-host .leaflet-control-layers-expanded .leaflet-control-layers-toggle {
5090
+ display: none;
5091
+ }
5092
+
5093
+ .leaflet-map-host .leaflet-control-layers label {
5094
+ display: flex;
5095
+ align-items: center;
5096
+ gap: 6px;
5097
+ margin: 0;
5098
+ padding: 1px 4px;
5099
+ border: none;
5100
+ border-radius: 8px;
5101
+ font-size: 0.74rem;
5102
+ font-weight: 500;
5103
+ color: #27445f;
5104
+ cursor: pointer;
5105
+ transition: background-color 0.18s ease, color 0.18s ease;
5106
+ line-height: 1.12;
5107
+ white-space: normal;
5108
+ }
5109
+
5110
+ .leaflet-map-host .leaflet-control-layers label:hover {
5111
+ background: #f3f7fb;
5112
+ transform: none;
5113
+ color: #1f5f9f;
5114
+ }
5115
+
5116
+ .leaflet-map-host .leaflet-control-layers input {
5117
+ accent-color: #1f6fb2;
5118
+ width: 16px;
5119
+ height: 16px;
5120
+ margin: 0;
5121
+ flex: 0 0 auto;
5122
+ align-self: center;
5123
+ }
5124
+
5125
+ .leaflet-map-host .leaflet-control-layers-selector {
5126
+ margin-top: 0;
5127
+ top: 0;
5128
+ position: static;
5129
+ }
5130
+
5131
+ .leaflet-map-host .leaflet-control-layers label > span {
5132
+ display: flex;
5133
+ align-items: center;
5134
+ gap: 5px;
5135
+ width: 100%;
5136
+ }
5137
+
5138
+ .leaflet-map-host .leaflet-control-layers label > span > span {
5139
+ display: inline-block;
5140
+ flex: 1 1 auto;
5141
+ min-width: 0;
5142
+ padding-top: 0;
5143
+ }
5144
+
5145
+ .leaflet-map-host .leaflet-control-layers-separator {
5146
+ margin: 3px -9px 4px;
5147
+ border-top-color: rgba(207, 217, 227, 0.95);
5148
+ }
5149
+
5150
+ .leaflet-map-host .leaflet-control-fullscreen,
5151
+ .leaflet-map-host .leaflet-control-measure {
5152
+ border: none;
5153
+ box-shadow: 0 10px 24px rgba(27, 48, 72, 0.16);
5154
+ }
5155
+
5156
+ .mesa-sr-only {
5157
+ position: absolute;
5158
+ width: 1px;
5159
+ height: 1px;
5160
+ padding: 0;
5161
+ margin: -1px;
5162
+ overflow: hidden;
5163
+ clip: rect(0, 0, 0, 0);
5164
+ white-space: nowrap;
5165
+ border: 0;
5166
+ }
5167
+
5168
+ .leaflet-map-host .leaflet-bar a,
5169
+ .leaflet-map-host .leaflet-bar button {
5170
+ border-color: rgba(193, 207, 220, 0.95);
5171
+ color: #24405b;
5172
+ }
5173
+
5174
+ .leaflet-map-host .mesa-leaflet-measure {
5175
+ background: transparent;
5176
+ border: none;
5177
+ box-shadow: none;
5178
+ }
5179
+
5180
+ .leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle {
5181
+ width: 38px;
5182
+ height: 38px;
5183
+ position: relative;
5184
+ display: block;
5185
+ background: rgba(255, 255, 255, 0.96);
5186
+ border: 1px solid rgba(193, 207, 220, 0.95);
5187
+ border-radius: 10px;
5188
+ box-shadow: 0 10px 24px rgba(27, 48, 72, 0.16);
5189
+ }
5190
+
5191
+ .leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle::before {
5192
+ content: '';
5193
+ position: absolute;
5194
+ inset: 0;
5195
+ margin: auto;
5196
+ width: 18px;
5197
+ height: 18px;
5198
+ background-repeat: no-repeat;
5199
+ background-position: center;
5200
+ background-size: 18px 18px;
5201
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%2324405b' d='M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25Zm2.92 2.33H5v-.92l8.12-8.12.92.92-8.12 8.12ZM20.71 7.04a1.003 1.003 0 0 0 0-1.42l-2.34-2.33a1.003 1.003 0 0 0-1.42 0l-1.83 1.83 3.75 3.75 1.84-1.83Z'/%3E%3C/svg%3E");
5202
+ }
5203
+
5204
+ .leaflet-map-host .mesa-leaflet-measure .mesa-leaflet-measure-toggle:hover {
5205
+ background: #eef5fb;
5206
+ }
5207
+
5208
+ .leaflet-map-host .mesa-leaflet-measure-interaction {
5209
+ width: min(270px, calc(100vw - 36px));
5210
+ margin-top: 8px;
5211
+ padding: 10px 12px 12px;
5212
+ background: rgba(255, 255, 255, 0.97);
5213
+ border: 1px solid rgba(188, 201, 214, 0.92);
5214
+ border-radius: 12px;
5215
+ box-shadow: 0 12px 28px rgba(24, 46, 71, 0.16);
5216
+ backdrop-filter: blur(6px);
5217
+ }
5218
+
5219
+ .leaflet-map-host .mesa-leaflet-measure-title {
5220
+ margin: 0 0 6px;
5221
+ font-size: 0.84rem;
5222
+ font-weight: 700;
5223
+ color: #24405b;
5224
+ }
5225
+
5226
+ .leaflet-map-host .mesa-leaflet-measure-hint {
5227
+ margin: 0;
5228
+ font-size: 0.74rem;
5229
+ line-height: 1.4;
5230
+ color: #4a6077;
5231
+ }
5232
+
5233
+ .leaflet-map-host .mesa-leaflet-measure-results {
5234
+ margin-top: 10px;
5235
+ display: grid;
5236
+ gap: 8px;
5237
+ }
5238
+
5239
+ .leaflet-map-host .mesa-leaflet-measure-row {
5240
+ display: grid;
5241
+ gap: 2px;
5242
+ padding: 7px 8px;
5243
+ border-radius: 10px;
5244
+ background: #f5f8fc;
5245
+ border: 1px solid #dce5ef;
5246
+ color: #24405b;
5247
+ font-size: 0.74rem;
5248
+ line-height: 1.35;
5249
+ }
5250
+
5251
+ .leaflet-map-host .mesa-leaflet-measure-row strong {
5252
+ font-size: 0.73rem;
5253
+ color: #1f4d7a;
5254
+ }
5255
+
5256
+ .leaflet-map-host .mesa-leaflet-measure-actions {
5257
+ display: flex;
5258
+ flex-wrap: wrap;
5259
+ gap: 6px;
5260
+ margin-top: 10px;
5261
+ }
5262
+
5263
+ .leaflet-map-host .mesa-leaflet-measure-btn {
5264
+ appearance: none;
5265
+ border: 1px solid #c8d6e4;
5266
+ border-radius: 8px;
5267
+ background: #fff;
5268
+ color: #24405b;
5269
+ font: inherit;
5270
+ font-size: 0.73rem;
5271
+ font-weight: 600;
5272
+ line-height: 1;
5273
+ padding: 7px 10px;
5274
+ cursor: pointer;
5275
+ }
5276
+
5277
+ .leaflet-map-host .mesa-leaflet-measure-btn:hover {
5278
+ background: #f3f7fb;
5279
+ }
5280
+
5281
+ .leaflet-map-host .mesa-leaflet-measure-btn.is-primary {
5282
+ background: #1f6fb2;
5283
+ border-color: #1f6fb2;
5284
+ color: #fff;
5285
+ }
5286
+
5287
+ .leaflet-map-host .mesa-leaflet-measure-btn.is-primary:hover {
5288
+ background: #175a90;
5289
+ }
5290
+
5291
  .table-wrapper {
5292
  overflow: auto;
5293
  border: 1px solid #d8e2ec;