Guilherme Silberfarb Costa commited on
Commit
6240ef0
·
1 Parent(s): b9bb1d5

Enhance avaliacao map and KNN interactions

Browse files
backend/app/api/pesquisa.py CHANGED
@@ -85,7 +85,7 @@ def pesquisar_admin_config_salvar(payload: PesquisaAdminConfigPayload) -> dict:
85
 
86
 
87
  @router.get("/logradouros-eixos")
88
- def pesquisar_logradouros_eixos(limite: int = Query(2000, ge=1, le=20000)) -> dict:
89
  return listar_logradouros_eixos(limite=limite)
90
 
91
 
 
85
 
86
 
87
  @router.get("/logradouros-eixos")
88
+ def pesquisar_logradouros_eixos(limite: int = Query(20000, ge=1, le=20000)) -> dict:
89
  return listar_logradouros_eixos(limite=limite)
90
 
91
 
backend/app/api/visualizacao.py CHANGED
@@ -51,6 +51,11 @@ class AvaliacaoKnnDetalhesPayload(SessionPayload):
51
  avaliando_lon: float | None = None
52
 
53
 
 
 
 
 
 
54
  class AvaliacaoDeletePayload(SessionPayload):
55
  indice: str | None = None
56
  indice_base: str | None = None
@@ -209,6 +214,17 @@ def evaluation_knn_details(payload: AvaliacaoKnnDetalhesPayload, request: Reques
209
  return resposta
210
 
211
 
 
 
 
 
 
 
 
 
 
 
 
212
  @router.post("/evaluation/clear")
213
  def evaluation_clear(payload: SessionPayload) -> dict[str, Any]:
214
  session = session_store.get(payload.session_id)
 
51
  avaliando_lon: float | None = None
52
 
53
 
54
+ class AvaliacaoMapaLocalizacaoPayload(SessionPayload):
55
+ avaliando_lat: float | None = None
56
+ avaliando_lon: float | None = None
57
+
58
+
59
  class AvaliacaoDeletePayload(SessionPayload):
60
  indice: str | None = None
61
  indice_base: str | None = None
 
214
  return resposta
215
 
216
 
217
+ @router.post("/evaluation/location-map")
218
+ def evaluation_location_map(payload: AvaliacaoMapaLocalizacaoPayload, request: Request) -> dict[str, Any]:
219
+ session = session_store.get(payload.session_id)
220
+ auth_service.require_user(request)
221
+ return visualizacao_service.mapa_localizacao_avaliacao(
222
+ session,
223
+ payload.avaliando_lat,
224
+ payload.avaliando_lon,
225
+ )
226
+
227
+
228
  @router.post("/evaluation/clear")
229
  def evaluation_clear(payload: SessionPayload) -> dict[str, Any]:
230
  session = session_store.get(payload.session_id)
backend/app/services/pesquisa_service.py CHANGED
@@ -2986,11 +2986,12 @@ def _carregar_catalogo_vias() -> list[dict[str, Any]]:
2986
  prefixo = str(row.get("NMIDEPRE") or "").strip()
2987
  abreviado = str(row.get("NMIDEABR") or "").strip()
2988
  categoria = str(row.get("CDIDECAT") or "").strip()
2989
- logradouro, aliases = _montar_logradouro_catalogo(nome, prefixo, abreviado, categoria)
2990
  catalogo_local.append(
2991
  {
2992
  "cdlog": cdlog,
2993
  "logradouro": logradouro,
 
2994
  "_aliases_norm": [_normalize(item) for item in aliases if _normalize(item)],
2995
  }
2996
  )
@@ -3028,11 +3029,12 @@ def _carregar_catalogo_vias() -> list[dict[str, Any]]:
3028
  prefixo = str(row.get(prefixo_col) or "").strip() if prefixo_col else ""
3029
  abreviado = str(row.get(abreviado_col) or "").strip() if abreviado_col else ""
3030
  categoria = str(row.get(categoria_col) or "").strip() if categoria_col else ""
3031
- logradouro, aliases = _montar_logradouro_catalogo(nome, prefixo, abreviado, categoria)
3032
  catalogo.append(
3033
  {
3034
  "cdlog": cdlog,
3035
  "logradouro": logradouro,
 
3036
  "_aliases_norm": [_normalize(item) for item in aliases if _normalize(item)],
3037
  }
3038
  )
@@ -3068,7 +3070,7 @@ def _montar_logradouro_catalogo(
3068
  prefixo: str,
3069
  abreviado: str,
3070
  categoria: str,
3071
- ) -> tuple[str, list[str]]:
3072
  nome_limpo = str(nome or "").strip()
3073
  prefixo_limpo = str(prefixo or "").strip()
3074
  abreviado_limpo = re.sub(r"\s+", " ", str(abreviado or "").strip())
@@ -3079,17 +3081,22 @@ def _montar_logradouro_catalogo(
3079
 
3080
  partes_base = [item for item in [tipo_abrev, prefixo_limpo, nome_limpo] if item]
3081
  partes_extenso = [item for item in [tipo_expandido, prefixo_limpo, nome_limpo] if item]
3082
- logradouro = " ".join(partes_base).strip() or abreviado_limpo or nome_limpo
 
 
 
3083
 
3084
  aliases = _dedupe_strings(
3085
  [
3086
  logradouro,
 
 
 
3087
  " ".join(partes_extenso).strip(),
3088
  nome_limpo,
3089
- abreviado_limpo,
3090
  ]
3091
  )
3092
- return logradouro, aliases
3093
 
3094
 
3095
  def _to_int_or_none(value: Any) -> int | None:
@@ -3581,18 +3588,25 @@ def _extrair_sugestoes(
3581
 
3582
  def listar_logradouros_eixos(limite: int | None = None) -> dict[str, Any]:
3583
  try:
3584
- logradouros = [
3585
- str(item.get("logradouro") or "").strip()
3586
- for item in _carregar_catalogo_vias()
3587
- ]
 
 
 
 
 
3588
  except HTTPException:
3589
- logradouros = []
3590
 
3591
- itens = _lista_textos_unicos(logradouros, limite)
 
 
3592
  return sanitize_value(
3593
  {
3594
- "logradouros_eixos": itens,
3595
- "total_logradouros": len(itens),
3596
  }
3597
  )
3598
 
 
2986
  prefixo = str(row.get("NMIDEPRE") or "").strip()
2987
  abreviado = str(row.get("NMIDEABR") or "").strip()
2988
  categoria = str(row.get("CDIDECAT") or "").strip()
2989
+ logradouro, aliases, display_label = _montar_logradouro_catalogo(nome, prefixo, abreviado, categoria)
2990
  catalogo_local.append(
2991
  {
2992
  "cdlog": cdlog,
2993
  "logradouro": logradouro,
2994
+ "display_label": display_label,
2995
  "_aliases_norm": [_normalize(item) for item in aliases if _normalize(item)],
2996
  }
2997
  )
 
3029
  prefixo = str(row.get(prefixo_col) or "").strip() if prefixo_col else ""
3030
  abreviado = str(row.get(abreviado_col) or "").strip() if abreviado_col else ""
3031
  categoria = str(row.get(categoria_col) or "").strip() if categoria_col else ""
3032
+ logradouro, aliases, display_label = _montar_logradouro_catalogo(nome, prefixo, abreviado, categoria)
3033
  catalogo.append(
3034
  {
3035
  "cdlog": cdlog,
3036
  "logradouro": logradouro,
3037
+ "display_label": display_label,
3038
  "_aliases_norm": [_normalize(item) for item in aliases if _normalize(item)],
3039
  }
3040
  )
 
3070
  prefixo: str,
3071
  abreviado: str,
3072
  categoria: str,
3073
+ ) -> tuple[str, list[str], str]:
3074
  nome_limpo = str(nome or "").strip()
3075
  prefixo_limpo = str(prefixo or "").strip()
3076
  abreviado_limpo = re.sub(r"\s+", " ", str(abreviado or "").strip())
 
3081
 
3082
  partes_base = [item for item in [tipo_abrev, prefixo_limpo, nome_limpo] if item]
3083
  partes_extenso = [item for item in [tipo_expandido, prefixo_limpo, nome_limpo] if item]
3084
+ logradouro = abreviado_limpo or " ".join(partes_base).strip() or nome_limpo
3085
+ display_label = abreviado_limpo or logradouro
3086
+ if nome_limpo and _normalize(nome_limpo) not in {_normalize(display_label), _normalize(logradouro)}:
3087
+ display_label = f"{display_label} ({nome_limpo})"
3088
 
3089
  aliases = _dedupe_strings(
3090
  [
3091
  logradouro,
3092
+ display_label,
3093
+ abreviado_limpo,
3094
+ " ".join(partes_base).strip(),
3095
  " ".join(partes_extenso).strip(),
3096
  nome_limpo,
 
3097
  ]
3098
  )
3099
+ return logradouro, aliases, display_label
3100
 
3101
 
3102
  def _to_int_or_none(value: Any) -> int | None:
 
3588
 
3589
  def listar_logradouros_eixos(limite: int | None = None) -> dict[str, Any]:
3590
  try:
3591
+ opcoes: list[dict[str, str]] = []
3592
+ vistos: set[str] = set()
3593
+ for item in _carregar_catalogo_vias():
3594
+ value = str(item.get("logradouro") or "").strip()
3595
+ if not value or value in vistos:
3596
+ continue
3597
+ vistos.add(value)
3598
+ label = str(item.get("display_label") or value).strip() or value
3599
+ opcoes.append({"value": value, "label": label})
3600
  except HTTPException:
3601
+ opcoes = []
3602
 
3603
+ opcoes.sort(key=lambda item: (str(item.get("label") or "").casefold(), str(item.get("value") or "").casefold()))
3604
+ if limite is not None and limite > 0:
3605
+ opcoes = opcoes[:limite]
3606
  return sanitize_value(
3607
  {
3608
+ "logradouros_eixos": opcoes,
3609
+ "total_logradouros": len(opcoes),
3610
  }
3611
  )
3612
 
backend/app/services/visualizacao_service.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  from pathlib import Path
4
  from time import sleep
5
  from threading import Lock, Thread
@@ -46,6 +47,26 @@ COORD_LON_NAMES = {"lon", "long", "longitude", "siat_longitude"}
46
  TRABALHOS_TECNICOS_MODELOS_MODO_PADRAO = pesquisa_service.TRABALHOS_TECNICOS_MODELOS_SELECIONADOS_E_OUTRAS_VERSOES
47
  _MAP_SUPPORT_WARMUP_LOCK = Lock()
48
  _MAP_SUPPORT_WARMUP_SIGNATURE: str | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
 
51
  def _to_dataframe(value: Any) -> pd.DataFrame | None:
@@ -212,6 +233,8 @@ def _criar_mapa_knn_destaque(
212
 
213
  if dados.empty:
214
  return "<p>Sem coordenadas validas para exibir o mapa KNN.</p>"
 
 
215
 
216
  aval_lat, aval_lon = _normalizar_coordenadas_avaliando(avaliando_lat, avaliando_lon)
217
  centro_lat = float(dados["__lat__"].median())
@@ -233,11 +256,14 @@ def _criar_mapa_knn_destaque(
233
  camada_knn = folium.FeatureGroup(name="Selecionados KNN", show=True)
234
  camada_avaliando = folium.FeatureGroup(name="Avaliando", show=True) if aval_lat is not None and aval_lon is not None else None
235
 
236
- for _, row in dados.iterrows():
237
  pos = int(row["__pos_base__"])
238
  selecionado = pos in posicoes_set
239
  col_y_val = row.get(coluna_y)
240
  valor_tooltip = _formatar_tooltip_valor(coluna_y, col_y_val)
 
 
 
241
  tooltip_html = (
242
  "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.5;'>"
243
  f"<b>Indice {row['__indice_base__']}</b>"
@@ -246,23 +272,28 @@ def _criar_mapa_knn_destaque(
246
  )
247
 
248
  marcador = folium.CircleMarker(
249
- location=[float(row["__lat__"]), float(row["__lon__"])],
250
- radius=8 if selecionado else 5,
251
  tooltip=folium.Tooltip(tooltip_html, sticky=True),
252
  color="#ffffff",
253
  weight=0.9,
254
  fill=True,
255
  fillColor="#d7263d" if selecionado else "#4f6d8a",
256
- fillOpacity=0.92 if selecionado else 0.52,
257
  )
258
- marcador.options["mesaBaseRadius"] = 8.0 if selecionado else 5.0
259
  (camada_knn if selecionado else camada_mercado).add_child(marcador)
260
 
261
  if camada_avaliando is not None:
262
  marcador_avaliando = folium.Marker(
263
  location=[float(aval_lat), float(aval_lon)],
264
  tooltip="Avaliando",
265
- icon=folium.Icon(color="red", icon="home", prefix="glyphicon"),
 
 
 
 
 
266
  )
267
  camada_avaliando.add_child(marcador_avaliando)
268
 
@@ -328,6 +359,8 @@ def _criar_payload_knn_destaque(
328
  ].copy()
329
  if dados.empty:
330
  return None
 
 
331
 
332
  aval_lat, aval_lon = _normalizar_coordenadas_avaliando(avaliando_lat, avaliando_lon)
333
  posicoes_set = {int(v) for v in (posicoes_knn or [])}
@@ -335,21 +368,22 @@ def _criar_payload_knn_destaque(
335
  points_knn: list[dict[str, Any]] = []
336
  bounds: list[list[float]] = []
337
 
338
- for _, row in dados.iterrows():
339
- lat = float(row["__lat__"])
340
- lon = float(row["__lon__"])
341
  pos = int(row["__pos_base__"])
342
  selecionado = pos in posicoes_set
343
  col_y_val = row.get(coluna_y)
344
  valor_tooltip = _formatar_tooltip_valor(coluna_y, col_y_val)
 
345
  point_payload = {
346
  "lat": lat,
347
  "lon": lon,
348
  "color": "#d7263d" if selecionado else "#4f6d8a",
349
- "base_radius": 8.0 if selecionado else 5.0,
350
  "stroke_color": "#ffffff",
351
  "stroke_weight": 0.9,
352
- "fill_opacity": 0.92 if selecionado else 0.52,
353
  "tooltip_html": (
354
  "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.5;'>"
355
  f"<b>Índice {escape(str(row['__indice_base__']))}</b>"
@@ -376,13 +410,9 @@ def _criar_payload_knn_destaque(
376
  "lat": float(aval_lat),
377
  "lon": float(aval_lon),
378
  "tooltip_html": "Avaliando",
379
- "marker_html": (
380
- "<div style='display:flex;align-items:center;justify-content:center;"
381
- "width:18px;height:18px;border-radius:999px;background:#dc3545;"
382
- "border:2px solid rgba(255,255,255,0.95);box-shadow:0 1px 4px rgba(0,0,0,0.2);'></div>"
383
- ),
384
- "icon_size": [18, 18],
385
- "icon_anchor": [9, 9],
386
  "class_name": "mesa-avaliando-marker",
387
  }
388
  ],
@@ -587,6 +617,166 @@ def _normalizar_coordenadas_avaliando(lat_raw: Any, lon_raw: Any) -> tuple[float
587
  return float(lat), float(lon)
588
 
589
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590
  def _montar_valores_knn(
591
  valores_x: dict[str, float],
592
  avaliando_lat: Any = None,
@@ -1044,6 +1234,30 @@ def exibir_contexto_avaliacao(session: SessionState) -> dict[str, Any]:
1044
  }
1045
 
1046
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1047
  def exibir_modelo(
1048
  session: SessionState,
1049
  trabalhos_tecnicos_modelos_modo: Any = None,
 
1
  from __future__ import annotations
2
 
3
+ from html import escape
4
  from pathlib import Path
5
  from time import sleep
6
  from threading import Lock, Thread
 
47
  TRABALHOS_TECNICOS_MODELOS_MODO_PADRAO = pesquisa_service.TRABALHOS_TECNICOS_MODELOS_SELECIONADOS_E_OUTRAS_VERSOES
48
  _MAP_SUPPORT_WARMUP_LOCK = Lock()
49
  _MAP_SUPPORT_WARMUP_SIGNATURE: str | None = None
50
+ _KNN_DEFAULT_RADIUS = 2.6
51
+ _KNN_RADIUS_MIN = 1.8
52
+ _KNN_RADIUS_MAX = 6.0
53
+ _KNN_FILL_OPACITY = 0.72
54
+ _AVALIACAO_MAPA_MERCADO_RADIUS = 2.45
55
+ _AVALIACAO_MAPA_AVALIANDO_RADIUS = 4.6
56
+ _KNN_AVALIANDO_MARKER_HTML = (
57
+ "<div style='display:flex;align-items:center;justify-content:center;"
58
+ "width:30px;height:30px;filter:drop-shadow(0 2px 6px rgba(0,0,0,0.22));'>"
59
+ "<div style='display:flex;align-items:center;justify-content:center;"
60
+ "width:24px;height:24px;border-radius:999px;background:rgba(255,255,255,0.97);"
61
+ "box-shadow:0 0 0 1.4px rgba(255,255,255,0.98),0 0 0 2.5px rgba(0,0,0,0.74);'>"
62
+ "<svg viewBox='0 0 24 24' width='20' height='20' aria-hidden='true'>"
63
+ "<path d='M12 3.2L3.9 10v10.1h5.4v-5.3h5.4v5.3h5.4V10L12 3.2Z' "
64
+ "fill='#c62828' stroke='rgba(255,255,255,0.96)' stroke-width='1.2' stroke-linejoin='round'/>"
65
+ "<rect x='10.1' y='15.2' width='3.8' height='4.9' rx='0.5' fill='rgba(255,255,255,0.96)'/>"
66
+ "</svg>"
67
+ "</div>"
68
+ "</div>"
69
+ )
70
 
71
 
72
  def _to_dataframe(value: Any) -> pd.DataFrame | None:
 
233
 
234
  if dados.empty:
235
  return "<p>Sem coordenadas validas para exibir o mapa KNN.</p>"
236
+ dados, tamanho_key, tamanho_func = _preparar_escala_raio_knn(dados, coluna_y)
237
+ dados_plot = _aplicar_jitter_pontos_knn(dados)
238
 
239
  aval_lat, aval_lon = _normalizar_coordenadas_avaliando(avaliando_lat, avaliando_lon)
240
  centro_lat = float(dados["__lat__"].median())
 
256
  camada_knn = folium.FeatureGroup(name="Selecionados KNN", show=True)
257
  camada_avaliando = folium.FeatureGroup(name="Avaliando", show=True) if aval_lat is not None and aval_lon is not None else None
258
 
259
+ for _, row in dados_plot.iterrows():
260
  pos = int(row["__pos_base__"])
261
  selecionado = pos in posicoes_set
262
  col_y_val = row.get(coluna_y)
263
  valor_tooltip = _formatar_tooltip_valor(coluna_y, col_y_val)
264
+ lat_plot = row["__lat_plot__"] if "__lat_plot__" in row.index and pd.notna(row["__lat_plot__"]) else row["__lat__"]
265
+ lon_plot = row["__lon_plot__"] if "__lon_plot__" in row.index and pd.notna(row["__lon_plot__"]) else row["__lon__"]
266
+ raio = _resolver_raio_ponto_knn(row, tamanho_key=tamanho_key, tamanho_func=tamanho_func)
267
  tooltip_html = (
268
  "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.5;'>"
269
  f"<b>Indice {row['__indice_base__']}</b>"
 
272
  )
273
 
274
  marcador = folium.CircleMarker(
275
+ location=[float(lat_plot), float(lon_plot)],
276
+ radius=raio,
277
  tooltip=folium.Tooltip(tooltip_html, sticky=True),
278
  color="#ffffff",
279
  weight=0.9,
280
  fill=True,
281
  fillColor="#d7263d" if selecionado else "#4f6d8a",
282
+ fillOpacity=_KNN_FILL_OPACITY,
283
  )
284
+ marcador.options["mesaBaseRadius"] = raio
285
  (camada_knn if selecionado else camada_mercado).add_child(marcador)
286
 
287
  if camada_avaliando is not None:
288
  marcador_avaliando = folium.Marker(
289
  location=[float(aval_lat), float(aval_lon)],
290
  tooltip="Avaliando",
291
+ icon=folium.DivIcon(
292
+ html=_KNN_AVALIANDO_MARKER_HTML,
293
+ icon_size=(30, 30),
294
+ icon_anchor=(15, 15),
295
+ class_name="mesa-avaliando-marker",
296
+ ),
297
  )
298
  camada_avaliando.add_child(marcador_avaliando)
299
 
 
359
  ].copy()
360
  if dados.empty:
361
  return None
362
+ dados, tamanho_key, tamanho_func = _preparar_escala_raio_knn(dados, coluna_y)
363
+ dados_plot = _aplicar_jitter_pontos_knn(dados)
364
 
365
  aval_lat, aval_lon = _normalizar_coordenadas_avaliando(avaliando_lat, avaliando_lon)
366
  posicoes_set = {int(v) for v in (posicoes_knn or [])}
 
368
  points_knn: list[dict[str, Any]] = []
369
  bounds: list[list[float]] = []
370
 
371
+ for _, row in dados_plot.iterrows():
372
+ lat = float(row["__lat_plot__"]) if "__lat_plot__" in row.index and pd.notna(row["__lat_plot__"]) else float(row["__lat__"])
373
+ lon = float(row["__lon_plot__"]) if "__lon_plot__" in row.index and pd.notna(row["__lon_plot__"]) else float(row["__lon__"])
374
  pos = int(row["__pos_base__"])
375
  selecionado = pos in posicoes_set
376
  col_y_val = row.get(coluna_y)
377
  valor_tooltip = _formatar_tooltip_valor(coluna_y, col_y_val)
378
+ raio = _resolver_raio_ponto_knn(row, tamanho_key=tamanho_key, tamanho_func=tamanho_func)
379
  point_payload = {
380
  "lat": lat,
381
  "lon": lon,
382
  "color": "#d7263d" if selecionado else "#4f6d8a",
383
+ "base_radius": raio,
384
  "stroke_color": "#ffffff",
385
  "stroke_weight": 0.9,
386
+ "fill_opacity": _KNN_FILL_OPACITY,
387
  "tooltip_html": (
388
  "<div style='font-family:\"Segoe UI\",Arial,sans-serif; font-size:13px; line-height:1.5;'>"
389
  f"<b>Índice {escape(str(row['__indice_base__']))}</b>"
 
410
  "lat": float(aval_lat),
411
  "lon": float(aval_lon),
412
  "tooltip_html": "Avaliando",
413
+ "marker_html": _KNN_AVALIANDO_MARKER_HTML,
414
+ "icon_size": [30, 30],
415
+ "icon_anchor": [15, 15],
 
 
 
 
416
  "class_name": "mesa-avaliando-marker",
417
  }
418
  ],
 
617
  return float(lat), float(lon)
618
 
619
 
620
+ def _aplicar_jitter_pontos_knn(dados: pd.DataFrame) -> pd.DataFrame:
621
+ if dados is None or dados.empty:
622
+ return dados
623
+ try:
624
+ return viz_app._aplicar_jitter_sobrepostos(
625
+ dados,
626
+ lat_col="__lat__",
627
+ lon_col="__lon__",
628
+ lat_plot_col="__lat_plot__",
629
+ lon_plot_col="__lon_plot__",
630
+ )
631
+ except Exception:
632
+ dados_plot = dados.copy()
633
+ dados_plot["__lat_plot__"] = dados_plot["__lat__"]
634
+ dados_plot["__lon_plot__"] = dados_plot["__lon__"]
635
+ return dados_plot
636
+
637
+
638
+ def _preparar_escala_raio_knn(
639
+ dados: pd.DataFrame,
640
+ coluna_y: str,
641
+ ) -> tuple[pd.DataFrame, str | None, Any]:
642
+ if dados is None or dados.empty:
643
+ return dados, None, None
644
+
645
+ dados_escalados = dados.copy()
646
+ tamanho_key = None
647
+ tamanho_func = None
648
+
649
+ if coluna_y and coluna_y in dados_escalados.columns:
650
+ serie_tamanho = _primeira_serie_por_nome(dados_escalados, coluna_y)
651
+ if serie_tamanho is not None:
652
+ tamanho_key = "__knn_tamanho__"
653
+ dados_escalados[tamanho_key] = pd.to_numeric(serie_tamanho, errors="coerce")
654
+ serie_valida = dados_escalados[tamanho_key].replace([np.inf, -np.inf], np.nan).dropna()
655
+ if not serie_valida.empty:
656
+ t_min = float(serie_valida.min())
657
+ t_max = float(serie_valida.max())
658
+ if t_max > t_min:
659
+ tamanho_func = (
660
+ lambda v, _min=t_min, _max=t_max: _KNN_RADIUS_MIN
661
+ + (float(v) - _min) / (_max - _min) * (_KNN_RADIUS_MAX - _KNN_RADIUS_MIN)
662
+ )
663
+ else:
664
+ tamanho_func = lambda _v: (_KNN_RADIUS_MIN + _KNN_RADIUS_MAX) / 2.0
665
+
666
+ return dados_escalados, tamanho_key, tamanho_func
667
+
668
+
669
+ def _resolver_raio_ponto_knn(
670
+ row: pd.Series,
671
+ *,
672
+ tamanho_key: str | None,
673
+ tamanho_func: Any,
674
+ ) -> float:
675
+ if tamanho_func and tamanho_key and tamanho_key in row.index and pd.notna(row[tamanho_key]):
676
+ try:
677
+ return float(max(1.0, tamanho_func(float(row[tamanho_key]))))
678
+ except Exception:
679
+ pass
680
+ return float(_KNN_DEFAULT_RADIUS)
681
+
682
+
683
+ def _criar_payload_mapa_avaliacao_localizacao(
684
+ df_base: pd.DataFrame,
685
+ avaliando_lat: float | None = None,
686
+ avaliando_lon: float | None = None,
687
+ ) -> dict[str, Any] | None:
688
+ if df_base is None or df_base.empty:
689
+ return None
690
+
691
+ lat_col = _detectar_coluna_coord(df_base, COORD_LAT_NAMES)
692
+ lon_col = _detectar_coluna_coord(df_base, COORD_LON_NAMES)
693
+ if not lat_col or not lon_col:
694
+ return None
695
+
696
+ lat_serie = _primeira_serie_por_nome(df_base, lat_col)
697
+ lon_serie = _primeira_serie_por_nome(df_base, lon_col)
698
+ if lat_serie is None or lon_serie is None:
699
+ return None
700
+
701
+ dados = df_base.copy()
702
+ dados["__lat__"] = pd.to_numeric(lat_serie, errors="coerce")
703
+ dados["__lon__"] = pd.to_numeric(lon_serie, errors="coerce")
704
+ dados = dados[
705
+ np.isfinite(dados["__lat__"])
706
+ & np.isfinite(dados["__lon__"])
707
+ & (np.abs(dados["__lat__"]) <= 90.0)
708
+ & (np.abs(dados["__lon__"]) <= 180.0)
709
+ ].copy()
710
+ if dados.empty:
711
+ return None
712
+
713
+ try:
714
+ dados_plot = viz_app._aplicar_jitter_sobrepostos(
715
+ dados,
716
+ lat_col="__lat__",
717
+ lon_col="__lon__",
718
+ lat_plot_col="__lat_plot__",
719
+ lon_plot_col="__lon_plot__",
720
+ )
721
+ except Exception:
722
+ dados_plot = dados.copy()
723
+ dados_plot["__lat_plot__"] = dados_plot["__lat__"]
724
+ dados_plot["__lon_plot__"] = dados_plot["__lon__"]
725
+
726
+ bounds: list[list[float]] = []
727
+ mercado_points: list[dict[str, Any]] = []
728
+ for _, row in dados_plot.iterrows():
729
+ lat = float(row["__lat_plot__"]) if "__lat_plot__" in row.index and pd.notna(row["__lat_plot__"]) else float(row["__lat__"])
730
+ lon = float(row["__lon_plot__"]) if "__lon_plot__" in row.index and pd.notna(row["__lon_plot__"]) else float(row["__lon__"])
731
+ mercado_points.append(
732
+ {
733
+ "lat": lat,
734
+ "lon": lon,
735
+ "color": "#f28c28",
736
+ "base_radius": _AVALIACAO_MAPA_MERCADO_RADIUS,
737
+ "stroke_color": "#000000",
738
+ "stroke_weight": 0.38,
739
+ "fill_opacity": 0.86,
740
+ "interactive": False,
741
+ }
742
+ )
743
+ bounds.append([lat, lon])
744
+
745
+ overlay_layers: list[dict[str, Any]] = [
746
+ {
747
+ "id": "mercado_avaliacao",
748
+ "label": "Dados de mercado",
749
+ "show": True,
750
+ "points": mercado_points,
751
+ }
752
+ ]
753
+
754
+ aval_lat, aval_lon = _normalizar_coordenadas_avaliando(avaliando_lat, avaliando_lon)
755
+ if aval_lat is not None and aval_lon is not None:
756
+ overlay_layers.append(
757
+ {
758
+ "id": "avaliando",
759
+ "label": "Avaliando",
760
+ "show": True,
761
+ "points": [
762
+ {
763
+ "lat": float(aval_lat),
764
+ "lon": float(aval_lon),
765
+ "color": "#d7263d",
766
+ "base_radius": _AVALIACAO_MAPA_AVALIANDO_RADIUS,
767
+ "stroke_color": "#ffffff",
768
+ "stroke_weight": 1.1,
769
+ "fill_opacity": 0.98,
770
+ "tooltip_html": "Avaliando",
771
+ }
772
+ ],
773
+ }
774
+ )
775
+ bounds.append([float(aval_lat), float(aval_lon)])
776
+
777
+ return build_leaflet_payload(bounds=bounds, overlay_layers=overlay_layers, show_bairros=True)
778
+
779
+
780
  def _montar_valores_knn(
781
  valores_x: dict[str, float],
782
  avaliando_lat: Any = None,
 
1234
  }
1235
 
1236
 
1237
+ def mapa_localizacao_avaliacao(
1238
+ session: SessionState,
1239
+ avaliando_lat: float | None = None,
1240
+ avaliando_lon: float | None = None,
1241
+ ) -> dict[str, Any]:
1242
+ aval_lat, aval_lon = _normalizar_coordenadas_avaliando(avaliando_lat, avaliando_lon)
1243
+ if aval_lat is None or aval_lon is None:
1244
+ raise HTTPException(status_code=400, detail="Informe coordenadas válidas para exibir o avaliando no mapa.")
1245
+
1246
+ core = _obter_visualizacao_core(session)
1247
+ mapa_payload = _criar_payload_mapa_avaliacao_localizacao(
1248
+ core["dados"],
1249
+ avaliando_lat=aval_lat,
1250
+ avaliando_lon=aval_lon,
1251
+ )
1252
+ if mapa_payload is None:
1253
+ raise HTTPException(status_code=400, detail="O modelo carregado não possui coordenadas válidas para montar o mapa.")
1254
+
1255
+ return {
1256
+ "mapa_html": "",
1257
+ "mapa_payload": mapa_payload,
1258
+ }
1259
+
1260
+
1261
  def exibir_modelo(
1262
  session: SessionState,
1263
  trabalhos_tecnicos_modelos_modo: Any = None,
frontend/src/api.js CHANGED
@@ -186,7 +186,7 @@ export const api = {
186
 
187
  pesquisaAdminConfig: () => getJson('/api/pesquisa/admin-config'),
188
  pesquisaAdminConfigSalvar: (campos = {}) => postJson('/api/pesquisa/admin-config', { campos }),
189
- pesquisarLogradourosEixos: (limite = 2000) => getJson(`/api/pesquisa/logradouros-eixos?limite=${encodeURIComponent(String(limite))}`),
190
 
191
  pesquisarModelos(filtros = {}) {
192
  const params = new URLSearchParams()
@@ -394,6 +394,11 @@ export const api = {
394
  avaliando_lat: avaliando?.lat ?? null,
395
  avaliando_lon: avaliando?.lon ?? null,
396
  }),
 
 
 
 
 
397
  evaluationClearViz: (sessionId) => postJson('/api/visualizacao/evaluation/clear', { session_id: sessionId }),
398
  evaluationDeleteViz: (sessionId, indice, indiceBase) => postJson('/api/visualizacao/evaluation/delete', {
399
  session_id: sessionId,
 
186
 
187
  pesquisaAdminConfig: () => getJson('/api/pesquisa/admin-config'),
188
  pesquisaAdminConfigSalvar: (campos = {}) => postJson('/api/pesquisa/admin-config', { campos }),
189
+ pesquisarLogradourosEixos: (limite = 20000) => getJson(`/api/pesquisa/logradouros-eixos?limite=${encodeURIComponent(String(limite))}`),
190
 
191
  pesquisarModelos(filtros = {}) {
192
  const params = new URLSearchParams()
 
394
  avaliando_lat: avaliando?.lat ?? null,
395
  avaliando_lon: avaliando?.lon ?? null,
396
  }),
397
+ evaluationLocationMapViz: (sessionId, avaliando = null) => postJson('/api/visualizacao/evaluation/location-map', {
398
+ session_id: sessionId,
399
+ avaliando_lat: avaliando?.lat ?? null,
400
+ avaliando_lon: avaliando?.lon ?? null,
401
+ }),
402
  evaluationClearViz: (sessionId) => postJson('/api/visualizacao/evaluation/clear', { session_id: sessionId }),
403
  evaluationDeleteViz: (sessionId, indice, indiceBase) => postJson('/api/visualizacao/evaluation/delete', {
404
  session_id: sessionId,
frontend/src/components/AvaliacaoTab.jsx CHANGED
@@ -211,6 +211,40 @@ function obterRotulosKnnAvaliacao(aval) {
211
  return ['Estimativa KNN', '']
212
  }
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  function obterValorKnnPrincipal(aval) {
215
  if (!aval?.knn_disponivel) return null
216
  const tipo = normalizarTipoY(aval?.tipo_y)
@@ -491,10 +525,34 @@ const EMPTY_LOCATION_INPUTS = {
491
  cdlog: '',
492
  }
493
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  function obterCoordenadasResolvidas(localizacao) {
495
- const lat = Number(localizacao?.lat)
496
- const lon = Number(localizacao?.lon)
497
- if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
498
  if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null
499
  return { lat, lon }
500
  }
@@ -543,9 +601,21 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
543
  const [knnDetalheAvaliando, setKnnDetalheAvaliando] = useState([])
544
  const [knnDetalheTabela, setKnnDetalheTabela] = useState(null)
545
  const [knnDetalheInfo, setKnnDetalheInfo] = useState(null)
 
 
 
 
 
 
 
 
 
546
 
547
  const uploadInputRef = useRef(null)
548
  const quickLoadHandledRef = useRef('')
 
 
 
549
 
550
  const repoModeloOptions = useMemo(
551
  () => (repoModelos || []).map((item) => ({
@@ -570,6 +640,83 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
570
  }, {})
571
  }, [knnDetalheTabela])
572
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
573
  function resolverModeloIdRepositorio(chaveBruta, modelosOverride = null) {
574
  const chave = String(chaveBruta || '').trim()
575
  if (!chave) return ''
@@ -809,7 +956,7 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
809
  try {
810
  const response = await api.pesquisarLogradourosEixos()
811
  const opcoes = Array.isArray(response?.logradouros_eixos)
812
- ? response.logradouros_eixos.map((item) => String(item || '').trim()).filter(Boolean)
813
  : []
814
  setAvaliandoLogradouroOptions(opcoes)
815
  setAvaliandoLogradouroLoaded(true)
@@ -965,8 +1112,8 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
965
  })
966
  const resolvida = {
967
  ...response,
968
- lat: Number(response?.lat),
969
- lon: Number(response?.lon),
970
  }
971
  setAvaliandoLocalizacaoResolvida(resolvida)
972
  setAvaliandoLocalizacaoStatus(response?.status || 'Localização do avaliando definida.')
@@ -1117,6 +1264,71 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
1117
  setKnnDetalheErro('')
1118
  }
1119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1120
  function calcularComparacaoBase(avaliacao) {
1121
  if (!baseCard || !baseCard.avaliacao) return '—'
1122
  const baseEstimado = Number(baseCard.avaliacao.estimado)
@@ -1460,8 +1672,20 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
1460
 
1461
  {avaliandoLocalizacaoAtiva ? (
1462
  <div className="pesquisa-localizacao-registered">
1463
- <div className="pesquisa-localizacao-registered-copy">
1464
- <strong>Geolocalização registrada</strong>
 
 
 
 
 
 
 
 
 
 
 
 
1465
  </div>
1466
  <div className="pesquisa-localizacao-summary">
1467
  {avaliandoLocalizacaoResolvida?.logradouro ? (
@@ -1780,6 +2004,88 @@ export default function AvaliacaoTab({ sessionId, quickLoadRequest = null, onRou
1780
  dangerouslySetInnerHTML={{ __html: avaliacaoPopup.html }}
1781
  />
1782
  ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1783
  {knnDetalheAberto ? (
1784
  <div className="pesquisa-modal-backdrop" onClick={(event) => {
1785
  if (event.target === event.currentTarget) onFecharDetalheKnn()
 
211
  return ['Estimativa KNN', '']
212
  }
213
 
214
+ function montarNomeArquivoMapaAvaliando() {
215
+ const agora = new Date()
216
+ const pad = (value) => String(value).padStart(2, '0')
217
+ return [
218
+ 'mapa-avaliando-',
219
+ agora.getFullYear(),
220
+ pad(agora.getMonth() + 1),
221
+ pad(agora.getDate()),
222
+ '_',
223
+ pad(agora.getHours()),
224
+ pad(agora.getMinutes()),
225
+ pad(agora.getSeconds()),
226
+ '.png',
227
+ ].join('')
228
+ }
229
+
230
+ function clampSelectionCoordinate(value, max) {
231
+ return Math.max(0, Math.min(Number.isFinite(value) ? value : 0, max))
232
+ }
233
+
234
+ function buildDownloadSelectionRect(startPoint, endPoint, bounds) {
235
+ const widthMax = Math.max(0, Number(bounds?.width) || 0)
236
+ const heightMax = Math.max(0, Number(bounds?.height) || 0)
237
+ const startX = clampSelectionCoordinate(Number(startPoint?.x), widthMax)
238
+ const startY = clampSelectionCoordinate(Number(startPoint?.y), heightMax)
239
+ const endX = clampSelectionCoordinate(Number(endPoint?.x), widthMax)
240
+ const endY = clampSelectionCoordinate(Number(endPoint?.y), heightMax)
241
+ const left = Math.min(startX, endX)
242
+ const top = Math.min(startY, endY)
243
+ const width = Math.abs(endX - startX)
244
+ const height = Math.abs(endY - startY)
245
+ return { left, top, width, height }
246
+ }
247
+
248
  function obterValorKnnPrincipal(aval) {
249
  if (!aval?.knn_disponivel) return null
250
  const tipo = normalizarTipoY(aval?.tipo_y)
 
525
  cdlog: '',
526
  }
527
 
528
+ function parseCoordinateValue(value) {
529
+ if (value === null || value === undefined) return null
530
+ const text = String(value).trim().replace(',', '.')
531
+ if (!text) return null
532
+ const parsed = Number(text)
533
+ return Number.isFinite(parsed) ? parsed : null
534
+ }
535
+
536
+ function normalizeLogradouroOption(item) {
537
+ if (typeof item === 'string') {
538
+ const text = String(item || '').trim()
539
+ return text ? { value: text, label: text } : null
540
+ }
541
+ if (!item || typeof item !== 'object') return null
542
+ const value = String(item.value ?? item.logradouro ?? '').trim()
543
+ if (!value) return null
544
+ const label = String(item.label ?? item.display_label ?? value).trim() || value
545
+ return {
546
+ value,
547
+ label,
548
+ secondary: String(item.secondary ?? '').trim(),
549
+ }
550
+ }
551
+
552
  function obterCoordenadasResolvidas(localizacao) {
553
+ const lat = parseCoordinateValue(localizacao?.lat)
554
+ const lon = parseCoordinateValue(localizacao?.lon)
555
+ if (lat === null || lon === null) return null
556
  if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null
557
  return { lat, lon }
558
  }
 
601
  const [knnDetalheAvaliando, setKnnDetalheAvaliando] = useState([])
602
  const [knnDetalheTabela, setKnnDetalheTabela] = useState(null)
603
  const [knnDetalheInfo, setKnnDetalheInfo] = useState(null)
604
+ const [avaliandoMapaAberto, setAvaliandoMapaAberto] = useState(false)
605
+ const [avaliandoMapaLoading, setAvaliandoMapaLoading] = useState(false)
606
+ const [avaliandoMapaErro, setAvaliandoMapaErro] = useState('')
607
+ const [avaliandoMapaHtml, setAvaliandoMapaHtml] = useState('')
608
+ const [avaliandoMapaPayload, setAvaliandoMapaPayload] = useState(null)
609
+ const [avaliandoMapaDownloadLoading, setAvaliandoMapaDownloadLoading] = useState(false)
610
+ const [avaliandoMapaDownloadErro, setAvaliandoMapaDownloadErro] = useState('')
611
+ const [avaliandoMapaSelecao, setAvaliandoMapaSelecao] = useState(null)
612
+ const [avaliandoMapaSelecionando, setAvaliandoMapaSelecionando] = useState(false)
613
 
614
  const uploadInputRef = useRef(null)
615
  const quickLoadHandledRef = useRef('')
616
+ const avaliandoMapaDownloadRef = useRef(null)
617
+ const avaliandoMapaSelectionWrapRef = useRef(null)
618
+ const avaliandoMapaSelectionStartRef = useRef(null)
619
 
620
  const repoModeloOptions = useMemo(
621
  () => (repoModelos || []).map((item) => ({
 
640
  }, {})
641
  }, [knnDetalheTabela])
642
 
643
+ useEffect(() => {
644
+ if (!avaliandoMapaAberto || !avaliandoMapaPayload) return undefined
645
+ const timerId = window.setTimeout(() => {
646
+ avaliandoMapaDownloadRef.current?.fitToPayloadBounds?.(112)
647
+ }, 80)
648
+ return () => {
649
+ window.clearTimeout(timerId)
650
+ }
651
+ }, [avaliandoMapaAberto, avaliandoMapaPayload])
652
+
653
+ function obterPontoSelecaoMapa(clientX, clientY) {
654
+ const wrap = avaliandoMapaSelectionWrapRef.current
655
+ if (!wrap) return null
656
+ const rect = wrap.getBoundingClientRect()
657
+ if (!rect.width || !rect.height) return null
658
+ return {
659
+ x: clampSelectionCoordinate(clientX - rect.left, rect.width),
660
+ y: clampSelectionCoordinate(clientY - rect.top, rect.height),
661
+ width: rect.width,
662
+ height: rect.height,
663
+ }
664
+ }
665
+
666
+ function onIniciarSelecaoMapa(event) {
667
+ if (!avaliandoMapaSelecionando) return
668
+ const point = obterPontoSelecaoMapa(event.clientX, event.clientY)
669
+ if (!point) return
670
+ avaliandoMapaSelectionStartRef.current = {
671
+ x: point.x,
672
+ y: point.y,
673
+ pointerId: event.pointerId,
674
+ width: point.width,
675
+ height: point.height,
676
+ }
677
+ setAvaliandoMapaSelecao({ left: point.x, top: point.y, width: 0, height: 0 })
678
+ setAvaliandoMapaDownloadErro('')
679
+ event.preventDefault()
680
+ event.currentTarget.setPointerCapture?.(event.pointerId)
681
+ }
682
+
683
+ function onMoverSelecaoMapa(event) {
684
+ const start = avaliandoMapaSelectionStartRef.current
685
+ if (!start) return
686
+ const point = obterPontoSelecaoMapa(event.clientX, event.clientY)
687
+ if (!point) return
688
+ setAvaliandoMapaSelecao(buildDownloadSelectionRect(start, point, start))
689
+ event.preventDefault()
690
+ }
691
+
692
+ function finalizarSelecaoMapa(event) {
693
+ const start = avaliandoMapaSelectionStartRef.current
694
+ if (!start) return
695
+ const point = obterPontoSelecaoMapa(event.clientX, event.clientY) || start
696
+ const nextSelection = buildDownloadSelectionRect(start, point, start)
697
+ avaliandoMapaSelectionStartRef.current = null
698
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
699
+ event.currentTarget.releasePointerCapture?.(event.pointerId)
700
+ }
701
+ if (nextSelection.width < 24 || nextSelection.height < 24) {
702
+ setAvaliandoMapaSelecao(null)
703
+ setAvaliandoMapaDownloadErro('Selecione um retangulo um pouco maior para gerar o PNG.')
704
+ setAvaliandoMapaSelecionando(false)
705
+ return
706
+ }
707
+ setAvaliandoMapaSelecao(nextSelection)
708
+ setAvaliandoMapaSelecionando(false)
709
+ }
710
+
711
+ function onCancelarSelecaoMapa(event) {
712
+ if (!avaliandoMapaSelectionStartRef.current) return
713
+ avaliandoMapaSelectionStartRef.current = null
714
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
715
+ event.currentTarget.releasePointerCapture?.(event.pointerId)
716
+ }
717
+ setAvaliandoMapaSelecionando(false)
718
+ }
719
+
720
  function resolverModeloIdRepositorio(chaveBruta, modelosOverride = null) {
721
  const chave = String(chaveBruta || '').trim()
722
  if (!chave) return ''
 
956
  try {
957
  const response = await api.pesquisarLogradourosEixos()
958
  const opcoes = Array.isArray(response?.logradouros_eixos)
959
+ ? response.logradouros_eixos.map(normalizeLogradouroOption).filter(Boolean)
960
  : []
961
  setAvaliandoLogradouroOptions(opcoes)
962
  setAvaliandoLogradouroLoaded(true)
 
1112
  })
1113
  const resolvida = {
1114
  ...response,
1115
+ lat: parseCoordinateValue(response?.lat),
1116
+ lon: parseCoordinateValue(response?.lon),
1117
  }
1118
  setAvaliandoLocalizacaoResolvida(resolvida)
1119
  setAvaliandoLocalizacaoStatus(response?.status || 'Localização do avaliando definida.')
 
1264
  setKnnDetalheErro('')
1265
  }
1266
 
1267
+ async function onAbrirMapaAvaliando() {
1268
+ const avaliandoCoords = obterCoordenadasResolvidas(avaliandoLocalizacaoResolvida)
1269
+ if (!sessionId || !avaliandoCoords) return
1270
+ setAvaliandoMapaAberto(true)
1271
+ setAvaliandoMapaLoading(true)
1272
+ setAvaliandoMapaErro('')
1273
+ setAvaliandoMapaDownloadErro('')
1274
+ setAvaliandoMapaSelecao(null)
1275
+ setAvaliandoMapaSelecionando(false)
1276
+ avaliandoMapaSelectionStartRef.current = null
1277
+ setAvaliandoMapaHtml('')
1278
+ setAvaliandoMapaPayload(null)
1279
+ try {
1280
+ const resp = await api.evaluationLocationMapViz(sessionId, avaliandoCoords)
1281
+ setAvaliandoMapaHtml(String(resp?.mapa_html || ''))
1282
+ setAvaliandoMapaPayload(resp?.mapa_payload || null)
1283
+ } catch (err) {
1284
+ setAvaliandoMapaErro(err?.message || 'Falha ao montar o mapa simplificado do avaliando.')
1285
+ } finally {
1286
+ setAvaliandoMapaLoading(false)
1287
+ }
1288
+ }
1289
+
1290
+ function onFecharMapaAvaliando() {
1291
+ setAvaliandoMapaAberto(false)
1292
+ setAvaliandoMapaLoading(false)
1293
+ setAvaliandoMapaErro('')
1294
+ setAvaliandoMapaDownloadLoading(false)
1295
+ setAvaliandoMapaDownloadErro('')
1296
+ setAvaliandoMapaSelecao(null)
1297
+ setAvaliandoMapaSelecionando(false)
1298
+ avaliandoMapaSelectionStartRef.current = null
1299
+ }
1300
+
1301
+ function onAtivarSelecaoMapaAvaliando() {
1302
+ setAvaliandoMapaDownloadErro('')
1303
+ setAvaliandoMapaSelecao(null)
1304
+ setAvaliandoMapaSelecionando(true)
1305
+ avaliandoMapaSelectionStartRef.current = null
1306
+ }
1307
+
1308
+ function onLimparSelecaoMapaAvaliando() {
1309
+ setAvaliandoMapaSelecao(null)
1310
+ setAvaliandoMapaSelecionando(false)
1311
+ setAvaliandoMapaDownloadErro('')
1312
+ avaliandoMapaSelectionStartRef.current = null
1313
+ }
1314
+
1315
+ async function onBaixarMapaAvaliandoPng() {
1316
+ if (!avaliandoMapaSelecao) {
1317
+ setAvaliandoMapaDownloadErro('Selecione com um retangulo a area que deseja baixar.')
1318
+ return
1319
+ }
1320
+ if (!avaliandoMapaDownloadRef.current?.downloadSelectionPng) return
1321
+ setAvaliandoMapaDownloadLoading(true)
1322
+ setAvaliandoMapaDownloadErro('')
1323
+ try {
1324
+ await avaliandoMapaDownloadRef.current.downloadSelectionPng(avaliandoMapaSelecao, montarNomeArquivoMapaAvaliando())
1325
+ } catch (err) {
1326
+ setAvaliandoMapaDownloadErro(err?.message || 'Falha ao exportar o mapa em PNG.')
1327
+ } finally {
1328
+ setAvaliandoMapaDownloadLoading(false)
1329
+ }
1330
+ }
1331
+
1332
  function calcularComparacaoBase(avaliacao) {
1333
  if (!baseCard || !baseCard.avaliacao) return '—'
1334
  const baseEstimado = Number(baseCard.avaliacao.estimado)
 
1672
 
1673
  {avaliandoLocalizacaoAtiva ? (
1674
  <div className="pesquisa-localizacao-registered">
1675
+ <div className="pesquisa-localizacao-registered-head">
1676
+ <div className="pesquisa-localizacao-registered-copy">
1677
+ <strong>Geolocalização registrada</strong>
1678
+ </div>
1679
+ <div className="pesquisa-localizacao-registered-actions">
1680
+ <button
1681
+ type="button"
1682
+ className="pesquisa-localizacao-action"
1683
+ onClick={() => void onAbrirMapaAvaliando()}
1684
+ disabled={avaliandoMapaLoading}
1685
+ >
1686
+ {avaliandoMapaLoading ? 'Carregando mapa...' : 'Ver no mapa'}
1687
+ </button>
1688
+ </div>
1689
  </div>
1690
  <div className="pesquisa-localizacao-summary">
1691
  {avaliandoLocalizacaoResolvida?.logradouro ? (
 
2004
  dangerouslySetInnerHTML={{ __html: avaliacaoPopup.html }}
2005
  />
2006
  ) : null}
2007
+ {avaliandoMapaAberto ? (
2008
+ <div className="pesquisa-modal-backdrop" onClick={(event) => {
2009
+ if (event.target === event.currentTarget) onFecharMapaAvaliando()
2010
+ }}
2011
+ >
2012
+ <div className="pesquisa-modal avaliacao-localizacao-download-modal">
2013
+ <div className="pesquisa-modal-head">
2014
+ <div>
2015
+ <h4>Ajustar recorte para PNG</h4>
2016
+ <p>Ajuste zoom e posição, depois desenhe um retângulo sobre a área que deseja baixar.</p>
2017
+ </div>
2018
+ <div className="avaliacao-localizacao-map-modal-actions">
2019
+ <button
2020
+ type="button"
2021
+ className={`pesquisa-localizacao-action avaliacao-download-toggle-btn${avaliandoMapaSelecao || avaliandoMapaSelecionando ? ' is-clear' : ' is-select'}`}
2022
+ onClick={avaliandoMapaSelecao || avaliandoMapaSelecionando ? onLimparSelecaoMapaAvaliando : onAtivarSelecaoMapaAvaliando}
2023
+ disabled={!avaliandoMapaPayload || avaliandoMapaDownloadLoading}
2024
+ >
2025
+ {avaliandoMapaSelecao || avaliandoMapaSelecionando ? 'Limpar recorte' : 'Selecionar retângulo'}
2026
+ </button>
2027
+ <button
2028
+ type="button"
2029
+ className="pesquisa-localizacao-action"
2030
+ onClick={() => void onBaixarMapaAvaliandoPng()}
2031
+ disabled={!avaliandoMapaPayload || !avaliandoMapaSelecao || avaliandoMapaDownloadLoading}
2032
+ >
2033
+ {avaliandoMapaDownloadLoading ? 'Baixando PNG...' : 'Baixar PNG'}
2034
+ </button>
2035
+ <button type="button" className="pesquisa-modal-close" onClick={onFecharMapaAvaliando}>
2036
+ Fechar
2037
+ </button>
2038
+ </div>
2039
+ </div>
2040
+ <div className="pesquisa-modal-body">
2041
+ {avaliandoMapaLoading ? (
2042
+ <div className="empty-box">Carregando mapa do modelo...</div>
2043
+ ) : null}
2044
+ {avaliandoMapaErro ? (
2045
+ <div className="error-line">{avaliandoMapaErro}</div>
2046
+ ) : null}
2047
+ {!avaliandoMapaLoading && !avaliandoMapaErro ? (
2048
+ <>
2049
+ <div className="avaliacao-knn-legenda">
2050
+ <span><b>Qualidade:</b> PNG de alta resolução só da área selecionada</span>
2051
+ <span><b>Como usar:</b> clique em selecionar retângulo e arraste no mapa</span>
2052
+ </div>
2053
+ <div className="avaliacao-localizacao-download-status">
2054
+ {avaliandoMapaSelecao
2055
+ ? `Recorte selecionado: ${Math.round(avaliandoMapaSelecao.width)} × ${Math.round(avaliandoMapaSelecao.height)} px`
2056
+ : (avaliandoMapaSelecionando ? 'Desenhe o retângulo sobre o mapa.' : 'Nenhum recorte selecionado ainda.')}
2057
+ </div>
2058
+ {avaliandoMapaDownloadErro ? (
2059
+ <div className="error-line">{avaliandoMapaDownloadErro}</div>
2060
+ ) : null}
2061
+ <div ref={avaliandoMapaSelectionWrapRef} className={`avaliacao-localizacao-download-map-wrap${avaliandoMapaSelecionando ? ' is-selecting' : ''}`}>
2062
+ <MapFrame ref={avaliandoMapaDownloadRef} html={avaliandoMapaHtml} payload={avaliandoMapaPayload} sessionId={sessionId} />
2063
+ <div
2064
+ className={`avaliacao-download-selection-layer${avaliandoMapaSelecionando ? ' is-active' : ''}`}
2065
+ onPointerDown={onIniciarSelecaoMapa}
2066
+ onPointerMove={onMoverSelecaoMapa}
2067
+ onPointerUp={finalizarSelecaoMapa}
2068
+ onPointerCancel={onCancelarSelecaoMapa}
2069
+ >
2070
+ {avaliandoMapaSelecao ? (
2071
+ <div
2072
+ className="avaliacao-download-selection-rect"
2073
+ style={{
2074
+ left: `${avaliandoMapaSelecao.left}px`,
2075
+ top: `${avaliandoMapaSelecao.top}px`,
2076
+ width: `${avaliandoMapaSelecao.width}px`,
2077
+ height: `${avaliandoMapaSelecao.height}px`,
2078
+ }}
2079
+ />
2080
+ ) : null}
2081
+ </div>
2082
+ </div>
2083
+ </>
2084
+ ) : null}
2085
+ </div>
2086
+ </div>
2087
+ </div>
2088
+ ) : null}
2089
  {knnDetalheAberto ? (
2090
  <div className="pesquisa-modal-backdrop" onClick={(event) => {
2091
  if (event.target === event.currentTarget) onFecharDetalheKnn()
frontend/src/components/LeafletMapFrame.jsx CHANGED
@@ -1,8 +1,8 @@
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
@@ -37,6 +37,23 @@ function buildPopupErrorHtml(message) {
37
  )
38
  }
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  function ensurePopupPager(root) {
41
  if (!root || root.dataset.mesaPagerBound === '1') return
42
  root.dataset.mesaPagerBound = '1'
@@ -363,11 +380,638 @@ async function readJsonSafely(response) {
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
@@ -378,6 +1022,7 @@ export default function LeafletMapFrame({ payload, sessionId }) {
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')
@@ -399,6 +1044,8 @@ export default function LeafletMapFrame({ payload, sessionId }) {
399
  attribution: index === 0
400
  ? '&copy; OpenStreetMap contributors'
401
  : '&copy; OpenStreetMap contributors &copy; CARTO',
 
 
402
  })
403
  const label = String(layerDef?.label || layerDef?.id || `Base ${index + 1}`)
404
  baseLayers[label] = tileLayer
@@ -523,7 +1170,9 @@ export default function LeafletMapFrame({ payload, sessionId }) {
523
  if (!Array.isArray(items) || !items.length) return
524
  responsivePointContainers.push(layerGroup)
525
  items.forEach((item) => {
526
- const marker = L.circleMarker([Number(item.lat), Number(item.lon)], {
 
 
527
  renderer: canvasRenderer,
528
  pane: String(item?.pane || 'mesa-market-pane'),
529
  radius: Number(item?.base_radius) || 4,
@@ -552,13 +1201,15 @@ export default function LeafletMapFrame({ payload, sessionId }) {
552
  function addMarkerOverlays(layerGroup, items) {
553
  if (!Array.isArray(items) || !items.length) return
554
  items.forEach((item) => {
 
 
555
  const icon = L.divIcon({
556
  html: String(item?.marker_html || ''),
557
  iconSize: Array.isArray(item?.icon_size) ? item.icon_size : [14, 14],
558
  iconAnchor: Array.isArray(item?.icon_anchor) ? item.icon_anchor : [7, 7],
559
  className: String(item?.class_name || 'mesa-map-marker'),
560
  })
561
- const marker = L.marker([Number(item.lat), Number(item.lon)], {
562
  icon,
563
  pane: String(item?.pane || (String(item?.class_name || '').includes('indice') ? 'mesa-indices-pane' : 'mesa-trabalhos-pane')),
564
  bubblingMouseEvents: item?.bubbling_mouse_events === true,
@@ -635,13 +1286,19 @@ export default function LeafletMapFrame({ payload, sessionId }) {
635
  let layer = null
636
 
637
  if (shapeType === 'circle' && Array.isArray(shape?.center) && shape.center.length === 2) {
638
- layer = L.circle(shape.center, { ...style, radius: Number(shape?.radius_m) || 0 })
 
 
 
639
  } else if (shapeType === 'polyline' && Array.isArray(shape?.coords) && shape.coords.length) {
640
  layer = L.polyline(shape.coords, style)
641
  } else if (shapeType === 'polygon' && Array.isArray(shape?.coords) && shape.coords.length) {
642
  layer = L.polygon(shape.coords, style)
643
  } else if (shapeType === 'circlemarker' && Array.isArray(shape?.center) && shape.center.length === 2) {
644
- layer = L.circleMarker(shape.center, { ...style, radius: Number(shape?.radius) || 6 })
 
 
 
645
  }
646
 
647
  if (!layer) return
@@ -701,12 +1358,11 @@ export default function LeafletMapFrame({ payload, sessionId }) {
701
  const points = Array.isArray(heatmapSpec?.points)
702
  ? heatmapSpec.points
703
  .map((item) => {
704
- const lat = Number(item?.lat)
705
- const lon = Number(item?.lon)
706
  const weight = Number(item?.weight)
707
- if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
708
- if (Number.isFinite(weight)) return [lat, lon, weight]
709
- return [lat, lon]
710
  })
711
  .filter(Boolean)
712
  : []
@@ -1038,12 +1694,7 @@ export default function LeafletMapFrame({ payload, sessionId }) {
1038
  map.on('zoomend overlayadd overlayremove', applyResponsiveRadius)
1039
  applyResponsiveRadius()
1040
 
1041
- const bounds = Array.isArray(payload.bounds) ? payload.bounds : null
1042
- if (bounds && bounds.length === 2) {
1043
- map.fitBounds(bounds, { padding: [48, 48], maxZoom: 18, animate: false })
1044
- } else if (Array.isArray(payload.center) && payload.center.length === 2) {
1045
- map.setView(payload.center, 12)
1046
- } else {
1047
  setRuntimeError('Falha ao montar mapa interativo.')
1048
  }
1049
 
@@ -1054,6 +1705,9 @@ export default function LeafletMapFrame({ payload, sessionId }) {
1054
  }
1055
  disposed = true
1056
  restoreMapInteractions?.()
 
 
 
1057
  map.remove()
1058
  }
1059
  }, [payload, sessionId])
@@ -1064,4 +1718,6 @@ export default function LeafletMapFrame({ payload, sessionId }) {
1064
  {runtimeError ? <div className="leaflet-map-runtime-error">{runtimeError}</div> : null}
1065
  </div>
1066
  )
1067
- }
 
 
 
1
+ import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
2
  import L from 'leaflet'
3
  import 'leaflet.fullscreen'
4
  import 'leaflet.heat'
5
+ import { apiUrl, downloadBlob, getAuthToken } from '../api'
6
 
7
  let bairrosGeojsonCache = null
8
  let bairrosGeojsonPromise = null
 
37
  )
38
  }
39
 
40
+ function parseFiniteCoordinate(value, min, max) {
41
+ if (value === null || value === undefined) return null
42
+ const text = String(value).trim().replace(',', '.')
43
+ if (!text) return null
44
+ const parsed = Number(text)
45
+ if (!Number.isFinite(parsed)) return null
46
+ if (parsed < min || parsed > max) return null
47
+ return parsed
48
+ }
49
+
50
+ function parseLatLonPair(rawLat, rawLon) {
51
+ const lat = parseFiniteCoordinate(rawLat, -90, 90)
52
+ const lon = parseFiniteCoordinate(rawLon, -180, 180)
53
+ if (lat === null || lon === null) return null
54
+ return [lat, lon]
55
+ }
56
+
57
  function ensurePopupPager(root) {
58
  if (!root || root.dataset.mesaPagerBound === '1') return
59
  root.dataset.mesaPagerBound = '1'
 
380
  }
381
  }
382
 
383
+ function waitForNextPaint() {
384
+ return new Promise((resolve) => {
385
+ window.requestAnimationFrame(() => {
386
+ window.requestAnimationFrame(resolve)
387
+ })
388
+ })
389
+ }
390
+
391
+ function isDrawableVisible(rect, hostRect) {
392
+ if (!rect || !hostRect) return false
393
+ if (rect.width <= 0 || rect.height <= 0) return false
394
+ return (
395
+ rect.right > hostRect.left
396
+ && rect.left < hostRect.right
397
+ && rect.bottom > hostRect.top
398
+ && rect.top < hostRect.bottom
399
+ )
400
+ }
401
+
402
+ function parseBorderRadiusPx(element) {
403
+ if (!element) return 0
404
+ const computed = window.getComputedStyle(element)
405
+ const raw = String(computed.borderTopLeftRadius || computed.borderRadius || '0')
406
+ const parsed = Number.parseFloat(raw)
407
+ return Number.isFinite(parsed) ? Math.max(0, parsed) : 0
408
+ }
409
+
410
+ function clipRoundedRect(ctx, width, height, radius) {
411
+ const corner = Math.max(0, Math.min(radius, Math.min(width, height) / 2))
412
+ if (!corner) {
413
+ ctx.beginPath()
414
+ ctx.rect(0, 0, width, height)
415
+ ctx.clip()
416
+ return
417
+ }
418
+ ctx.beginPath()
419
+ ctx.moveTo(corner, 0)
420
+ ctx.lineTo(width - corner, 0)
421
+ ctx.quadraticCurveTo(width, 0, width, corner)
422
+ ctx.lineTo(width, height - corner)
423
+ ctx.quadraticCurveTo(width, height, width - corner, height)
424
+ ctx.lineTo(corner, height)
425
+ ctx.quadraticCurveTo(0, height, 0, height - corner)
426
+ ctx.lineTo(0, corner)
427
+ ctx.quadraticCurveTo(0, 0, corner, 0)
428
+ ctx.closePath()
429
+ ctx.clip()
430
+ }
431
+
432
+ function collectVisibleMapDrawables(container, hostRect) {
433
+ const drawables = []
434
+ let sequence = 0
435
+ const register = (element, kind) => {
436
+ if (!element) return
437
+ const rect = element.getBoundingClientRect()
438
+ if (!isDrawableVisible(rect, hostRect)) return
439
+ const pane = element.closest('.leaflet-pane')
440
+ const paneZ = Number.parseInt(String(window.getComputedStyle(pane || element).zIndex || '0'), 10)
441
+ drawables.push({
442
+ element,
443
+ kind,
444
+ rect,
445
+ paneZ: Number.isFinite(paneZ) ? paneZ : 0,
446
+ sequence,
447
+ })
448
+ sequence += 1
449
+ }
450
+
451
+ Array.from(container.querySelectorAll('.leaflet-tile-pane img.leaflet-tile')).forEach((element) => register(element, 'image'))
452
+ Array.from(container.querySelectorAll('.leaflet-pane canvas')).forEach((element) => register(element, 'canvas'))
453
+ Array.from(container.querySelectorAll('.leaflet-pane svg')).forEach((element) => register(element, 'svg'))
454
+
455
+ drawables.sort((left, right) => {
456
+ if (left.paneZ !== right.paneZ) return left.paneZ - right.paneZ
457
+ return left.sequence - right.sequence
458
+ })
459
+ return drawables
460
+ }
461
+
462
+ function serializeSvgElement(element, width, height) {
463
+ const clone = element.cloneNode(true)
464
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
465
+ clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
466
+ clone.setAttribute('width', String(width))
467
+ clone.setAttribute('height', String(height))
468
+ if (!clone.getAttribute('viewBox')) {
469
+ clone.setAttribute('viewBox', `0 0 ${width} ${height}`)
470
+ }
471
+ return new XMLSerializer().serializeToString(clone)
472
+ }
473
+
474
+ function loadSvgImage(svgMarkup) {
475
+ return new Promise((resolve, reject) => {
476
+ const blob = new Blob([svgMarkup], { type: 'image/svg+xml;charset=utf-8' })
477
+ const objectUrl = URL.createObjectURL(blob)
478
+ const image = new Image()
479
+ image.decoding = 'async'
480
+ image.onload = () => {
481
+ URL.revokeObjectURL(objectUrl)
482
+ resolve(image)
483
+ }
484
+ image.onerror = () => {
485
+ URL.revokeObjectURL(objectUrl)
486
+ reject(new Error('Nao foi possivel desenhar uma camada vetorial do mapa.'))
487
+ }
488
+ image.src = objectUrl
489
+ })
490
+ }
491
+
492
+ function canvasToBlob(canvas) {
493
+ return new Promise((resolve, reject) => {
494
+ try {
495
+ canvas.toBlob((blob) => {
496
+ if (blob) {
497
+ resolve(blob)
498
+ return
499
+ }
500
+ reject(new Error('Nao foi possivel gerar a imagem PNG do mapa.'))
501
+ }, 'image/png')
502
+ } catch (error) {
503
+ reject(error)
504
+ }
505
+ })
506
+ }
507
+
508
+ function clampNumber(value, min, max) {
509
+ return Math.max(min, Math.min(max, value))
510
+ }
511
+
512
+ function normalizeFitPadding(value, fallback = 48) {
513
+ const numeric = Number(value)
514
+ const resolved = Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback
515
+ return [resolved, resolved]
516
+ }
517
+
518
+ function normalizeSelectionRect(selectionRect, containerWidth, containerHeight) {
519
+ const rawLeft = Number(selectionRect?.left ?? selectionRect?.x)
520
+ const rawTop = Number(selectionRect?.top ?? selectionRect?.y)
521
+ const rawWidth = Number(selectionRect?.width)
522
+ const rawHeight = Number(selectionRect?.height)
523
+ if (![rawLeft, rawTop, rawWidth, rawHeight].every(Number.isFinite)) return null
524
+ if (rawWidth <= 0 || rawHeight <= 0) return null
525
+
526
+ const left = clampNumber(rawLeft, 0, containerWidth)
527
+ const top = clampNumber(rawTop, 0, containerHeight)
528
+ const right = clampNumber(rawLeft + rawWidth, 0, containerWidth)
529
+ const bottom = clampNumber(rawTop + rawHeight, 0, containerHeight)
530
+ const width = right - left
531
+ const height = bottom - top
532
+ if (width < 12 || height < 12) return null
533
+ return {
534
+ left,
535
+ top,
536
+ width,
537
+ height,
538
+ right,
539
+ bottom,
540
+ }
541
+ }
542
+
543
+ function intersectRectangles(leftRect, rightRect) {
544
+ const left = Math.max(leftRect.left, rightRect.left)
545
+ const top = Math.max(leftRect.top, rightRect.top)
546
+ const right = Math.min(leftRect.right, rightRect.right)
547
+ const bottom = Math.min(leftRect.bottom, rightRect.bottom)
548
+ if (right <= left || bottom <= top) return null
549
+ return {
550
+ left,
551
+ top,
552
+ right,
553
+ bottom,
554
+ width: right - left,
555
+ height: bottom - top,
556
+ }
557
+ }
558
+
559
+ function resolveDrawableSourceSize(element, kind, drawWidth, drawHeight) {
560
+ if (kind === 'image') {
561
+ return {
562
+ width: Number(element?.naturalWidth) || drawWidth,
563
+ height: Number(element?.naturalHeight) || drawHeight,
564
+ }
565
+ }
566
+ if (kind === 'canvas') {
567
+ return {
568
+ width: Number(element?.width) || drawWidth,
569
+ height: Number(element?.height) || drawHeight,
570
+ }
571
+ }
572
+ return { width: drawWidth, height: drawHeight }
573
+ }
574
+
575
+ function computeSelectionExportScale(selectionRect) {
576
+ const longestEdge = Math.max(selectionRect?.width || 0, selectionRect?.height || 0, 1)
577
+ const deviceScale = Number(window.devicePixelRatio) || 1
578
+ const preferredScale = Math.max(3, deviceScale * 2.5)
579
+ const boundedScale = 3600 / longestEdge
580
+ return Math.max(2, Math.min(5, preferredScale, boundedScale))
581
+ }
582
+
583
+ function fitMapToPayloadBounds(map, payload, padding = 48) {
584
+ if (!map) return false
585
+ const bounds = Array.isArray(payload?.bounds) ? payload.bounds : null
586
+ if (bounds && bounds.length === 2) {
587
+ map.fitBounds(bounds, { padding: normalizeFitPadding(padding), maxZoom: 18, animate: false })
588
+ return true
589
+ }
590
+ if (Array.isArray(payload?.center) && payload.center.length === 2) {
591
+ map.setView(payload.center, 12, { animate: false })
592
+ return true
593
+ }
594
+ return false
595
+ }
596
+
597
+ async function waitForTileLayerLoad(tileLayer, timeoutMs = 6500) {
598
+ if (!tileLayer) return
599
+ await new Promise((resolve) => {
600
+ let settled = false
601
+ const finalize = () => {
602
+ if (settled) return
603
+ settled = true
604
+ window.clearTimeout(timerId)
605
+ tileLayer.off('load', finalize)
606
+ resolve()
607
+ }
608
+ const timerId = window.setTimeout(finalize, timeoutMs)
609
+ tileLayer.on('load', finalize)
610
+ if (!tileLayer._loading) {
611
+ window.setTimeout(finalize, 180)
612
+ }
613
+ })
614
+ }
615
+
616
+ function resolveActiveTileLayerConfig(sourceMap, payload) {
617
+ const definitions = Array.isArray(payload?.tile_layers) ? payload.tile_layers : []
618
+ if (!sourceMap || !definitions.length) return definitions[0] || null
619
+
620
+ const activeUrls = new Set()
621
+ sourceMap.eachLayer((layer) => {
622
+ if (layer instanceof L.TileLayer && sourceMap.hasLayer(layer)) {
623
+ const url = String(layer?._url || '').trim()
624
+ if (url) activeUrls.add(url)
625
+ }
626
+ })
627
+ return definitions.find((item) => activeUrls.has(String(item?.url || '').trim())) || definitions[0] || null
628
+ }
629
+
630
+ async function buildHighResolutionSelectionBlob({
631
+ sourceMap,
632
+ sourceContainer,
633
+ payload,
634
+ selectionRect,
635
+ }) {
636
+ if (!sourceMap || !sourceContainer) {
637
+ throw new Error('Mapa indisponivel para exportacao.')
638
+ }
639
+
640
+ const hostRect = sourceContainer.getBoundingClientRect()
641
+ const containerWidth = Math.round(hostRect.width)
642
+ const containerHeight = Math.round(hostRect.height)
643
+ const normalizedSelection = normalizeSelectionRect(selectionRect, containerWidth, containerHeight)
644
+ if (!normalizedSelection) {
645
+ throw new Error('Selecione um retangulo valido no mapa antes de baixar.')
646
+ }
647
+
648
+ const exportScale = computeSelectionExportScale(normalizedSelection)
649
+ const exportWidth = Math.max(480, Math.round(normalizedSelection.width * exportScale))
650
+ const exportHeight = Math.max(480, Math.round(normalizedSelection.height * exportScale))
651
+ const selectionCenter = sourceMap.containerPointToLatLng([
652
+ normalizedSelection.left + (normalizedSelection.width / 2),
653
+ normalizedSelection.top + (normalizedSelection.height / 2),
654
+ ])
655
+ const sourceZoom = Number(sourceMap.getZoom?.() ?? 0)
656
+ const exportZoom = sourceZoom + Math.log2(exportScale)
657
+
658
+ const mount = document.createElement('div')
659
+ mount.style.position = 'fixed'
660
+ mount.style.left = '-20000px'
661
+ mount.style.top = '0'
662
+ mount.style.width = `${exportWidth}px`
663
+ mount.style.height = `${exportHeight}px`
664
+ mount.style.opacity = '0'
665
+ mount.style.pointerEvents = 'none'
666
+ mount.style.background = '#ffffff'
667
+ mount.style.overflow = 'hidden'
668
+ document.body.appendChild(mount)
669
+
670
+ let exportMap = null
671
+ try {
672
+ exportMap = L.map(mount, {
673
+ zoomControl: false,
674
+ attributionControl: false,
675
+ preferCanvas: true,
676
+ dragging: false,
677
+ scrollWheelZoom: false,
678
+ doubleClickZoom: false,
679
+ boxZoom: false,
680
+ keyboard: false,
681
+ touchZoom: false,
682
+ tapHold: false,
683
+ })
684
+
685
+ const bairrosPane = exportMap.createPane('mesa-bairros-pane')
686
+ const marketPane = exportMap.createPane('mesa-market-pane')
687
+ const trabalhosPane = exportMap.createPane('mesa-trabalhos-pane')
688
+ const indicesPane = exportMap.createPane('mesa-indices-pane')
689
+ bairrosPane.style.zIndex = '360'
690
+ marketPane.style.zIndex = '420'
691
+ trabalhosPane.style.zIndex = '430'
692
+ indicesPane.style.zIndex = '440'
693
+
694
+ const canvasRenderer = L.canvas({ padding: 0.5, pane: 'mesa-market-pane' })
695
+ const overlaySpecs = Array.isArray(payload?.overlay_layers) && payload.overlay_layers.length
696
+ ? payload.overlay_layers
697
+ : buildLegacyOverlayLayers(payload)
698
+ const responsivePointContainers = []
699
+ const radiusBehavior = payload?.radius_behavior || {}
700
+ const minRadius = Number(radiusBehavior.min_radius) || 1.6
701
+ const maxRadius = Number(radiusBehavior.max_radius) || 52.0
702
+ const referenceZoom = Number(radiusBehavior.reference_zoom) || 12.0
703
+ const growthFactor = Number(radiusBehavior.growth_factor) || 0.2
704
+
705
+ const activeTileLayerConfig = resolveActiveTileLayerConfig(sourceMap, payload)
706
+ let tileLayer = null
707
+ if (activeTileLayerConfig?.url) {
708
+ tileLayer = L.tileLayer(String(activeTileLayerConfig.url || ''), {
709
+ attribution: String(activeTileLayerConfig?.attribution || ''),
710
+ crossOrigin: 'anonymous',
711
+ detectRetina: true,
712
+ }).addTo(exportMap)
713
+ }
714
+
715
+ const applyResponsiveRadius = () => {
716
+ const zoom = typeof exportMap.getZoom === 'function' ? exportMap.getZoom() : referenceZoom
717
+ const zoomDelta = zoom - referenceZoom
718
+ const expFactor = 2 ** (zoomDelta * growthFactor)
719
+ let floorScale = 1.0
720
+ if (zoomDelta >= 0) {
721
+ floorScale = 1 + zoomDelta * 0.22
722
+ if (zoom >= 15) {
723
+ floorScale += (zoom - 14) * 0.30
724
+ }
725
+ } else {
726
+ floorScale = Math.max(0.28, 1 + zoomDelta * 0.20)
727
+ }
728
+
729
+ responsivePointContainers.forEach((container) => {
730
+ if (!container || typeof container.eachLayer !== 'function') return
731
+ container.eachLayer((layer) => {
732
+ if (typeof layer.setRadius !== 'function') return
733
+ const base = Number(layer.options?.mesaBaseRadius || layer.options?.radius || 4)
734
+ const dynamicMin = Math.max(minRadius, base * floorScale)
735
+ const dynamicMax = Math.max(dynamicMin + 0.1, Math.min(maxRadius, base * 8.0))
736
+ layer.setRadius(clampNumber(base * expFactor, dynamicMin, dynamicMax))
737
+ })
738
+ })
739
+ }
740
+
741
+ const addPointMarkers = (layerGroup, items) => {
742
+ if (!Array.isArray(items) || !items.length) return
743
+ responsivePointContainers.push(layerGroup)
744
+ items.forEach((item) => {
745
+ const latlng = parseLatLonPair(item?.lat, item?.lon)
746
+ if (!latlng) return
747
+ const marker = L.circleMarker(latlng, {
748
+ renderer: canvasRenderer,
749
+ pane: String(item?.pane || 'mesa-market-pane'),
750
+ radius: Number(item?.base_radius) || 4,
751
+ color: String(item?.stroke_color || '#000000'),
752
+ weight: Number.isFinite(Number(item?.stroke_weight))
753
+ ? Number(item.stroke_weight)
754
+ : (Number(item?.base_radius) > 4 ? 1 : 0.8),
755
+ fill: item?.fill !== false,
756
+ fillColor: String(item?.color || item?.fill_color || '#FF8C00'),
757
+ fillOpacity: Number.isFinite(Number(item?.fill_opacity)) ? Number(item.fill_opacity) : 0.68,
758
+ interactive: false,
759
+ bubblingMouseEvents: false,
760
+ })
761
+ marker.options.mesaBaseRadius = Number(item?.base_radius) || 4
762
+ layerGroup.addLayer(marker)
763
+ })
764
+ }
765
+
766
+ const addShapeOverlays = (layerGroup, shapes) => {
767
+ if (!Array.isArray(shapes) || !shapes.length) return
768
+ shapes.forEach((shape) => {
769
+ const shapeType = String(shape?.type || shape?.shape_type || '').trim().toLowerCase()
770
+ const style = {
771
+ color: String(shape?.color || '#1f6fb2'),
772
+ weight: Number.isFinite(Number(shape?.weight)) ? Number(shape.weight) : 2,
773
+ opacity: Number.isFinite(Number(shape?.opacity)) ? Number(shape.opacity) : 0.8,
774
+ fill: shape?.fill === true,
775
+ fillColor: String(shape?.fill_color || shape?.color || '#1f6fb2'),
776
+ fillOpacity: Number.isFinite(Number(shape?.fill_opacity)) ? Number(shape.fill_opacity) : 0.12,
777
+ dashArray: shape?.dash_array ? String(shape.dash_array) : undefined,
778
+ pane: String(shape?.pane || 'mesa-bairros-pane'),
779
+ interactive: false,
780
+ }
781
+ let layer = null
782
+
783
+ if (shapeType === 'circle' && Array.isArray(shape?.center) && shape.center.length === 2) {
784
+ const center = parseLatLonPair(shape.center[0], shape.center[1])
785
+ if (center) {
786
+ layer = L.circle(center, { ...style, radius: Number(shape?.radius_m) || 0 })
787
+ }
788
+ } else if (shapeType === 'polyline' && Array.isArray(shape?.coords) && shape.coords.length) {
789
+ layer = L.polyline(shape.coords, style)
790
+ } else if (shapeType === 'polygon' && Array.isArray(shape?.coords) && shape.coords.length) {
791
+ layer = L.polygon(shape.coords, style)
792
+ } else if (shapeType === 'circlemarker' && Array.isArray(shape?.center) && shape.center.length === 2) {
793
+ const center = parseLatLonPair(shape.center[0], shape.center[1])
794
+ if (center) {
795
+ layer = L.circleMarker(center, { ...style, radius: Number(shape?.radius) || 6 })
796
+ }
797
+ }
798
+
799
+ if (layer) {
800
+ layerGroup.addLayer(layer)
801
+ }
802
+ })
803
+ }
804
+
805
+ const addGeoJsonOverlay = async (layerGroup, spec) => {
806
+ const geojsonLayer = L.geoJSON(null, {
807
+ pane: String(spec?.geojson_pane || 'mesa-bairros-pane'),
808
+ style: () => ({
809
+ color: String(spec?.geojson_style?.color || '#4c6882'),
810
+ weight: Number.isFinite(Number(spec?.geojson_style?.weight)) ? Number(spec.geojson_style.weight) : 1.0,
811
+ fillColor: String(spec?.geojson_style?.fillColor || '#f39c12'),
812
+ fillOpacity: Number.isFinite(Number(spec?.geojson_style?.fillOpacity))
813
+ ? Number(spec.geojson_style.fillOpacity)
814
+ : 0.04,
815
+ interactive: false,
816
+ }),
817
+ })
818
+ layerGroup.addLayer(geojsonLayer)
819
+
820
+ if (spec?.geojson_data) {
821
+ geojsonLayer.addData(spec.geojson_data)
822
+ return
823
+ }
824
+ if (!spec?.geojson_url) return
825
+ try {
826
+ const geojson = await carregarBairrosGeojson(spec.geojson_url)
827
+ if (geojson) {
828
+ geojsonLayer.addData(geojson)
829
+ }
830
+ } catch {
831
+ // Camada opcional; manter o download funcional mesmo sem bairros.
832
+ }
833
+ }
834
+
835
+ const addHeatmapOverlay = (layerGroup, heatmapSpec) => {
836
+ if (!heatmapSpec || typeof L.heatLayer !== 'function') return
837
+ const points = Array.isArray(heatmapSpec?.points)
838
+ ? heatmapSpec.points
839
+ .map((item) => {
840
+ const latlng = parseLatLonPair(item?.lat, item?.lon)
841
+ const weight = Number(item?.weight)
842
+ if (!latlng) return null
843
+ if (Number.isFinite(weight)) return [latlng[0], latlng[1], weight]
844
+ return latlng
845
+ })
846
+ .filter(Boolean)
847
+ : []
848
+ if (!points.length) return
849
+ const layer = L.heatLayer(points, {
850
+ radius: Number(heatmapSpec?.radius) || 20,
851
+ blur: Number(heatmapSpec?.blur) || 18,
852
+ minOpacity: Number.isFinite(Number(heatmapSpec?.min_opacity)) ? Number(heatmapSpec.min_opacity) : 0.28,
853
+ maxZoom: Number(heatmapSpec?.max_zoom) || 17,
854
+ gradient: heatmapSpec?.gradient || undefined,
855
+ })
856
+ layerGroup.addLayer(layer)
857
+ }
858
+
859
+ const overlayPromises = overlaySpecs.map(async (spec) => {
860
+ if (spec?.show === false) return
861
+ const layerGroup = L.layerGroup().addTo(exportMap)
862
+ if (spec?.geojson_url || spec?.geojson_data) {
863
+ await addGeoJsonOverlay(layerGroup, spec)
864
+ }
865
+ addHeatmapOverlay(layerGroup, spec?.heatmap)
866
+ addShapeOverlays(layerGroup, spec?.shapes)
867
+ addPointMarkers(layerGroup, spec?.points)
868
+ })
869
+
870
+ exportMap.setView(selectionCenter, exportZoom, { animate: false })
871
+ applyResponsiveRadius()
872
+ exportMap.on('zoomend', applyResponsiveRadius)
873
+
874
+ await Promise.all(overlayPromises)
875
+ await waitForTileLayerLoad(tileLayer)
876
+ await waitForNextPaint()
877
+ await waitForNextPaint()
878
+
879
+ const blob = await captureContainerRegionBlob(mount, null, 1)
880
+ exportMap.off('zoomend', applyResponsiveRadius)
881
+ return blob
882
+ } finally {
883
+ if (exportMap) {
884
+ exportMap.remove()
885
+ }
886
+ mount.remove()
887
+ }
888
+ }
889
+
890
+ async function captureContainerRegionBlob(container, selectionRect = null, scaleOverride = null) {
891
+ const hostRect = container.getBoundingClientRect()
892
+ const containerWidth = Math.round(hostRect.width)
893
+ const containerHeight = Math.round(hostRect.height)
894
+ const normalizedSelection = selectionRect
895
+ ? normalizeSelectionRect(selectionRect, containerWidth, containerHeight)
896
+ : {
897
+ left: 0,
898
+ top: 0,
899
+ width: containerWidth,
900
+ height: containerHeight,
901
+ right: containerWidth,
902
+ bottom: containerHeight,
903
+ }
904
+
905
+ if (!normalizedSelection || normalizedSelection.width <= 0 || normalizedSelection.height <= 0) {
906
+ throw new Error('Nao foi possivel montar a area de exportacao do mapa.')
907
+ }
908
+
909
+ const scale = Number.isFinite(Number(scaleOverride)) && Number(scaleOverride) > 0
910
+ ? Number(scaleOverride)
911
+ : computeSelectionExportScale(normalizedSelection)
912
+ const canvas = document.createElement('canvas')
913
+ canvas.width = Math.max(1, Math.round(normalizedSelection.width * scale))
914
+ canvas.height = Math.max(1, Math.round(normalizedSelection.height * scale))
915
+
916
+ const ctx = canvas.getContext('2d')
917
+ if (!ctx) {
918
+ throw new Error('O navegador nao conseguiu montar o canvas de exportacao.')
919
+ }
920
+
921
+ ctx.scale(scale, scale)
922
+ ctx.imageSmoothingEnabled = true
923
+ ctx.imageSmoothingQuality = 'high'
924
+ ctx.fillStyle = '#ffffff'
925
+ ctx.fillRect(0, 0, normalizedSelection.width, normalizedSelection.height)
926
+
927
+ const drawables = collectVisibleMapDrawables(container, hostRect)
928
+ for (const drawable of drawables) {
929
+ const { element, kind, rect } = drawable
930
+ const drawableRect = {
931
+ left: rect.left - hostRect.left,
932
+ top: rect.top - hostRect.top,
933
+ right: rect.right - hostRect.left,
934
+ bottom: rect.bottom - hostRect.top,
935
+ width: rect.width,
936
+ height: rect.height,
937
+ }
938
+ const overlap = intersectRectangles(drawableRect, normalizedSelection)
939
+ if (!overlap) continue
940
+
941
+ const sourceWidth = Math.max(1, drawableRect.width)
942
+ const sourceHeight = Math.max(1, drawableRect.height)
943
+ const sourceSize = resolveDrawableSourceSize(element, kind, sourceWidth, sourceHeight)
944
+ const ratioX = sourceSize.width / sourceWidth
945
+ const ratioY = sourceSize.height / sourceHeight
946
+ const sx = (overlap.left - drawableRect.left) * ratioX
947
+ const sy = (overlap.top - drawableRect.top) * ratioY
948
+ const sw = overlap.width * ratioX
949
+ const sh = overlap.height * ratioY
950
+ const dx = overlap.left - normalizedSelection.left
951
+ const dy = overlap.top - normalizedSelection.top
952
+
953
+ let source = element
954
+ if (kind === 'svg') {
955
+ const svgMarkup = serializeSvgElement(element, sourceWidth, sourceHeight)
956
+ source = await loadSvgImage(svgMarkup)
957
+ }
958
+
959
+ ctx.drawImage(source, sx, sy, sw, sh, dx, dy, overlap.width, overlap.height)
960
+ }
961
+
962
+ return canvasToBlob(canvas)
963
+ }
964
+
965
+ const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId }, ref) {
966
  const hostRef = useRef(null)
967
+ const mapRef = useRef(null)
968
+ const payloadRef = useRef(payload)
969
  const popupCacheRef = useRef(new Map())
970
  const [runtimeError, setRuntimeError] = useState('')
971
 
972
+ payloadRef.current = payload
973
+
974
+ useImperativeHandle(ref, () => ({
975
+ fitToPayloadBounds(padding = 48) {
976
+ const map = mapRef.current
977
+ if (!map) return false
978
+ return fitMapToPayloadBounds(map, payloadRef.current, padding)
979
+ },
980
+ async downloadSelectionPng(selectionRect, fileName = 'mapa-recorte.png') {
981
+ const container = hostRef.current
982
+ const blob = await buildHighResolutionSelectionBlob({
983
+ sourceMap: mapRef.current,
984
+ sourceContainer: container,
985
+ payload: payloadRef.current,
986
+ selectionRect,
987
+ })
988
+ downloadBlob(blob, fileName)
989
+ return true
990
+ },
991
+ async downloadVisiblePng(fileName = 'mapa.png') {
992
+ const container = hostRef.current
993
+ if (!container) {
994
+ throw new Error('Mapa indisponivel para exportacao.')
995
+ }
996
+
997
+ mapRef.current?.invalidateSize?.(false)
998
+ await waitForNextPaint()
999
+
1000
+ const hostRect = container.getBoundingClientRect()
1001
+ const width = Math.round(hostRect.width)
1002
+ const height = Math.round(hostRect.height)
1003
+ if (width <= 0 || height <= 0) {
1004
+ throw new Error('O mapa ainda nao terminou de renderizar para exportacao.')
1005
+ }
1006
+
1007
+ const deviceScale = Number(window.devicePixelRatio) || 1
1008
+ const scale = Math.max(3, Math.min(5, deviceScale * 2))
1009
+ const blob = await captureContainerRegionBlob(container, null, scale)
1010
+ downloadBlob(blob, fileName)
1011
+ return true
1012
+ },
1013
+ }), [])
1014
+
1015
  useEffect(() => {
1016
  if (!hostRef.current || !payload) return undefined
1017
  let disposed = false
 
1022
  zoomControl: true,
1023
  preferCanvas: true,
1024
  })
1025
+ mapRef.current = map
1026
  let restoreMapInteractions = null
1027
  const bairrosPane = map.createPane('mesa-bairros-pane')
1028
  const marketPane = map.createPane('mesa-market-pane')
 
1044
  attribution: index === 0
1045
  ? '&copy; OpenStreetMap contributors'
1046
  : '&copy; OpenStreetMap contributors &copy; CARTO',
1047
+ crossOrigin: 'anonymous',
1048
+ detectRetina: true,
1049
  })
1050
  const label = String(layerDef?.label || layerDef?.id || `Base ${index + 1}`)
1051
  baseLayers[label] = tileLayer
 
1170
  if (!Array.isArray(items) || !items.length) return
1171
  responsivePointContainers.push(layerGroup)
1172
  items.forEach((item) => {
1173
+ const latlng = parseLatLonPair(item?.lat, item?.lon)
1174
+ if (!latlng) return
1175
+ const marker = L.circleMarker(latlng, {
1176
  renderer: canvasRenderer,
1177
  pane: String(item?.pane || 'mesa-market-pane'),
1178
  radius: Number(item?.base_radius) || 4,
 
1201
  function addMarkerOverlays(layerGroup, items) {
1202
  if (!Array.isArray(items) || !items.length) return
1203
  items.forEach((item) => {
1204
+ const latlng = parseLatLonPair(item?.lat, item?.lon)
1205
+ if (!latlng) return
1206
  const icon = L.divIcon({
1207
  html: String(item?.marker_html || ''),
1208
  iconSize: Array.isArray(item?.icon_size) ? item.icon_size : [14, 14],
1209
  iconAnchor: Array.isArray(item?.icon_anchor) ? item.icon_anchor : [7, 7],
1210
  className: String(item?.class_name || 'mesa-map-marker'),
1211
  })
1212
+ const marker = L.marker(latlng, {
1213
  icon,
1214
  pane: String(item?.pane || (String(item?.class_name || '').includes('indice') ? 'mesa-indices-pane' : 'mesa-trabalhos-pane')),
1215
  bubblingMouseEvents: item?.bubbling_mouse_events === true,
 
1286
  let layer = null
1287
 
1288
  if (shapeType === 'circle' && Array.isArray(shape?.center) && shape.center.length === 2) {
1289
+ const center = parseLatLonPair(shape.center[0], shape.center[1])
1290
+ if (center) {
1291
+ layer = L.circle(center, { ...style, radius: Number(shape?.radius_m) || 0 })
1292
+ }
1293
  } else if (shapeType === 'polyline' && Array.isArray(shape?.coords) && shape.coords.length) {
1294
  layer = L.polyline(shape.coords, style)
1295
  } else if (shapeType === 'polygon' && Array.isArray(shape?.coords) && shape.coords.length) {
1296
  layer = L.polygon(shape.coords, style)
1297
  } else if (shapeType === 'circlemarker' && Array.isArray(shape?.center) && shape.center.length === 2) {
1298
+ const center = parseLatLonPair(shape.center[0], shape.center[1])
1299
+ if (center) {
1300
+ layer = L.circleMarker(center, { ...style, radius: Number(shape?.radius) || 6 })
1301
+ }
1302
  }
1303
 
1304
  if (!layer) return
 
1358
  const points = Array.isArray(heatmapSpec?.points)
1359
  ? heatmapSpec.points
1360
  .map((item) => {
1361
+ const latlng = parseLatLonPair(item?.lat, item?.lon)
 
1362
  const weight = Number(item?.weight)
1363
+ if (!latlng) return null
1364
+ if (Number.isFinite(weight)) return [latlng[0], latlng[1], weight]
1365
+ return latlng
1366
  })
1367
  .filter(Boolean)
1368
  : []
 
1694
  map.on('zoomend overlayadd overlayremove', applyResponsiveRadius)
1695
  applyResponsiveRadius()
1696
 
1697
+ if (!fitMapToPayloadBounds(map, payload, 48)) {
 
 
 
 
 
1698
  setRuntimeError('Falha ao montar mapa interativo.')
1699
  }
1700
 
 
1705
  }
1706
  disposed = true
1707
  restoreMapInteractions?.()
1708
+ if (mapRef.current === map) {
1709
+ mapRef.current = null
1710
+ }
1711
  map.remove()
1712
  }
1713
  }, [payload, sessionId])
 
1718
  {runtimeError ? <div className="leaflet-map-runtime-error">{runtimeError}</div> : null}
1719
  </div>
1720
  )
1721
+ })
1722
+
1723
+ export default LeafletMapFrame
frontend/src/components/MapFrame.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useMemo, useRef } from 'react'
2
  import LeafletMapFrame from './LeafletMapFrame'
3
 
4
  function hashHtml(value) {
@@ -11,8 +11,9 @@ function hashHtml(value) {
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])
18
 
@@ -130,8 +131,29 @@ export default function MapFrame({ html, payload = null, sessionId = '' }) {
130
  }
131
  }, [clearTimers])
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  if (payload && payload.type === 'mesa_leaflet_payload') {
134
- return <LeafletMapFrame payload={payload} sessionId={sessionId} />
135
  }
136
 
137
  if (!html) {
@@ -149,4 +171,6 @@ export default function MapFrame({ html, payload = null, sessionId = '' }) {
149
  onLoad={scheduleRecenter}
150
  />
151
  )
152
- }
 
 
 
1
+ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
2
  import LeafletMapFrame from './LeafletMapFrame'
3
 
4
  function hashHtml(value) {
 
11
  return `${value.length}-${Math.abs(hash)}`
12
  }
13
 
14
+ const MapFrame = forwardRef(function MapFrame({ html, payload = null, sessionId = '' }, ref) {
15
  const iframeRef = useRef(null)
16
+ const leafletRef = useRef(null)
17
  const timersRef = useRef([])
18
  const frameKey = useMemo(() => hashHtml(html || ''), [html])
19
 
 
131
  }
132
  }, [clearTimers])
133
 
134
+ useImperativeHandle(ref, () => ({
135
+ fitToPayloadBounds(padding = 48) {
136
+ if (leafletRef.current?.fitToPayloadBounds) {
137
+ return leafletRef.current.fitToPayloadBounds(padding)
138
+ }
139
+ return false
140
+ },
141
+ async downloadSelectionPng(selectionRect, fileName = 'mapa-recorte.png') {
142
+ if (leafletRef.current?.downloadSelectionPng) {
143
+ return leafletRef.current.downloadSelectionPng(selectionRect, fileName)
144
+ }
145
+ throw new Error('A exportacao em PNG esta disponivel apenas para mapas interativos.')
146
+ },
147
+ async downloadVisiblePng(fileName = 'mapa.png') {
148
+ if (leafletRef.current?.downloadVisiblePng) {
149
+ return leafletRef.current.downloadVisiblePng(fileName)
150
+ }
151
+ throw new Error('A exportacao em PNG esta disponivel apenas para mapas interativos.')
152
+ },
153
+ }), [])
154
+
155
  if (payload && payload.type === 'mesa_leaflet_payload') {
156
+ return <LeafletMapFrame ref={leafletRef} payload={payload} sessionId={sessionId} />
157
  }
158
 
159
  if (!html) {
 
171
  onLoad={scheduleRecenter}
172
  />
173
  )
174
+ })
175
+
176
+ export default MapFrame
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -120,17 +120,41 @@ function formatAvaliandoGeoLabel(index) {
120
  return `A${index + 1}`
121
  }
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  function buildAvaliandosGeoPayload(entries = []) {
124
  return (entries || []).map((item, index) => ({
125
  id: String(item?.id || `avaliando-${index + 1}`),
126
  label: formatAvaliandoGeoLabel(index),
127
- lat: Number(item?.lat),
128
- lon: Number(item?.lon),
129
  logradouro: item?.logradouro || null,
130
  numero_usado: item?.numero_usado || null,
131
  cdlog: item?.cdlog ?? null,
132
  origem: item?.origem || null,
133
- })).filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lon))
134
  }
135
 
136
  function getResumoEspacialValor(resumo, criterio) {
@@ -187,8 +211,8 @@ function createLocalizacaoEntry(resolvida, id) {
187
  return {
188
  ...resolvida,
189
  id,
190
- lat: Number(resolvida?.lat),
191
- lon: Number(resolvida?.lon),
192
  }
193
  }
194
 
@@ -1069,13 +1093,13 @@ export default function PesquisaTab({
1069
  pesquisaExecutada: true,
1070
  avaliandos: avaliandosGeolocalizados.map((item, index) => ({
1071
  id: String(item?.id || `avaliando-${index + 1}`),
1072
- lat: Number(item?.lat),
1073
- lon: Number(item?.lon),
1074
  logradouro: String(item?.logradouro || ''),
1075
  numero_usado: String(item?.numero_usado || ''),
1076
  cdlog: item?.cdlog ?? null,
1077
  origem: String(item?.origem || 'coords'),
1078
- })).filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lon)),
1079
  }
1080
  }
1081
 
@@ -1269,7 +1293,7 @@ export default function PesquisaTab({
1269
  try {
1270
  const response = await api.pesquisarLogradourosEixos()
1271
  const opcoes = Array.isArray(response?.logradouros_eixos)
1272
- ? response.logradouros_eixos.map((item) => String(item || '').trim()).filter(Boolean)
1273
  : []
1274
  setLogradouroOptions(opcoes)
1275
  setLogradouroOptionsLoaded(true)
@@ -1300,9 +1324,9 @@ export default function PesquisaTab({
1300
  const rawAvaliandos = Array.isArray(routeRequest?.avaliandos) ? routeRequest.avaliandos : []
1301
  let nextEntries = rawAvaliandos
1302
  .map((item, index) => {
1303
- const lat = Number(item?.lat)
1304
- const lon = Number(item?.lon)
1305
- if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
1306
  return createLocalizacaoEntry({
1307
  lat,
1308
  lon,
@@ -1313,9 +1337,9 @@ export default function PesquisaTab({
1313
  }, String(item?.id || `avaliando-${localizacaoIdCounterRef.current + index}`))
1314
  })
1315
  .filter(Boolean)
1316
- const lat = Number(routeRequest?.avaliando?.lat)
1317
- const lon = Number(routeRequest?.avaliando?.lon)
1318
- const possuiAvaliando = Number.isFinite(lat) && Number.isFinite(lon)
1319
  if (!nextEntries.length && possuiAvaliando) {
1320
  nextEntries = [createLocalizacaoEntry({
1321
  lat,
@@ -1763,8 +1787,8 @@ export default function PesquisaTab({
1763
  })
1764
  const resolvida = {
1765
  ...response,
1766
- lat: Number(response?.lat),
1767
- lon: Number(response?.lon),
1768
  }
1769
  const nextEntry = createLocalizacaoEntry(
1770
  resolvida,
 
120
  return `A${index + 1}`
121
  }
122
 
123
+ function parseCoordinateValue(value) {
124
+ if (value === null || value === undefined) return null
125
+ const text = String(value).trim().replace(',', '.')
126
+ if (!text) return null
127
+ const parsed = Number(text)
128
+ return Number.isFinite(parsed) ? parsed : null
129
+ }
130
+
131
+ function normalizeLogradouroOption(item) {
132
+ if (typeof item === 'string') {
133
+ const text = String(item || '').trim()
134
+ return text ? { value: text, label: text } : null
135
+ }
136
+ if (!item || typeof item !== 'object') return null
137
+ const value = String(item.value ?? item.logradouro ?? '').trim()
138
+ if (!value) return null
139
+ const label = String(item.label ?? item.display_label ?? value).trim() || value
140
+ return {
141
+ value,
142
+ label,
143
+ secondary: String(item.secondary ?? '').trim(),
144
+ }
145
+ }
146
+
147
  function buildAvaliandosGeoPayload(entries = []) {
148
  return (entries || []).map((item, index) => ({
149
  id: String(item?.id || `avaliando-${index + 1}`),
150
  label: formatAvaliandoGeoLabel(index),
151
+ lat: parseCoordinateValue(item?.lat),
152
+ lon: parseCoordinateValue(item?.lon),
153
  logradouro: item?.logradouro || null,
154
  numero_usado: item?.numero_usado || null,
155
  cdlog: item?.cdlog ?? null,
156
  origem: item?.origem || null,
157
+ })).filter((item) => item.lat !== null && item.lon !== null)
158
  }
159
 
160
  function getResumoEspacialValor(resumo, criterio) {
 
211
  return {
212
  ...resolvida,
213
  id,
214
+ lat: parseCoordinateValue(resolvida?.lat),
215
+ lon: parseCoordinateValue(resolvida?.lon),
216
  }
217
  }
218
 
 
1093
  pesquisaExecutada: true,
1094
  avaliandos: avaliandosGeolocalizados.map((item, index) => ({
1095
  id: String(item?.id || `avaliando-${index + 1}`),
1096
+ lat: parseCoordinateValue(item?.lat),
1097
+ lon: parseCoordinateValue(item?.lon),
1098
  logradouro: String(item?.logradouro || ''),
1099
  numero_usado: String(item?.numero_usado || ''),
1100
  cdlog: item?.cdlog ?? null,
1101
  origem: String(item?.origem || 'coords'),
1102
+ })).filter((item) => item.lat !== null && item.lon !== null),
1103
  }
1104
  }
1105
 
 
1293
  try {
1294
  const response = await api.pesquisarLogradourosEixos()
1295
  const opcoes = Array.isArray(response?.logradouros_eixos)
1296
+ ? response.logradouros_eixos.map(normalizeLogradouroOption).filter(Boolean)
1297
  : []
1298
  setLogradouroOptions(opcoes)
1299
  setLogradouroOptionsLoaded(true)
 
1324
  const rawAvaliandos = Array.isArray(routeRequest?.avaliandos) ? routeRequest.avaliandos : []
1325
  let nextEntries = rawAvaliandos
1326
  .map((item, index) => {
1327
+ const lat = parseCoordinateValue(item?.lat)
1328
+ const lon = parseCoordinateValue(item?.lon)
1329
+ if (lat === null || lon === null) return null
1330
  return createLocalizacaoEntry({
1331
  lat,
1332
  lon,
 
1337
  }, String(item?.id || `avaliando-${localizacaoIdCounterRef.current + index}`))
1338
  })
1339
  .filter(Boolean)
1340
+ const lat = parseCoordinateValue(routeRequest?.avaliando?.lat)
1341
+ const lon = parseCoordinateValue(routeRequest?.avaliando?.lon)
1342
+ const possuiAvaliando = lat !== null && lon !== null
1343
  if (!nextEntries.length && possuiAvaliando) {
1344
  nextEntries = [createLocalizacaoEntry({
1345
  lat,
 
1787
  })
1788
  const resolvida = {
1789
  ...response,
1790
+ lat: parseCoordinateValue(response?.lat),
1791
+ lon: parseCoordinateValue(response?.lon),
1792
  }
1793
  const nextEntry = createLocalizacaoEntry(
1794
  resolvida,
frontend/src/deepLinks.js CHANGED
@@ -144,7 +144,7 @@ export function hasMesaDeepLink(intent) {
144
  || trimValue(intent.trabalhoId)
145
  || trimValue(intent.subtab)
146
  || hasPesquisaFilters(intent.filters)
147
- || (intent.avaliando && Number.isFinite(Number(intent.avaliando.lat)) && Number.isFinite(Number(intent.avaliando.lon))),
148
  )
149
  }
150
 
 
144
  || trimValue(intent.trabalhoId)
145
  || trimValue(intent.subtab)
146
  || hasPesquisaFilters(intent.filters)
147
+ || (intent.avaliando && parseFiniteNumber(intent.avaliando.lat) !== null && parseFiniteNumber(intent.avaliando.lon) !== null),
148
  )
149
  }
150
 
frontend/src/styles.css CHANGED
@@ -4121,6 +4121,42 @@ button.pesquisa-coluna-remove:hover {
4121
  width: min(1180px, 100%);
4122
  }
4123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4124
  .avaliacao-knn-legenda {
4125
  display: flex;
4126
  flex-wrap: wrap;
@@ -4130,11 +4166,58 @@ button.pesquisa-coluna-remove:hover {
4130
  margin-bottom: 2px;
4131
  }
4132
 
 
 
4133
  .avaliacao-knn-map-wrap .map-frame {
4134
  min-height: 420px;
4135
  height: 420px;
4136
  }
4137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4138
  .avaliacao-knn-detalhes-box {
4139
  margin-bottom: 0;
4140
  }
@@ -4217,6 +4300,7 @@ button.pesquisa-coluna-remove:hover {
4217
  }
4218
 
4219
  @media (max-width: 900px) {
 
4220
  .avaliacao-knn-map-wrap .map-frame {
4221
  min-height: 340px;
4222
  height: 340px;
@@ -7310,6 +7394,11 @@ button.btn-download-subtle {
7310
  flex-direction: column;
7311
  }
7312
 
 
 
 
 
 
7313
  .pesquisa-card-values-modal-actions {
7314
  width: 100%;
7315
  justify-content: flex-end;
 
4121
  width: min(1180px, 100%);
4122
  }
4123
 
4124
+ .avaliacao-localizacao-map-modal {
4125
+ width: min(980px, 100%);
4126
+ }
4127
+
4128
+ .avaliacao-localizacao-download-modal {
4129
+ width: min(1180px, 100%);
4130
+ }
4131
+
4132
+ .avaliacao-localizacao-map-modal-actions {
4133
+ display: inline-flex;
4134
+ align-items: center;
4135
+ gap: 8px;
4136
+ flex-wrap: wrap;
4137
+ justify-content: flex-end;
4138
+ }
4139
+
4140
+ .avaliacao-download-toggle-btn {
4141
+ color: #ffffff;
4142
+ }
4143
+
4144
+ .avaliacao-download-toggle-btn.is-select {
4145
+ --btn-bg-start: #2f80cf;
4146
+ --btn-bg-end: #2368af;
4147
+ --btn-border: #1f5f9f;
4148
+ background: linear-gradient(180deg, var(--btn-bg-start) 0%, var(--btn-bg-end) 100%);
4149
+ border-color: var(--btn-border);
4150
+ }
4151
+
4152
+ .avaliacao-download-toggle-btn.is-clear {
4153
+ --btn-bg-start: #8d98a5;
4154
+ --btn-bg-end: #778290;
4155
+ --btn-border: #687380;
4156
+ background: linear-gradient(180deg, var(--btn-bg-start) 0%, var(--btn-bg-end) 100%);
4157
+ border-color: var(--btn-border);
4158
+ }
4159
+
4160
  .avaliacao-knn-legenda {
4161
  display: flex;
4162
  flex-wrap: wrap;
 
4166
  margin-bottom: 2px;
4167
  }
4168
 
4169
+ .avaliacao-localizacao-map-wrap .map-frame,
4170
+ .avaliacao-localizacao-download-map-wrap .map-frame,
4171
  .avaliacao-knn-map-wrap .map-frame {
4172
  min-height: 420px;
4173
  height: 420px;
4174
  }
4175
 
4176
+ .avaliacao-localizacao-download-map-wrap .map-frame {
4177
+ min-height: 560px;
4178
+ height: 560px;
4179
+ }
4180
+
4181
+ .avaliacao-localizacao-download-status {
4182
+ font-size: 0.84rem;
4183
+ color: #4d6479;
4184
+ }
4185
+
4186
+ .avaliacao-localizacao-download-map-wrap {
4187
+ position: relative;
4188
+ }
4189
+
4190
+ .avaliacao-localizacao-download-map-wrap.is-selecting .map-frame {
4191
+ pointer-events: none;
4192
+ }
4193
+
4194
+ .avaliacao-download-selection-layer {
4195
+ position: absolute;
4196
+ inset: 0;
4197
+ pointer-events: none;
4198
+ z-index: 1400;
4199
+ }
4200
+
4201
+ .avaliacao-download-selection-layer.is-active {
4202
+ pointer-events: auto;
4203
+ cursor: crosshair;
4204
+ touch-action: none;
4205
+ }
4206
+
4207
+ .avaliacao-download-selection-layer.is-active::before {
4208
+ content: '';
4209
+ position: absolute;
4210
+ inset: 0;
4211
+ background: rgba(9, 18, 28, 0.06);
4212
+ }
4213
+
4214
+ .avaliacao-download-selection-rect {
4215
+ position: absolute;
4216
+ border: 2px dashed rgba(215, 38, 61, 0.96);
4217
+ background: rgba(255, 255, 255, 0.16);
4218
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.72);
4219
+ }
4220
+
4221
  .avaliacao-knn-detalhes-box {
4222
  margin-bottom: 0;
4223
  }
 
4300
  }
4301
 
4302
  @media (max-width: 900px) {
4303
+ .avaliacao-localizacao-map-wrap .map-frame,
4304
  .avaliacao-knn-map-wrap .map-frame {
4305
  min-height: 340px;
4306
  height: 340px;
 
7394
  flex-direction: column;
7395
  }
7396
 
7397
+ .avaliacao-localizacao-map-modal-actions {
7398
+ width: 100%;
7399
+ justify-content: flex-end;
7400
+ }
7401
+
7402
  .pesquisa-card-values-modal-actions {
7403
  width: 100%;
7404
  justify-content: flex-end;