Spaces:
Running
Running
Guilherme Silberfarb Costa commited on
Commit ·
6240ef0
1
Parent(s): b9bb1d5
Enhance avaliacao map and KNN interactions
Browse files- backend/app/api/pesquisa.py +1 -1
- backend/app/api/visualizacao.py +16 -0
- backend/app/services/pesquisa_service.py +28 -14
- backend/app/services/visualizacao_service.py +232 -18
- frontend/src/api.js +6 -1
- frontend/src/components/AvaliacaoTab.jsx +314 -8
- frontend/src/components/LeafletMapFrame.jsx +675 -19
- frontend/src/components/MapFrame.jsx +28 -4
- frontend/src/components/PesquisaTab.jsx +41 -17
- frontend/src/deepLinks.js +1 -1
- frontend/src/styles.css +89 -0
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(
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3585 |
-
|
| 3586 |
-
|
| 3587 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3588 |
except HTTPException:
|
| 3589 |
-
|
| 3590 |
|
| 3591 |
-
|
|
|
|
|
|
|
| 3592 |
return sanitize_value(
|
| 3593 |
{
|
| 3594 |
-
"logradouros_eixos":
|
| 3595 |
-
"total_logradouros": len(
|
| 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
|
| 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(
|
| 250 |
-
radius=
|
| 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=
|
| 257 |
)
|
| 258 |
-
marcador.options["mesaBaseRadius"] =
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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":
|
| 350 |
"stroke_color": "#ffffff",
|
| 351 |
"stroke_weight": 0.9,
|
| 352 |
-
"fill_opacity":
|
| 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 |
-
|
| 381 |
-
|
| 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 =
|
| 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 =
|
| 496 |
-
const lon =
|
| 497 |
-
if (
|
| 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(
|
| 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:
|
| 969 |
-
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-
|
| 1464 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
? '© OpenStreetMap contributors'
|
| 401 |
: '© OpenStreetMap contributors © 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
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 705 |
-
const lon = Number(item?.lon)
|
| 706 |
const weight = Number(item?.weight)
|
| 707 |
-
if (!
|
| 708 |
-
if (Number.isFinite(weight)) return [
|
| 709 |
-
return
|
| 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 |
-
|
| 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 |
? '© OpenStreetMap contributors'
|
| 1046 |
: '© OpenStreetMap contributors © 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 |
-
|
| 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:
|
| 128 |
-
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) =>
|
| 134 |
}
|
| 135 |
|
| 136 |
function getResumoEspacialValor(resumo, criterio) {
|
|
@@ -187,8 +211,8 @@ function createLocalizacaoEntry(resolvida, id) {
|
|
| 187 |
return {
|
| 188 |
...resolvida,
|
| 189 |
id,
|
| 190 |
-
lat:
|
| 191 |
-
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:
|
| 1073 |
-
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) =>
|
| 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(
|
| 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 =
|
| 1304 |
-
const lon =
|
| 1305 |
-
if (
|
| 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 =
|
| 1317 |
-
const lon =
|
| 1318 |
-
const possuiAvaliando =
|
| 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:
|
| 1767 |
-
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 &&
|
| 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;
|