Spaces:
Building
Building
| 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) | |