mesa-react / backend /app /core /map_layers.py
Guilherme Silberfarb Costa
correcao de mapas, linkagem da pesquisa e avaliacao
03a7ca2
from __future__ import annotations
import json
from html import escape
from pathlib import Path
from threading import Lock
from typing import Any
import folium
from branca.element import Element
_BASE_DIR = Path(__file__).resolve().parent
_BAIRROS_SHP_PATH = _BASE_DIR / "dados" / "Bairros_LC12112_16.shp"
_TOOLTIP_FIELDS = ("NOME", "BAIRRO", "NME_BAI", "NOME_BAIRRO")
_SIMPLIFY_TOLERANCE = 0.00005
_BAIRROS_CACHE_LOCK = Lock()
_BAIRROS_GEOJSON_CACHE: dict[str, Any] | None = None
_BAIRROS_GEOJSON_CARREGADO = False
def _carregar_bairros_geojson() -> dict[str, Any] | None:
global _BAIRROS_GEOJSON_CACHE, _BAIRROS_GEOJSON_CARREGADO
with _BAIRROS_CACHE_LOCK:
if _BAIRROS_GEOJSON_CARREGADO:
return _BAIRROS_GEOJSON_CACHE
_BAIRROS_GEOJSON_CARREGADO = True
if not _BAIRROS_SHP_PATH.exists():
return None
try:
import geopandas as gpd
except Exception:
return None
try:
gdf = gpd.read_file(_BAIRROS_SHP_PATH, engine="fiona")
if gdf is None or gdf.empty:
geojson = None
else:
if gdf.crs is not None:
gdf = gdf.to_crs("EPSG:4326")
campos = ["geometry"]
for campo in _TOOLTIP_FIELDS:
if campo in gdf.columns:
campos.insert(0, campo)
break
gdf = gdf.loc[:, campos].copy()
if _SIMPLIFY_TOLERANCE > 0:
gdf["geometry"] = gdf.geometry.simplify(_SIMPLIFY_TOLERANCE, preserve_topology=True)
geojson = json.loads(gdf.to_json(drop_id=True))
except Exception:
geojson = None
with _BAIRROS_CACHE_LOCK:
_BAIRROS_GEOJSON_CACHE = geojson
return geojson
def add_bairros_layer(
mapa: folium.Map,
*,
show: bool = True,
layer_name: str = "Bairros",
) -> bool:
geojson = _carregar_bairros_geojson()
if not geojson:
return False
tooltip = None
features = geojson.get("features") if isinstance(geojson, dict) else None
if isinstance(features, list) and features:
props = features[0].get("properties") if isinstance(features[0], dict) else None
if isinstance(props, dict):
for candidate in _TOOLTIP_FIELDS:
if candidate in props:
tooltip = folium.GeoJsonTooltip(
fields=[candidate],
aliases=["Bairro:"],
localize=False,
sticky=False,
labels=True,
)
break
folium.GeoJson(
data=geojson,
name=layer_name,
show=show,
control=True,
overlay=True,
smooth_factor=0.6,
style_function=lambda _: {
"color": "#4c6882",
"weight": 1.0,
"fillColor": "#f39c12",
"fillOpacity": 0.04,
},
highlight_function=lambda _: {
"color": "#e67e22",
"weight": 1.6,
"fillOpacity": 0.12,
},
tooltip=tooltip,
).add_to(mapa)
return True
def add_indice_marker(
camada: folium.map.FeatureGroup,
*,
lat: float,
lon: float,
indice: Any,
) -> None:
texto = escape(str(indice))
html = (
'<div style="'
"transform: translate(10px, -14px);"
"display:inline-block;"
"background: rgba(255, 255, 255, 0.9);"
"border: 1px solid rgba(28, 45, 66, 0.45);"
"border-radius: 10px;"
"padding: 1px 6px;"
"font-size: 11px;"
"font-weight: 700;"
"line-height: 1.2;"
"color: #1f2f44;"
"white-space: nowrap;"
"box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);"
"pointer-events: none;"
f'">{texto}</div>'
)
folium.Marker(
location=[float(lat), float(lon)],
icon=folium.DivIcon(
html=html,
icon_size=(72, 24),
icon_anchor=(0, 0),
class_name="mesa-indice-label",
),
).add_to(camada)
def add_zoom_responsive_circle_markers(
mapa: folium.Map,
*,
min_radius: float = 1.6,
max_radius: float = 52.0,
reference_zoom: float = 12.0,
growth_factor: float = 0.20,
) -> None:
if getattr(mapa, "_mesa_zoom_radius_script", False):
return
map_name = mapa.get_name()
script = f"""
<script>
(function() {{
const MAP_NAME = "{map_name}";
const MIN_RADIUS = {float(min_radius):.6f};
const MAX_RADIUS = {float(max_radius):.6f};
const REF_ZOOM = {float(reference_zoom):.6f};
const GROWTH = {float(growth_factor):.6f};
function clamp(v, lo, hi) {{
return Math.max(lo, Math.min(hi, v));
}}
function visitLayers(layer, visited, fn) {{
if (!layer || !layer._leaflet_id || visited.has(layer._leaflet_id)) return;
visited.add(layer._leaflet_id);
fn(layer);
if (layer.eachLayer) {{
layer.eachLayer(function(child) {{
visitLayers(child, visited, fn);
}});
}}
}}
function applyRadius(map) {{
const zoom = map.getZoom ? map.getZoom() : REF_ZOOM;
const zoomDelta = zoom - REF_ZOOM;
const expFactor = Math.pow(2, zoomDelta * GROWTH);
let floorScale = 1.0;
if (zoomDelta >= 0) {{
floorScale = 1 + zoomDelta * 0.22;
if (zoom >= 15) {{
floorScale += (zoom - 14) * 0.30;
}}
}} else {{
floorScale = Math.max(0.28, 1 + zoomDelta * 0.20);
}}
const seen = new Set();
map.eachLayer(function(layer) {{
visitLayers(layer, seen, function(item) {{
if (!(item instanceof L.CircleMarker)) return;
const baseRaw = Number(
item.options.mesaBaseRadius ??
item.options.baseRadius ??
item.options.radius ??
4
);
const base = Number.isFinite(baseRaw) && baseRaw > 0 ? baseRaw : 4;
item.options.mesaBaseRadius = base;
const dynamicMin = Math.max(MIN_RADIUS, base * floorScale);
const dynamicMax = Math.max(dynamicMin + 0.1, Math.min(MAX_RADIUS, base * 8.0));
const radius = clamp(base * expFactor, dynamicMin, dynamicMax);
item.setRadius(radius);
}});
}});
}}
function bindWhenMapReady() {{
const map = window[MAP_NAME];
if (!map) {{
setTimeout(bindWhenMapReady, 50);
return;
}}
if (map.__mesaRadiusBound) return;
map.__mesaRadiusBound = true;
map.on('zoomend', function() {{ applyRadius(map); }});
map.on('overlayadd', function() {{ setTimeout(function() {{ applyRadius(map); }}, 10); }});
map.on('overlayremove', function() {{ setTimeout(function() {{ applyRadius(map); }}, 10); }});
map.whenReady(function() {{ setTimeout(function() {{ applyRadius(map); }}, 10); }});
setTimeout(function() {{ applyRadius(map); }}, 30);
}}
bindWhenMapReady();
}})();
</script>
"""
mapa.get_root().html.add_child(Element(script))
setattr(mapa, "_mesa_zoom_radius_script", True)
def add_popup_pagination_handlers(mapa: folium.Map) -> None:
if getattr(mapa, "_mesa_popup_pager_script", False):
return
map_name = mapa.get_name()
script = f"""
<script>
(function() {{
const MAP_NAME = "{map_name}";
function getCssGapPx(el, fallback) {{
if (!el || !window.getComputedStyle) return fallback;
const style = window.getComputedStyle(el);
const raw = style.columnGap || style.gap || '';
const value = parseFloat(raw);
return Number.isFinite(value) ? value : fallback;
}}
function buildNumberButton() {{
const btn = document.createElement('button');
btn.type = 'button';
btn.dataset.pageNumber = '1';
btn.style.border = '1px solid #ced8e2';
btn.style.background = '#fff';
btn.style.borderRadius = '6px';
btn.style.padding = '2px 7px';
btn.style.fontSize = '11px';
btn.style.cursor = 'pointer';
btn.style.color = '#4e6479';
btn.style.display = 'inline-flex';
btn.style.alignItems = 'center';
btn.style.justifyContent = 'center';
btn.textContent = '1';
return btn;
}}
function ensureNumberSlots(numberWrap, count) {{
if (!numberWrap) return;
let target = Number.isFinite(count) ? count : 1;
if (target < 1) target = 1;
while (numberWrap.children.length < target) {{
numberWrap.appendChild(buildNumberButton());
}}
while (numberWrap.children.length > target) {{
numberWrap.removeChild(numberWrap.lastElementChild);
}}
}}
function measureNumberButtonWidth(numberWrap) {{
if (!numberWrap) return 30;
const probe = buildNumberButton();
probe.style.visibility = 'hidden';
probe.style.position = 'absolute';
probe.style.pointerEvents = 'none';
numberWrap.appendChild(probe);
const width = probe.getBoundingClientRect().width || 30;
numberWrap.removeChild(probe);
return Math.max(24, width);
}}
function resolveWindowSize(root, total) {{
if (!(total > 0)) return 1;
const controls = root.querySelector('.mesa-popup-controls');
const numberWrap = root.querySelector('[data-page-number-wrap]');
if (!controls || !numberWrap) return Math.max(1, total);
const controlsWidth = controls.getBoundingClientRect().width || root.getBoundingClientRect().width || 0;
if (!(controlsWidth > 0)) return Math.max(1, total);
const controlsGap = getCssGapPx(controls, 5);
const numberGap = getCssGapPx(numberWrap, 5);
let navWidth = 0;
controls.querySelectorAll('[data-page-nav]').forEach(function(btn) {{
navWidth += btn.getBoundingClientRect().width || 28;
}});
const numberButtonWidth = measureNumberButtonWidth(numberWrap);
const navCount = controls.querySelectorAll('[data-page-nav]').length;
const estimatedGaps = Math.max(0, navCount + 2) * controlsGap;
let available = controlsWidth - navWidth - estimatedGaps;
if (!(available > 0)) {{
available = controlsWidth - navWidth;
}}
let fit = Math.floor((available + numberGap) / (numberButtonWidth + numberGap));
if (!Number.isFinite(fit) || fit < 1) fit = 1;
if (fit > total) fit = total;
ensureNumberSlots(numberWrap, fit);
return fit;
}}
function updatePager(root, targetPage) {{
if (!root) return;
const pages = root.querySelectorAll('.mesa-popup-page');
const total = pages.length;
if (!total) return;
let windowSize = resolveWindowSize(root, total);
if (!Number.isFinite(windowSize) || windowSize < 1) windowSize = 1;
if (windowSize > total) windowSize = total;
root.dataset.pageWindow = String(windowSize);
const currentRaw = parseInt(root.dataset.currentPage || '1', 10);
let current = Number.isFinite(currentRaw) && currentRaw > 0 ? currentRaw : 1;
let next = Number.isFinite(targetPage) ? targetPage : current;
if (next < 1) next = 1;
if (next > total) next = total;
current = next;
let start = parseInt(root.dataset.pageStart || '1', 10);
if (!Number.isFinite(start) || start < 1) start = 1;
if (total <= windowSize) {{
start = 1;
}} else {{
if (current < start) start = current;
if (current > start + windowSize - 1) start = current - windowSize + 1;
const maxStart = total - windowSize + 1;
if (start > maxStart) start = maxStart;
if (start < 1) start = 1;
}}
root.dataset.currentPage = String(current);
root.dataset.pageStart = String(start);
pages.forEach(function(pageEl, idx) {{
pageEl.style.display = idx + 1 === current ? 'block' : 'none';
}});
const numberWrap = root.querySelector('[data-page-number-wrap]');
const numberButtons = numberWrap ? numberWrap.querySelectorAll('[data-page-number]') : [];
numberButtons.forEach(function(buttonEl, idx) {{
const value = start + idx;
if (value <= total) {{
buttonEl.style.display = 'inline-flex';
buttonEl.dataset.pageValue = String(value);
buttonEl.textContent = String(value);
buttonEl.style.background = value === current ? '#eaf1f7' : '#fff';
buttonEl.style.borderColor = value === current ? '#9fb4c8' : '#ced8e2';
buttonEl.style.color = value === current ? '#2f4b66' : '#4e6479';
}} else {{
buttonEl.style.display = 'none';
}}
}});
const onFirst = current <= 1;
const onLast = current >= total;
const firstBtn = root.querySelector('[data-a="first"]');
const prevBtn = root.querySelector('[data-a="prev"]');
const nextBtn = root.querySelector('[data-a="next"]');
const lastBtn = root.querySelector('[data-a="last"]');
if (firstBtn) {{
firstBtn.disabled = onFirst;
firstBtn.style.opacity = onFirst ? '0.45' : '1';
firstBtn.style.cursor = onFirst ? 'not-allowed' : 'pointer';
}}
if (prevBtn) {{
prevBtn.disabled = onFirst;
prevBtn.style.opacity = onFirst ? '0.45' : '1';
prevBtn.style.cursor = onFirst ? 'not-allowed' : 'pointer';
}}
if (nextBtn) {{
nextBtn.disabled = onLast;
nextBtn.style.opacity = onLast ? '0.45' : '1';
nextBtn.style.cursor = onLast ? 'not-allowed' : 'pointer';
}}
if (lastBtn) {{
lastBtn.disabled = onLast;
lastBtn.style.opacity = onLast ? '0.45' : '1';
lastBtn.style.cursor = onLast ? 'not-allowed' : 'pointer';
}}
}}
function handleClick(event) {{
const target = event && event.target ? event.target : null;
if (!target || !target.closest) return;
const button = target.closest('[data-page-number], [data-page-nav]');
if (!button) return;
const root = button.closest('[data-pager]');
if (!root) return;
event.preventDefault();
event.stopPropagation();
if (button.disabled) return;
const action = button.dataset.a || button.dataset.pageValue || '';
const currentRaw = parseInt(root.dataset.currentPage || '1', 10);
const current = Number.isFinite(currentRaw) && currentRaw > 0 ? currentRaw : 1;
const total = root.querySelectorAll('.mesa-popup-page').length;
if (!total) return;
let nextPage = current;
if (action === 'first') nextPage = 1;
else if (action === 'prev') nextPage = current - 1;
else if (action === 'next') nextPage = current + 1;
else if (action === 'last') nextPage = total;
else {{
const parsed = parseInt(action, 10);
if (Number.isFinite(parsed)) nextPage = parsed;
}}
updatePager(root, nextPage);
}}
function bindWhenMapReady() {{
const map = window[MAP_NAME];
if (!map) {{
setTimeout(bindWhenMapReady, 50);
return;
}}
if (map.__mesaPopupPagerBound) return;
map.__mesaPopupPagerBound = true;
const mapContainer = typeof map.getContainer === 'function' ? map.getContainer() : null;
const doc = mapContainer && mapContainer.ownerDocument ? mapContainer.ownerDocument : document;
doc.addEventListener('click', handleClick, true);
map.on('popupopen', function(evt) {{
const popup = evt && evt.popup ? evt.popup : null;
const contentNode = popup && popup._contentNode ? popup._contentNode : null;
if (!contentNode || !contentNode.querySelector) return;
const root = contentNode.querySelector('[data-pager]');
if (!root) return;
const initialRaw = parseInt(root.dataset.currentPage || '1', 10);
const initialPage = Number.isFinite(initialRaw) && initialRaw > 0 ? initialRaw : 1;
updatePager(root, initialPage);
}});
let resizeTimer = null;
window.addEventListener('resize', function() {{
if (resizeTimer) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(function() {{
const pagers = doc.querySelectorAll('[data-pager]');
pagers.forEach(function(root) {{
const currentRaw = parseInt(root.dataset.currentPage || '1', 10);
const current = Number.isFinite(currentRaw) && currentRaw > 0 ? currentRaw : 1;
updatePager(root, current);
}});
}}, 70);
}});
}}
bindWhenMapReady();
}})();
</script>
"""
mapa.get_root().html.add_child(Element(script))
setattr(mapa, "_mesa_popup_pager_script", True)