import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' import L from 'leaflet' import 'leaflet.fullscreen' import 'leaflet.heat' import { apiUrl, downloadBlob, getAuthToken } from '../api' let bairrosGeojsonCache = null let bairrosGeojsonPromise = null function escapeHtml(value) { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') } function buildTooltipHtml(tooltip) { const title = String(tooltip?.title || '').trim() const label = String(tooltip?.label || '').trim() const value = String(tooltip?.value || '').trim() return [ '
', title ? `${escapeHtml(title)}` : '', label && value ? `
${escapeHtml(label)}: ${escapeHtml(value)}` : '', '
', ].join('') } function buildPopupErrorHtml(message) { const text = escapeHtml(message || 'Falha ao carregar os dados do registro.') return ( '
' + '
Dados do Registro
' + `
${text}
` + '
' ) } function parseFiniteCoordinate(value, min, max) { if (value === null || value === undefined) return null const text = String(value).trim().replace(',', '.') if (!text) return null const parsed = Number(text) if (!Number.isFinite(parsed)) return null if (parsed < min || parsed > max) return null return parsed } function parseLatLonPair(rawLat, rawLon) { const lat = parseFiniteCoordinate(rawLat, -90, 90) const lon = parseFiniteCoordinate(rawLon, -180, 180) if (lat === null || lon === null) return null return [lat, lon] } function ensurePopupPager(root) { if (!root || root.dataset.mesaPagerBound === '1') return root.dataset.mesaPagerBound = '1' const pages = Array.from(root.querySelectorAll('.mesa-popup-page')) if (!pages.length) return const numberWrap = root.querySelector('[data-page-number-wrap]') if (numberWrap) { numberWrap.innerHTML = '' pages.forEach((_, index) => { const button = document.createElement('button') button.type = 'button' button.dataset.pageNumber = String(index + 1) button.textContent = String(index + 1) button.style.border = '1px solid #ced8e2' button.style.background = '#fff' button.style.borderRadius = '6px' button.style.padding = '2px 7px' button.style.fontSize = '11px' button.style.cursor = 'pointer' button.style.color = '#4e6479' button.style.display = 'inline-flex' button.style.alignItems = 'center' button.style.justifyContent = 'center' numberWrap.appendChild(button) }) } function updatePager(targetPage) { const total = pages.length let current = Number.parseInt(String(targetPage || root.dataset.currentPage || '1'), 10) if (!Number.isFinite(current) || current < 1) current = 1 if (current > total) current = total root.dataset.currentPage = String(current) pages.forEach((pageEl, index) => { pageEl.style.display = index + 1 === current ? 'block' : 'none' }) Array.from(root.querySelectorAll('[data-page-number]')).forEach((button) => { const value = Number.parseInt(String(button.dataset.pageNumber || '1'), 10) const ativo = value === current button.style.background = ativo ? '#eaf1f7' : '#fff' button.style.borderColor = ativo ? '#9fb4c8' : '#ced8e2' button.style.color = ativo ? '#2f4b66' : '#4e6479' }) const onFirst = current <= 1 const onLast = current >= total const navState = [ ['first', onFirst], ['prev', onFirst], ['next', onLast], ['last', onLast], ] navState.forEach(([action, disabled]) => { const button = root.querySelector(`[data-a="${action}"]`) if (!button) return button.disabled = disabled button.style.opacity = disabled ? '0.45' : '1' button.style.cursor = disabled ? 'not-allowed' : 'pointer' }) } root.addEventListener('click', (event) => { const button = event.target.closest('[data-a], [data-page-number]') if (!button) return event.preventDefault() event.stopPropagation() const current = Number.parseInt(String(root.dataset.currentPage || '1'), 10) || 1 if (button.dataset.pageNumber) { updatePager(Number.parseInt(String(button.dataset.pageNumber), 10) || current) return } const action = String(button.dataset.a || '').trim() if (action === 'first') updatePager(1) if (action === 'prev') updatePager(current - 1) if (action === 'next') updatePager(current + 1) if (action === 'last') updatePager(pages.length) }) updatePager(1) } async function carregarBairrosGeojson(url) { if (bairrosGeojsonCache) return bairrosGeojsonCache if (bairrosGeojsonPromise) return bairrosGeojsonPromise const endpoint = String(url || '').trim() if (!endpoint) return null bairrosGeojsonPromise = fetch(endpoint, { headers: (() => { const token = getAuthToken() return token ? { 'X-Auth-Token': token } : {} })(), }) .then(async (response) => { if (!response.ok) { throw new Error('Falha ao carregar camada de bairros.') } return response.json() }) .then((payload) => { bairrosGeojsonCache = payload return payload }) .finally(() => { bairrosGeojsonPromise = null }) return bairrosGeojsonPromise } function construirLegendaControl(legend) { if (!legend) return null return L.control({ position: 'bottomright' }) } function formatLegendNumber(value) { const number = Number(value) if (!Number.isFinite(number)) return '' return number.toLocaleString('pt-BR', { maximumFractionDigits: 2 }) } function buildLegendHtml(legend) { const title = escapeHtml(String(legend?.title || '').trim()) const colors = Array.isArray(legend?.colors) ? legend.colors.filter(Boolean) : [] const gradient = colors.length ? colors.join(', ') : '#2ecc71, #e74c3c' const tickValues = Array.isArray(legend?.tick_values) ? legend.tick_values : [] const tickLabels = Array.isArray(legend?.tick_labels) ? legend.tick_labels : [] const hasTicks = tickValues.length > 1 && tickValues.length === tickLabels.length const ticksHtml = hasTicks ? ( '
' + tickValues.map((tickValue, index) => { const rawMin = Number(legend?.vmin) const rawMax = Number(legend?.vmax) const ratio = Number.isFinite(rawMin) && Number.isFinite(rawMax) && rawMax > rawMin ? (Number(tickValue) - rawMin) / (rawMax - rawMin) : index / Math.max(1, tickValues.length - 1) const left = Math.max(0, Math.min(100, ratio * 100)) return ( `` + `${escapeHtml(String(tickLabels[index] || ''))}` + '' ) }).join('') + '
' ) : '' const scaleHtml = hasTicks ? '' : ( '
' + `${escapeHtml(formatLegendNumber(legend?.vmin))}` + `${escapeHtml(formatLegendNumber(legend?.vmax))}` + '
' ) return [ title ? `${title}` : '', `
`, ticksHtml, scaleHtml, ].join('') } function addNoticeControl(map, notice) { const message = String(notice?.message || '').trim() if (!message) return null const control = L.control({ position: notice?.position || 'topright' }) control.onAdd = () => { const div = L.DomUtil.create('div', 'mesa-leaflet-notice') div.innerHTML = escapeHtml(message) return div } control.addTo(map) return control } function buildIndiceBadgeHtml(indice) { return ( '
' + `${escapeHtml(indice)}` + '
' ) } function formatCoordinate(value, positiveLabel, negativeLabel) { const numeric = Number(value) if (!Number.isFinite(numeric)) return '-' const absolute = Math.abs(numeric) const degrees = Math.floor(absolute) const minutesFloat = (absolute - degrees) * 60 const minutes = Math.floor(minutesFloat) const seconds = ((minutesFloat - minutes) * 60).toFixed(2) const direction = numeric >= 0 ? positiveLabel : negativeLabel return `${degrees}° ${minutes.toString().padStart(2, '0')}' ${seconds.padStart(5, '0')}" ${direction}` } function formatLatLng(latlng) { if (!latlng) return '' return `${formatCoordinate(latlng.lat, 'N', 'S')} / ${formatCoordinate(latlng.lng, 'L', 'W')}` } function formatDecimalLatLng(latlng) { if (!latlng) return '' return `${Number(latlng.lat).toFixed(6)} / ${Number(latlng.lng).toFixed(6)}` } function formatDistance(distanceMeters) { const value = Number(distanceMeters) if (!Number.isFinite(value) || value <= 0) return '0 m' if (value >= 1000) { return `${(value / 1000).toLocaleString('pt-BR', { maximumFractionDigits: 2 })} km` } return `${value.toLocaleString('pt-BR', { maximumFractionDigits: 1 })} m` } function computePolygonArea(latlngs) { if (!Array.isArray(latlngs) || latlngs.length < 3) return 0 const d2r = Math.PI / 180 const radius = 6378137 let area = 0 for (let index = 0; index < latlngs.length; index += 1) { const current = latlngs[index] const next = latlngs[(index + 1) % latlngs.length] area += ( (Number(next.lng) - Number(current.lng)) * d2r * (2 + Math.sin(Number(current.lat) * d2r) + Math.sin(Number(next.lat) * d2r)) ) } return Math.abs(area * radius * radius / 2) } function formatArea(areaSquareMeters) { const value = Number(areaSquareMeters) if (!Number.isFinite(value) || value <= 0) return '' if (value >= 10000) { return `${(value / 10000).toLocaleString('pt-BR', { maximumFractionDigits: 2 })} ha` } return `${value.toLocaleString('pt-BR', { maximumFractionDigits: 0 })} m²` } function buildLegacyOverlayLayers(payload) { const overlays = [] if (payload?.show_bairros) { overlays.push({ id: 'bairros', label: 'Bairros', show: true, geojson_url: payload?.bairros_geojson_url || '', geojson_style: { color: '#4c6882', weight: 1.15, opacity: 0.88, fillColor: '#f39c12', fillOpacity: 0, }, geojson_tooltip_properties: ['NOME', 'BAIRRO', 'NME_BAI', 'NOME_BAIRRO'], geojson_tooltip_label: 'Bairro', }) } if (Array.isArray(payload?.market_points) && payload.market_points.length) { overlays.push({ id: 'mercado', label: 'Mercado', show: true, points: payload.market_points.map((item) => ({ ...item, popup_request: Number.isFinite(Number(item?.row_id)) ? { kind: 'visualizacao_row', row_id: Number(item.row_id) } : null, })), }) overlays.push({ id: 'indices', label: 'Índices', show: Boolean(payload?.show_indices), markers: payload.market_points.map((item) => ({ lat: Number(item?.lat), lon: Number(item?.lon), marker_html: buildIndiceBadgeHtml(item?.indice), icon_size: [72, 24], icon_anchor: [0, 0], class_name: 'mesa-indice-label', interactive: false, keyboard: false, })), }) } if (Array.isArray(payload?.trabalhos_tecnicos_points) && payload.trabalhos_tecnicos_points.length) { overlays.push({ id: 'trabalhos-tecnicos', label: 'Trabalhos técnicos', show: true, markers: payload.trabalhos_tecnicos_points, }) } return overlays } async function readJsonSafely(response) { const raw = await response.text() if (!raw || !raw.trim()) return null try { return JSON.parse(raw) } catch { return null } } function waitForNextPaint() { return new Promise((resolve) => { window.requestAnimationFrame(() => { window.requestAnimationFrame(resolve) }) }) } function isDrawableVisible(rect, hostRect) { if (!rect || !hostRect) return false if (rect.width <= 0 || rect.height <= 0) return false return ( rect.right > hostRect.left && rect.left < hostRect.right && rect.bottom > hostRect.top && rect.top < hostRect.bottom ) } function parseBorderRadiusPx(element) { if (!element) return 0 const computed = window.getComputedStyle(element) const raw = String(computed.borderTopLeftRadius || computed.borderRadius || '0') const parsed = Number.parseFloat(raw) return Number.isFinite(parsed) ? Math.max(0, parsed) : 0 } function clipRoundedRect(ctx, width, height, radius) { const corner = Math.max(0, Math.min(radius, Math.min(width, height) / 2)) if (!corner) { ctx.beginPath() ctx.rect(0, 0, width, height) ctx.clip() return } ctx.beginPath() ctx.moveTo(corner, 0) ctx.lineTo(width - corner, 0) ctx.quadraticCurveTo(width, 0, width, corner) ctx.lineTo(width, height - corner) ctx.quadraticCurveTo(width, height, width - corner, height) ctx.lineTo(corner, height) ctx.quadraticCurveTo(0, height, 0, height - corner) ctx.lineTo(0, corner) ctx.quadraticCurveTo(0, 0, corner, 0) ctx.closePath() ctx.clip() } function collectVisibleMapDrawables(container, hostRect) { const drawables = [] let sequence = 0 const register = (element, kind) => { if (!element) return const rect = element.getBoundingClientRect() if (!isDrawableVisible(rect, hostRect)) return const pane = element.closest('.leaflet-pane') const paneZ = Number.parseInt(String(window.getComputedStyle(pane || element).zIndex || '0'), 10) drawables.push({ element, kind, rect, paneZ: Number.isFinite(paneZ) ? paneZ : 0, sequence, }) sequence += 1 } Array.from(container.querySelectorAll('.leaflet-tile-pane img.leaflet-tile')).forEach((element) => register(element, 'image')) Array.from(container.querySelectorAll('.leaflet-pane canvas')).forEach((element) => register(element, 'canvas')) Array.from(container.querySelectorAll('.leaflet-pane svg')).forEach((element) => register(element, 'svg')) drawables.sort((left, right) => { if (left.paneZ !== right.paneZ) return left.paneZ - right.paneZ return left.sequence - right.sequence }) return drawables } function serializeSvgElement(element, width, height) { const clone = element.cloneNode(true) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg') clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink') clone.setAttribute('width', String(width)) clone.setAttribute('height', String(height)) if (!clone.getAttribute('viewBox')) { clone.setAttribute('viewBox', `0 0 ${width} ${height}`) } return new XMLSerializer().serializeToString(clone) } function loadSvgImage(svgMarkup) { return new Promise((resolve, reject) => { const blob = new Blob([svgMarkup], { type: 'image/svg+xml;charset=utf-8' }) const objectUrl = URL.createObjectURL(blob) const image = new Image() image.decoding = 'async' image.onload = () => { URL.revokeObjectURL(objectUrl) resolve(image) } image.onerror = () => { URL.revokeObjectURL(objectUrl) reject(new Error('Nao foi possivel desenhar uma camada vetorial do mapa.')) } image.src = objectUrl }) } function canvasToBlob(canvas) { return new Promise((resolve, reject) => { try { canvas.toBlob((blob) => { if (blob) { resolve(blob) return } reject(new Error('Nao foi possivel gerar a imagem PNG do mapa.')) }, 'image/png') } catch (error) { reject(error) } }) } function clampNumber(value, min, max) { return Math.max(min, Math.min(max, value)) } function normalizeFitPadding(value, fallback = 48) { const numeric = Number(value) const resolved = Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback return [resolved, resolved] } function normalizeSelectionRect(selectionRect, containerWidth, containerHeight) { const rawLeft = Number(selectionRect?.left ?? selectionRect?.x) const rawTop = Number(selectionRect?.top ?? selectionRect?.y) const rawWidth = Number(selectionRect?.width) const rawHeight = Number(selectionRect?.height) if (![rawLeft, rawTop, rawWidth, rawHeight].every(Number.isFinite)) return null if (rawWidth <= 0 || rawHeight <= 0) return null const left = clampNumber(rawLeft, 0, containerWidth) const top = clampNumber(rawTop, 0, containerHeight) const right = clampNumber(rawLeft + rawWidth, 0, containerWidth) const bottom = clampNumber(rawTop + rawHeight, 0, containerHeight) const width = right - left const height = bottom - top if (width < 12 || height < 12) return null return { left, top, width, height, right, bottom, } } function intersectRectangles(leftRect, rightRect) { const left = Math.max(leftRect.left, rightRect.left) const top = Math.max(leftRect.top, rightRect.top) const right = Math.min(leftRect.right, rightRect.right) const bottom = Math.min(leftRect.bottom, rightRect.bottom) if (right <= left || bottom <= top) return null return { left, top, right, bottom, width: right - left, height: bottom - top, } } function resolveDrawableSourceSize(element, kind, drawWidth, drawHeight) { if (kind === 'image') { return { width: Number(element?.naturalWidth) || drawWidth, height: Number(element?.naturalHeight) || drawHeight, } } if (kind === 'canvas') { return { width: Number(element?.width) || drawWidth, height: Number(element?.height) || drawHeight, } } return { width: drawWidth, height: drawHeight } } function computeSelectionExportScale(selectionRect) { const longestEdge = Math.max(selectionRect?.width || 0, selectionRect?.height || 0, 1) const deviceScale = Number(window.devicePixelRatio) || 1 const preferredScale = Math.max(3, deviceScale * 2.5) const boundedScale = 3600 / longestEdge return Math.max(2, Math.min(5, preferredScale, boundedScale)) } function fitMapToPayloadBounds(map, payload, padding = 48) { if (!map) return false const bounds = Array.isArray(payload?.bounds) ? payload.bounds : null if (bounds && bounds.length === 2) { map.fitBounds(bounds, { padding: normalizeFitPadding(padding), maxZoom: 18, animate: false }) return true } if (Array.isArray(payload?.center) && payload.center.length === 2) { map.setView(payload.center, 12, { animate: false }) return true } return false } async function waitForTileLayerLoad(tileLayer, timeoutMs = 6500) { if (!tileLayer) return await new Promise((resolve) => { let settled = false const finalize = () => { if (settled) return settled = true window.clearTimeout(timerId) tileLayer.off('load', finalize) resolve() } const timerId = window.setTimeout(finalize, timeoutMs) tileLayer.on('load', finalize) if (!tileLayer._loading) { window.setTimeout(finalize, 180) } }) } function resolveActiveTileLayerConfig(sourceMap, payload) { const definitions = Array.isArray(payload?.tile_layers) ? payload.tile_layers : [] if (!sourceMap || !definitions.length) return definitions[0] || null const activeUrls = new Set() sourceMap.eachLayer((layer) => { if (layer instanceof L.TileLayer && sourceMap.hasLayer(layer)) { const url = String(layer?._url || '').trim() if (url) activeUrls.add(url) } }) return definitions.find((item) => activeUrls.has(String(item?.url || '').trim())) || definitions[0] || null } async function buildHighResolutionSelectionBlob({ sourceMap, sourceContainer, payload, selectionRect, }) { if (!sourceMap || !sourceContainer) { throw new Error('Mapa indisponivel para exportacao.') } const hostRect = sourceContainer.getBoundingClientRect() const containerWidth = Math.round(hostRect.width) const containerHeight = Math.round(hostRect.height) const normalizedSelection = normalizeSelectionRect(selectionRect, containerWidth, containerHeight) if (!normalizedSelection) { throw new Error('Selecione um retangulo valido no mapa antes de baixar.') } const exportScale = computeSelectionExportScale(normalizedSelection) const exportWidth = Math.max(480, Math.round(normalizedSelection.width * exportScale)) const exportHeight = Math.max(480, Math.round(normalizedSelection.height * exportScale)) const selectionCenter = sourceMap.containerPointToLatLng([ normalizedSelection.left + (normalizedSelection.width / 2), normalizedSelection.top + (normalizedSelection.height / 2), ]) const sourceZoom = Number(sourceMap.getZoom?.() ?? 0) const exportZoom = sourceZoom + Math.log2(exportScale) const mount = document.createElement('div') mount.style.position = 'fixed' mount.style.left = '-20000px' mount.style.top = '0' mount.style.width = `${exportWidth}px` mount.style.height = `${exportHeight}px` mount.style.opacity = '0' mount.style.pointerEvents = 'none' mount.style.background = '#ffffff' mount.style.overflow = 'hidden' document.body.appendChild(mount) let exportMap = null try { exportMap = L.map(mount, { zoomControl: false, attributionControl: false, preferCanvas: true, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false, tapHold: false, }) const bairrosPane = exportMap.createPane('mesa-bairros-pane') const marketPane = exportMap.createPane('mesa-market-pane') const trabalhosPane = exportMap.createPane('mesa-trabalhos-pane') const indicesPane = exportMap.createPane('mesa-indices-pane') bairrosPane.style.zIndex = '410' marketPane.style.zIndex = '420' trabalhosPane.style.zIndex = '430' indicesPane.style.zIndex = '440' const canvasRenderer = L.canvas({ padding: 0.5, pane: 'mesa-market-pane' }) const overlaySpecs = Array.isArray(payload?.overlay_layers) && payload.overlay_layers.length ? payload.overlay_layers : buildLegacyOverlayLayers(payload) const responsivePointContainers = [] const radiusBehavior = payload?.radius_behavior || {} const minRadius = Number(radiusBehavior.min_radius) || 1.6 const maxRadius = Number(radiusBehavior.max_radius) || 52.0 const referenceZoom = Number(radiusBehavior.reference_zoom) || 12.0 const growthFactor = Number(radiusBehavior.growth_factor) || 0.2 const activeTileLayerConfig = resolveActiveTileLayerConfig(sourceMap, payload) let tileLayer = null if (activeTileLayerConfig?.url) { tileLayer = L.tileLayer(String(activeTileLayerConfig.url || ''), { attribution: String(activeTileLayerConfig?.attribution || ''), crossOrigin: 'anonymous', detectRetina: true, }).addTo(exportMap) } const applyResponsiveRadius = () => { const zoom = typeof exportMap.getZoom === 'function' ? exportMap.getZoom() : referenceZoom const zoomDelta = zoom - referenceZoom const expFactor = 2 ** (zoomDelta * growthFactor) 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) } responsivePointContainers.forEach((container) => { if (!container || typeof container.eachLayer !== 'function') return container.eachLayer((layer) => { if (typeof layer.setRadius !== 'function') return const base = Number(layer.options?.mesaBaseRadius || layer.options?.radius || 4) const dynamicMin = Math.max(minRadius, base * floorScale) const dynamicMax = Math.max(dynamicMin + 0.1, Math.min(maxRadius, base * 8.0)) layer.setRadius(clampNumber(base * expFactor, dynamicMin, dynamicMax)) }) }) } const addPointMarkers = (layerGroup, items) => { if (!Array.isArray(items) || !items.length) return responsivePointContainers.push(layerGroup) items.forEach((item) => { const latlng = parseLatLonPair(item?.lat, item?.lon) if (!latlng) return const marker = L.circleMarker(latlng, { renderer: canvasRenderer, pane: String(item?.pane || 'mesa-market-pane'), radius: Number(item?.base_radius) || 4, color: String(item?.stroke_color || '#000000'), weight: Number.isFinite(Number(item?.stroke_weight)) ? Number(item.stroke_weight) : (Number(item?.base_radius) > 4 ? 1 : 0.8), fill: item?.fill !== false, fillColor: String(item?.color || item?.fill_color || '#FF8C00'), fillOpacity: Number.isFinite(Number(item?.fill_opacity)) ? Number(item.fill_opacity) : 0.68, interactive: false, bubblingMouseEvents: false, }) marker.options.mesaBaseRadius = Number(item?.base_radius) || 4 layerGroup.addLayer(marker) }) } const addShapeOverlays = (layerGroup, shapes) => { if (!Array.isArray(shapes) || !shapes.length) return shapes.forEach((shape) => { const shapeType = String(shape?.type || shape?.shape_type || '').trim().toLowerCase() const style = { color: String(shape?.color || '#1f6fb2'), weight: Number.isFinite(Number(shape?.weight)) ? Number(shape.weight) : 2, opacity: Number.isFinite(Number(shape?.opacity)) ? Number(shape.opacity) : 0.8, fill: shape?.fill === true, fillColor: String(shape?.fill_color || shape?.color || '#1f6fb2'), fillOpacity: Number.isFinite(Number(shape?.fill_opacity)) ? Number(shape.fill_opacity) : 0.12, dashArray: shape?.dash_array ? String(shape.dash_array) : undefined, pane: String(shape?.pane || 'mesa-bairros-pane'), interactive: false, } let layer = null if (shapeType === 'circle' && Array.isArray(shape?.center) && shape.center.length === 2) { const center = parseLatLonPair(shape.center[0], shape.center[1]) if (center) { layer = L.circle(center, { ...style, radius: Number(shape?.radius_m) || 0 }) } } else if (shapeType === 'polyline' && Array.isArray(shape?.coords) && shape.coords.length) { layer = L.polyline(shape.coords, style) } else if (shapeType === 'polygon' && Array.isArray(shape?.coords) && shape.coords.length) { layer = L.polygon(shape.coords, style) } else if (shapeType === 'circlemarker' && Array.isArray(shape?.center) && shape.center.length === 2) { const center = parseLatLonPair(shape.center[0], shape.center[1]) if (center) { layer = L.circleMarker(center, { ...style, radius: Number(shape?.radius) || 6 }) } } if (layer) { layerGroup.addLayer(layer) } }) } const addGeoJsonOverlay = async (layerGroup, spec) => { const geojsonLayer = L.geoJSON(null, { pane: String(spec?.geojson_pane || 'mesa-bairros-pane'), style: () => ({ color: String(spec?.geojson_style?.color || '#4c6882'), weight: Number.isFinite(Number(spec?.geojson_style?.weight)) ? Number(spec.geojson_style.weight) : 1.0, opacity: Number.isFinite(Number(spec?.geojson_style?.opacity)) ? Number(spec.geojson_style.opacity) : 0.88, fillColor: String(spec?.geojson_style?.fillColor || '#f39c12'), fillOpacity: Number.isFinite(Number(spec?.geojson_style?.fillOpacity)) ? Number(spec.geojson_style.fillOpacity) : 0.04, interactive: false, }), }) layerGroup.addLayer(geojsonLayer) if (spec?.geojson_data) { geojsonLayer.addData(spec.geojson_data) return } if (!spec?.geojson_url) return try { const geojson = await carregarBairrosGeojson(spec.geojson_url) if (geojson) { geojsonLayer.addData(geojson) } } catch { // Camada opcional; manter o download funcional mesmo sem bairros. } } const addHeatmapOverlay = (layerGroup, heatmapSpec) => { if (!heatmapSpec || typeof L.heatLayer !== 'function') return const points = Array.isArray(heatmapSpec?.points) ? heatmapSpec.points .map((item) => { const latlng = parseLatLonPair(item?.lat, item?.lon) const weight = Number(item?.weight) if (!latlng) return null if (Number.isFinite(weight)) return [latlng[0], latlng[1], weight] return latlng }) .filter(Boolean) : [] if (!points.length) return const layer = L.heatLayer(points, { radius: Number(heatmapSpec?.radius) || 20, blur: Number(heatmapSpec?.blur) || 18, minOpacity: Number.isFinite(Number(heatmapSpec?.min_opacity)) ? Number(heatmapSpec.min_opacity) : 0.28, maxZoom: Number(heatmapSpec?.max_zoom) || 17, gradient: heatmapSpec?.gradient || undefined, }) layerGroup.addLayer(layer) } const overlayPromises = overlaySpecs.map(async (spec) => { if (spec?.show === false) return const layerGroup = L.layerGroup().addTo(exportMap) if (spec?.geojson_url || spec?.geojson_data) { await addGeoJsonOverlay(layerGroup, spec) } addHeatmapOverlay(layerGroup, spec?.heatmap) addShapeOverlays(layerGroup, spec?.shapes) addPointMarkers(layerGroup, spec?.points) }) exportMap.setView(selectionCenter, exportZoom, { animate: false }) applyResponsiveRadius() exportMap.on('zoomend', applyResponsiveRadius) await Promise.all(overlayPromises) await waitForTileLayerLoad(tileLayer) await waitForNextPaint() await waitForNextPaint() const blob = await captureContainerRegionBlob(mount, null, 1) exportMap.off('zoomend', applyResponsiveRadius) return blob } finally { if (exportMap) { exportMap.remove() } mount.remove() } } async function captureContainerRegionBlob(container, selectionRect = null, scaleOverride = null) { const hostRect = container.getBoundingClientRect() const containerWidth = Math.round(hostRect.width) const containerHeight = Math.round(hostRect.height) const normalizedSelection = selectionRect ? normalizeSelectionRect(selectionRect, containerWidth, containerHeight) : { left: 0, top: 0, width: containerWidth, height: containerHeight, right: containerWidth, bottom: containerHeight, } if (!normalizedSelection || normalizedSelection.width <= 0 || normalizedSelection.height <= 0) { throw new Error('Nao foi possivel montar a area de exportacao do mapa.') } const scale = Number.isFinite(Number(scaleOverride)) && Number(scaleOverride) > 0 ? Number(scaleOverride) : computeSelectionExportScale(normalizedSelection) const canvas = document.createElement('canvas') canvas.width = Math.max(1, Math.round(normalizedSelection.width * scale)) canvas.height = Math.max(1, Math.round(normalizedSelection.height * scale)) const ctx = canvas.getContext('2d') if (!ctx) { throw new Error('O navegador nao conseguiu montar o canvas de exportacao.') } ctx.scale(scale, scale) ctx.imageSmoothingEnabled = true ctx.imageSmoothingQuality = 'high' ctx.fillStyle = '#ffffff' ctx.fillRect(0, 0, normalizedSelection.width, normalizedSelection.height) const drawables = collectVisibleMapDrawables(container, hostRect) for (const drawable of drawables) { const { element, kind, rect } = drawable const drawableRect = { left: rect.left - hostRect.left, top: rect.top - hostRect.top, right: rect.right - hostRect.left, bottom: rect.bottom - hostRect.top, width: rect.width, height: rect.height, } const overlap = intersectRectangles(drawableRect, normalizedSelection) if (!overlap) continue const sourceWidth = Math.max(1, drawableRect.width) const sourceHeight = Math.max(1, drawableRect.height) const sourceSize = resolveDrawableSourceSize(element, kind, sourceWidth, sourceHeight) const ratioX = sourceSize.width / sourceWidth const ratioY = sourceSize.height / sourceHeight const sx = (overlap.left - drawableRect.left) * ratioX const sy = (overlap.top - drawableRect.top) * ratioY const sw = overlap.width * ratioX const sh = overlap.height * ratioY const dx = overlap.left - normalizedSelection.left const dy = overlap.top - normalizedSelection.top let source = element if (kind === 'svg') { const svgMarkup = serializeSvgElement(element, sourceWidth, sourceHeight) source = await loadSvgImage(svgMarkup) } ctx.drawImage(source, sx, sy, sw, sh, dx, dy, overlap.width, overlap.height) } return canvasToBlob(canvas) } const LeafletMapFrame = forwardRef(function LeafletMapFrame({ payload, sessionId }, ref) { const hostRef = useRef(null) const mapRef = useRef(null) const payloadRef = useRef(payload) const popupCacheRef = useRef(new Map()) const [runtimeError, setRuntimeError] = useState('') payloadRef.current = payload useImperativeHandle(ref, () => ({ fitToPayloadBounds(padding = 48) { const map = mapRef.current if (!map) return false return fitMapToPayloadBounds(map, payloadRef.current, padding) }, async downloadSelectionPng(selectionRect, fileName = 'mapa-recorte.png') { const container = hostRef.current const blob = await buildHighResolutionSelectionBlob({ sourceMap: mapRef.current, sourceContainer: container, payload: payloadRef.current, selectionRect, }) downloadBlob(blob, fileName) return true }, async downloadVisiblePng(fileName = 'mapa.png') { const container = hostRef.current if (!container) { throw new Error('Mapa indisponivel para exportacao.') } mapRef.current?.invalidateSize?.(false) await waitForNextPaint() const hostRect = container.getBoundingClientRect() const width = Math.round(hostRect.width) const height = Math.round(hostRect.height) if (width <= 0 || height <= 0) { throw new Error('O mapa ainda nao terminou de renderizar para exportacao.') } const deviceScale = Number(window.devicePixelRatio) || 1 const scale = Math.max(3, Math.min(5, deviceScale * 2)) const blob = await captureContainerRegionBlob(container, null, scale) downloadBlob(blob, fileName) return true }, }), []) useEffect(() => { if (!hostRef.current || !payload) return undefined let disposed = false setRuntimeError('') hostRef.current.innerHTML = '' const map = L.map(hostRef.current, { zoomControl: true, preferCanvas: true, }) mapRef.current = map let restoreMapInteractions = null const bairrosPane = map.createPane('mesa-bairros-pane') const marketPane = map.createPane('mesa-market-pane') const trabalhosPane = map.createPane('mesa-trabalhos-pane') const indicesPane = map.createPane('mesa-indices-pane') bairrosPane.style.zIndex = '410' marketPane.style.zIndex = '420' trabalhosPane.style.zIndex = '430' indicesPane.style.zIndex = '440' const canvasRenderer = L.canvas({ padding: 0.5, pane: 'mesa-market-pane' }) const baseLayers = {} const overlayLayers = {} const overlayGroupsById = new Map() const dependencyAwareOverlays = [] const hoverHighlightMetas = [] ;(payload.tile_layers || []).forEach((layerDef, index) => { const tileLayer = L.tileLayer(String(layerDef?.url || ''), { attribution: index === 0 ? '© OpenStreetMap contributors' : '© OpenStreetMap contributors © CARTO', crossOrigin: 'anonymous', detectRetina: true, }) const label = String(layerDef?.label || layerDef?.id || `Base ${index + 1}`) baseLayers[label] = tileLayer if (index === 0) { tileLayer.addTo(map) } }) const overlaySpecs = Array.isArray(payload.overlay_layers) && payload.overlay_layers.length ? payload.overlay_layers : buildLegacyOverlayLayers(payload) const responsivePointContainers = [] const radiusBehavior = payload.radius_behavior || {} const minRadius = Number(radiusBehavior.min_radius) || 1.6 const maxRadius = Number(radiusBehavior.max_radius) || 52.0 const referenceZoom = Number(radiusBehavior.reference_zoom) || 12.0 const growthFactor = Number(radiusBehavior.growth_factor) || 0.2 function clamp(value, min, max) { return Math.max(min, Math.min(max, value)) } function visitLayerTree(layer, visited, fn) { if (!layer || !layer._leaflet_id || visited.has(layer._leaflet_id)) return visited.add(layer._leaflet_id) fn(layer) if (typeof layer.eachLayer === 'function') { layer.eachLayer((child) => { visitLayerTree(child, visited, fn) }) } } function applyResponsiveRadius() { const zoom = typeof map.getZoom === 'function' ? map.getZoom() : referenceZoom const zoomDelta = zoom - referenceZoom const expFactor = 2 ** (zoomDelta * growthFactor) 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) } responsivePointContainers.forEach((container) => { if (!container || typeof container.eachLayer !== 'function') return container.eachLayer((layer) => { if (typeof layer.setRadius !== 'function') return const base = Number(layer.options?.mesaBaseRadius || layer.options?.radius || 4) const dynamicMin = Math.max(minRadius, base * floorScale) const dynamicMax = Math.max(dynamicMin + 0.1, Math.min(maxRadius, base * 8.0)) layer.setRadius(clamp(base * expFactor, dynamicMin, dynamicMax)) }) }) } async function carregarPopupVisualizacao(rowId, layer) { const cacheKey = String(rowId) const cached = popupCacheRef.current.get(cacheKey) if (cached) { const cachedHtml = typeof cached === 'string' ? cached : String(cached?.html || '') const cachedWidth = typeof cached === 'string' ? 420 : (Number(cached?.width) || 420) layer.unbindPopup() layer.bindPopup(cachedHtml, { maxWidth: cachedWidth }).openPopup() window.setTimeout(() => { const root = layer.getPopup()?.getElement()?.querySelector('[data-pager]') if (root) ensurePopupPager(root) }, 0) return } layer.bindPopup( '
Carregando detalhes...
', { maxWidth: 360 }, ).openPopup() try { const response = await fetch(apiUrl('/api/visualizacao/map/popup'), { method: 'POST', headers: { 'Content-Type': 'application/json', ...(getAuthToken() ? { 'X-Auth-Token': getAuthToken() } : {}), }, body: JSON.stringify({ session_id: sessionId, row_id: rowId, }), }) const payloadResp = await readJsonSafely(response) if (!response.ok) { throw new Error(String(payloadResp?.detail || 'Falha ao carregar os dados do registro.')) } if (!payloadResp || typeof payloadResp !== 'object') { throw new Error('Resposta vazia ao carregar os dados do registro.') } const popupHtml = String(payloadResp?.popup_html || '') const popupWidth = Number(payloadResp?.popup_width) || 420 if (!popupHtml.trim()) { throw new Error('Detalhes do registro indisponíveis para este ponto.') } popupCacheRef.current.set(cacheKey, { html: popupHtml, width: popupWidth }) layer.unbindPopup() layer.bindPopup(popupHtml, { maxWidth: popupWidth }).openPopup() window.setTimeout(() => { const root = layer.getPopup()?.getElement()?.querySelector('[data-pager]') if (root) ensurePopupPager(root) }, 0) } catch (error) { layer.unbindPopup() layer.bindPopup(buildPopupErrorHtml(error?.message || 'Falha ao carregar os dados do registro.'), { maxWidth: 360 }).openPopup() } } function bindPointPopup(marker, item) { const popupHtml = String(item?.popup_html || '').trim() if (popupHtml) { marker.bindPopup(popupHtml, { maxWidth: Number(item?.popup_max_width) || 420 }) return } const popupRequest = item?.popup_request if (popupRequest?.kind === 'visualizacao_row' && Number.isFinite(Number(popupRequest?.row_id)) && sessionId) { marker.on('click', () => { void carregarPopupVisualizacao(Number(popupRequest.row_id), marker) }) } } function rememberPathBaseStyle(layer) { if (!layer || !layer.options) return if (!Number.isFinite(layer.options.mesaBaseOpacity)) { layer.options.mesaBaseOpacity = Number.isFinite(layer.options.opacity) ? layer.options.opacity : 1 } if (!Number.isFinite(layer.options.mesaBaseFillOpacity)) { layer.options.mesaBaseFillOpacity = Number.isFinite(layer.options.fillOpacity) ? layer.options.fillOpacity : 0 } if (!Number.isFinite(layer.options.mesaBaseWeight)) { layer.options.mesaBaseWeight = Number.isFinite(layer.options.weight) ? layer.options.weight : 1 } if (!layer.options.mesaBaseColor && layer.options.color) { layer.options.mesaBaseColor = layer.options.color } if (!layer.options.mesaBaseFillColor && layer.options.fillColor) { layer.options.mesaBaseFillColor = layer.options.fillColor } } function rememberMarkerBaseStyle(layer) { if (!layer || !layer.options) return if (!Number.isFinite(layer.options.mesaBaseMarkerOpacity)) { layer.options.mesaBaseMarkerOpacity = Number.isFinite(layer.options.opacity) ? layer.options.opacity : 1 } if (!Number.isFinite(layer.options.mesaBaseFillOpacity)) { layer.options.mesaBaseFillOpacity = Number.isFinite(layer.options.fillOpacity) ? layer.options.fillOpacity : 0 } if (!Number.isFinite(layer.options.mesaBaseRadius) && typeof layer.getRadius === 'function') { const radius = layer.getRadius() if (Number.isFinite(radius) && radius > 0) { layer.options.mesaBaseRadius = radius } } } function applyVisualState(layer, state) { if (!layer) return const isPath = typeof layer.setStyle === 'function' if (isPath) { rememberPathBaseStyle(layer) const baseOpacity = Number.isFinite(layer.options.mesaBaseOpacity) ? layer.options.mesaBaseOpacity : 1 const baseFillOpacity = Number.isFinite(layer.options.mesaBaseFillOpacity) ? layer.options.mesaBaseFillOpacity : 0 const baseWeight = Number.isFinite(layer.options.mesaBaseWeight) ? layer.options.mesaBaseWeight : 1 let nextOpacity = baseOpacity let nextFillOpacity = baseFillOpacity let nextWeight = baseWeight if (state === 'dim') { nextOpacity = Math.max(0.06, baseOpacity * 0.14) nextFillOpacity = baseFillOpacity > 0 ? Math.max(0.02, baseFillOpacity * 0.18) : baseFillOpacity nextWeight = Math.max(1, baseWeight * 0.82) } else if (state === 'highlight') { nextOpacity = Math.max(baseOpacity, 0.98) nextFillOpacity = baseFillOpacity > 0 ? Math.max(baseFillOpacity, Math.min(0.24, (baseFillOpacity * 1.65) + 0.045)) : baseFillOpacity nextWeight = Math.max(baseWeight + 1.2, baseWeight * 1.25) } layer.setStyle({ opacity: nextOpacity, fillOpacity: nextFillOpacity, weight: nextWeight, color: layer.options.mesaBaseColor || layer.options.color, fillColor: layer.options.mesaBaseFillColor || layer.options.fillColor || layer.options.mesaBaseColor || layer.options.color, }) if (state === 'highlight' && typeof layer.bringToFront === 'function') { layer.bringToFront() } } if (typeof layer.setRadius === 'function') { rememberMarkerBaseStyle(layer) const baseRadius = Number.isFinite(layer.options.mesaBaseRadius) ? layer.options.mesaBaseRadius : null if (baseRadius !== null) { let nextRadius = baseRadius if (state === 'dim') { nextRadius = Math.max(1.6, baseRadius * 0.86) } else if (state === 'highlight') { nextRadius = Math.min(baseRadius * 1.32, baseRadius + 3.0) } layer.setRadius(nextRadius) } if (isPath && state === 'highlight' && typeof layer.bringToFront === 'function') { layer.bringToFront() } } if (!isPath && typeof layer.setOpacity === 'function') { rememberMarkerBaseStyle(layer) const baseOpacity = Number.isFinite(layer.options.mesaBaseMarkerOpacity) ? layer.options.mesaBaseMarkerOpacity : 1 let nextOpacity = baseOpacity if (state === 'dim') { nextOpacity = Math.max(0.18, baseOpacity * 0.28) } else if (state === 'highlight') { nextOpacity = Math.max(baseOpacity, 1) } layer.setOpacity(nextOpacity) } } function applyGroupState(meta, state) { if (!meta?.layerGroup) return const visited = new Set() visitLayerTree(meta.layerGroup, visited, (current) => { if (current === meta.layerGroup) return applyVisualState(current, state) }) } function updateHoverLegendState(groupKey, activeId) { const metas = hoverHighlightMetas.filter((entry) => entry.groupKey === groupKey) metas.forEach((meta) => { const label = meta.labelElement if (!label) return const isActive = Boolean(activeId) && meta.id === activeId label.style.cursor = 'pointer' label.style.transition = 'opacity 0.16s ease, color 0.16s ease, font-weight 0.16s ease' label.style.opacity = activeId ? (isActive ? '1' : '0.42') : '' label.style.color = isActive ? '#123b63' : '' label.style.fontWeight = isActive ? '700' : '' }) } function clearHoverHighlight(groupKey) { const metas = hoverHighlightMetas.filter((entry) => entry.groupKey === groupKey) metas.forEach((meta) => { applyGroupState(meta, 'normal') }) updateHoverLegendState(groupKey, null) } function setHoveredHighlight(groupKey, activeId) { const metas = hoverHighlightMetas.filter((entry) => entry.groupKey === groupKey) const activeMeta = metas.find((entry) => entry.id === activeId) || null if (!activeMeta || !activeMeta.layerGroup || !map.hasLayer(activeMeta.layerGroup)) { clearHoverHighlight(groupKey) return } metas.forEach((meta) => { if (!meta.layerGroup || !map.hasLayer(meta.layerGroup)) { applyGroupState(meta, 'normal') return } applyGroupState(meta, meta.id === activeId ? 'highlight' : 'dim') }) updateHoverLegendState(groupKey, activeId) } function bindLayerControlHover(layerControl) { const container = layerControl?.getContainer?.() if (!container) return const labels = Array.from(container.querySelectorAll('.leaflet-control-layers-overlays label')) if (!labels.length) return const metaByLayerId = new Map() hoverHighlightMetas.forEach((meta) => { if (!meta?.layerGroup) return metaByLayerId.set(String(L.Util.stamp(meta.layerGroup)), meta) }) labels.forEach((label) => { const input = label.querySelector('input') const layerId = String(input?.layerId || '') const meta = metaByLayerId.get(layerId) if (!meta) return meta.labelElement = label if (label.dataset.mesaModelHoverBound === '1') return label.dataset.mesaModelHoverBound = '1' const onEnter = () => setHoveredHighlight(meta.groupKey, meta.id) const onLeave = (event) => { const nextTarget = event?.relatedTarget || null if (nextTarget && label.contains(nextTarget)) return clearHoverHighlight(meta.groupKey) } label.addEventListener('mouseenter', onEnter) label.addEventListener('mouseover', onEnter) label.addEventListener('mouseleave', onLeave) label.addEventListener('mouseout', onLeave) }) hoverHighlightMetas.forEach((meta) => { if (meta.labelElement) { meta.labelElement.style.cursor = 'pointer' meta.labelElement.style.transition = 'opacity 0.16s ease, color 0.16s ease, font-weight 0.16s ease' } }) } function addPointMarkers(layerGroup, items) { if (!Array.isArray(items) || !items.length) return responsivePointContainers.push(layerGroup) items.forEach((item) => { const latlng = parseLatLonPair(item?.lat, item?.lon) if (!latlng) return const marker = L.circleMarker(latlng, { renderer: canvasRenderer, pane: String(item?.pane || 'mesa-market-pane'), radius: Number(item?.base_radius) || 4, color: String(item?.stroke_color || '#000000'), weight: Number.isFinite(Number(item?.stroke_weight)) ? Number(item.stroke_weight) : (Number(item?.base_radius) > 4 ? 1 : 0.8), fill: item?.fill !== false, fillColor: String(item?.color || item?.fill_color || '#FF8C00'), fillOpacity: Number.isFinite(Number(item?.fill_opacity)) ? Number(item.fill_opacity) : 0.68, interactive: item?.interactive !== false, bubblingMouseEvents: false, }) marker.options.mesaBaseRadius = Number(item?.base_radius) || 4 const tooltipHtml = String(item?.tooltip_html || '').trim() if (tooltipHtml) { marker.bindTooltip(tooltipHtml, { sticky: item?.tooltip_sticky !== false }) } else if (item?.tooltip) { marker.bindTooltip(buildTooltipHtml(item.tooltip), { sticky: item?.tooltip_sticky !== false }) } bindPointPopup(marker, item) layerGroup.addLayer(marker) }) } function addMarkerOverlays(layerGroup, items) { if (!Array.isArray(items) || !items.length) return items.forEach((item) => { const latlng = parseLatLonPair(item?.lat, item?.lon) if (!latlng) return const icon = L.divIcon({ html: String(item?.marker_html || ''), iconSize: Array.isArray(item?.icon_size) ? item.icon_size : [14, 14], iconAnchor: Array.isArray(item?.icon_anchor) ? item.icon_anchor : [7, 7], className: String(item?.class_name || 'mesa-map-marker'), }) const marker = L.marker(latlng, { icon, pane: String(item?.pane || (String(item?.class_name || '').includes('indice') ? 'mesa-indices-pane' : 'mesa-trabalhos-pane')), bubblingMouseEvents: item?.bubbling_mouse_events === true, interactive: item?.interactive !== false, keyboard: item?.keyboard !== false, }) if (item?.ignore_bounds) { marker.options.mesaIgnoreBounds = true } if (item?.tooltip_html) { marker.bindTooltip(String(item.tooltip_html), { sticky: item?.tooltip_sticky !== false }) } if (item?.popup_html) { marker.bindPopup(String(item.popup_html), { maxWidth: Number(item?.popup_max_width) || 360 }) } layerGroup.addLayer(marker) }) } function filterDependencyAwareMarkers(items) { if (!Array.isArray(items) || !items.length) return [] return items.filter((item) => { const sourceIds = Array.isArray(item?.source_overlay_ids) ? item.source_overlay_ids.map((entry) => String(entry || '').trim()).filter(Boolean) : [] if (!sourceIds.length) return true return sourceIds.some((sourceId) => { const sourceLayer = overlayGroupsById.get(sourceId) return Boolean(sourceLayer && map.hasLayer(sourceLayer)) }) }) } function areRequiredOverlaysVisible(spec) { const requiredIds = Array.isArray(spec?.required_overlay_ids) ? spec.required_overlay_ids.map((entry) => String(entry || '').trim()).filter(Boolean) : [] if (!requiredIds.length) return true return requiredIds.every((requiredId) => { const requiredLayer = overlayGroupsById.get(requiredId) return Boolean(requiredLayer && map.hasLayer(requiredLayer)) }) } function renderDependencyAwareOverlay(spec, layerGroup) { if (!layerGroup) return layerGroup.clearLayers() if (!map.hasLayer(layerGroup)) return if (!areRequiredOverlaysVisible(spec)) return addShapeOverlays(layerGroup, spec?.shapes) addMarkerOverlays(layerGroup, filterDependencyAwareMarkers(spec?.markers)) } function refreshDependencyAwareOverlays() { dependencyAwareOverlays.forEach(({ spec, layerGroup }) => { renderDependencyAwareOverlay(spec, layerGroup) }) } function addShapeOverlays(layerGroup, shapes) { if (!Array.isArray(shapes) || !shapes.length) return shapes.forEach((shape) => { const shapeType = String(shape?.type || shape?.shape_type || '').trim().toLowerCase() const style = { color: String(shape?.color || '#1f6fb2'), weight: Number.isFinite(Number(shape?.weight)) ? Number(shape.weight) : 2, opacity: Number.isFinite(Number(shape?.opacity)) ? Number(shape.opacity) : 0.8, fill: shape?.fill === true, fillColor: String(shape?.fill_color || shape?.color || '#1f6fb2'), fillOpacity: Number.isFinite(Number(shape?.fill_opacity)) ? Number(shape.fill_opacity) : 0.12, dashArray: shape?.dash_array ? String(shape.dash_array) : undefined, pane: String(shape?.pane || 'mesa-bairros-pane'), } let layer = null if (shapeType === 'circle' && Array.isArray(shape?.center) && shape.center.length === 2) { const center = parseLatLonPair(shape.center[0], shape.center[1]) if (center) { layer = L.circle(center, { ...style, radius: Number(shape?.radius_m) || 0 }) } } else if (shapeType === 'polyline' && Array.isArray(shape?.coords) && shape.coords.length) { layer = L.polyline(shape.coords, style) } else if (shapeType === 'polygon' && Array.isArray(shape?.coords) && shape.coords.length) { layer = L.polygon(shape.coords, style) } else if (shapeType === 'circlemarker' && Array.isArray(shape?.center) && shape.center.length === 2) { const center = parseLatLonPair(shape.center[0], shape.center[1]) if (center) { layer = L.circleMarker(center, { ...style, radius: Number(shape?.radius) || 6 }) } } if (!layer) return if (shape?.ignore_bounds) { layer.options.mesaIgnoreBounds = true } if (shape?.tooltip_html) { layer.bindTooltip(String(shape.tooltip_html), { sticky: shape?.tooltip_sticky !== false }) } if (shape?.popup_html) { layer.bindPopup(String(shape.popup_html), { maxWidth: Number(shape?.popup_max_width) || 360 }) } layerGroup.addLayer(layer) }) } function addGeoJsonOverlay(layerGroup, spec) { const geojsonLayer = L.geoJSON(null, { pane: String(spec?.geojson_pane || 'mesa-bairros-pane'), style: () => ({ color: String(spec?.geojson_style?.color || '#4c6882'), weight: Number.isFinite(Number(spec?.geojson_style?.weight)) ? Number(spec.geojson_style.weight) : 1.0, opacity: Number.isFinite(Number(spec?.geojson_style?.opacity)) ? Number(spec.geojson_style.opacity) : 0.88, fillColor: String(spec?.geojson_style?.fillColor || '#f39c12'), fillOpacity: Number.isFinite(Number(spec?.geojson_style?.fillOpacity)) ? Number(spec.geojson_style.fillOpacity) : 0.04, }), onEachFeature: (feature, layer) => { const props = feature?.properties || {} const candidates = Array.isArray(spec?.geojson_tooltip_properties) ? spec.geojson_tooltip_properties : [] const value = candidates.map((key) => props?.[key]).find((entry) => String(entry || '').trim()) if (value) { const prefix = String(spec?.geojson_tooltip_label || '').trim() layer.bindTooltip(prefix ? `${prefix}: ${String(value)}` : String(value), { sticky: false }) } }, }) layerGroup.addLayer(geojsonLayer) if (spec?.geojson_data) { geojsonLayer.addData(spec.geojson_data) return } if (!spec?.geojson_url) return void carregarBairrosGeojson(spec.geojson_url) .then((geojson) => { if (disposed || !geojson) return geojsonLayer.addData(geojson) }) .catch(() => { // camada opcional; manter o mapa funcional sem ela }) } function addHeatmapOverlay(layerGroup, heatmapSpec) { if (!heatmapSpec || typeof L.heatLayer !== 'function') return const points = Array.isArray(heatmapSpec?.points) ? heatmapSpec.points .map((item) => { const latlng = parseLatLonPair(item?.lat, item?.lon) const weight = Number(item?.weight) if (!latlng) return null if (Number.isFinite(weight)) return [latlng[0], latlng[1], weight] return latlng }) .filter(Boolean) : [] if (!points.length) return const layer = L.heatLayer(points, { radius: Number(heatmapSpec?.radius) || 20, blur: Number(heatmapSpec?.blur) || 18, minOpacity: Number.isFinite(Number(heatmapSpec?.min_opacity)) ? Number(heatmapSpec.min_opacity) : 0.28, maxZoom: Number(heatmapSpec?.max_zoom) || 17, gradient: heatmapSpec?.gradient || undefined, }) layerGroup.addLayer(layer) } overlaySpecs.forEach((spec, index) => { const specId = String(spec?.id || '').trim() const label = String(spec?.label || spec?.id || `Camada ${index + 1}`) const layerGroup = L.layerGroup() overlayLayers[label] = layerGroup if (specId) { overlayGroupsById.set(specId, layerGroup) } if (spec?.show !== false) { layerGroup.addTo(map) } if (spec?.geojson_url || spec?.geojson_data) { addGeoJsonOverlay(layerGroup, spec) } addHeatmapOverlay(layerGroup, spec?.heatmap) addPointMarkers(layerGroup, spec?.points) const hasMarkerDependencies = Array.isArray(spec?.markers) && spec.markers.some((item) => Array.isArray(item?.source_overlay_ids) && item.source_overlay_ids.length) const hasOverlayDependencies = Array.isArray(spec?.required_overlay_ids) && spec.required_overlay_ids.length > 0 if (hasMarkerDependencies || hasOverlayDependencies) { dependencyAwareOverlays.push({ spec, layerGroup }) renderDependencyAwareOverlay(spec, layerGroup) } else { addShapeOverlays(layerGroup, spec?.shapes) addMarkerOverlays(layerGroup, spec?.markers) } const hoverGroup = String(spec?.hover_highlight_group || '').trim() if (hoverGroup && specId) { hoverHighlightMetas.push({ id: specId, groupKey: hoverGroup, layerGroup, labelElement: null, }) } }) if (dependencyAwareOverlays.length) { map.on('overlayadd', refreshDependencyAwareOverlays) map.on('overlayremove', refreshDependencyAwareOverlays) } if (payload.controls?.layer_control) { const collapsed = payload.controls?.layer_control_collapsed !== false const layerControl = L.control.layers(baseLayers, overlayLayers, { collapsed }).addTo(map) if (hoverHighlightMetas.length) { window.requestAnimationFrame(() => bindLayerControlHover(layerControl)) window.setTimeout(() => bindLayerControlHover(layerControl), 40) window.setTimeout(() => bindLayerControlHover(layerControl), 180) } } if (payload.controls?.fullscreen && typeof L.control.fullscreen === 'function') { L.control.fullscreen({ position: 'topleft' }).addTo(map) } if (payload.controls?.measure) { const disableInteractionsForMeasure = () => { if (restoreMapInteractions) return const states = { dragging: !!map.dragging?.enabled?.(), doubleClickZoom: !!map.doubleClickZoom?.enabled?.(), boxZoom: !!map.boxZoom?.enabled?.(), keyboard: !!map.keyboard?.enabled?.(), touchZoom: !!map.touchZoom?.enabled?.(), scrollWheelZoom: !!map.scrollWheelZoom?.enabled?.(), } map.dragging?.disable?.() map.doubleClickZoom?.disable?.() map.boxZoom?.disable?.() map.keyboard?.disable?.() map.touchZoom?.disable?.() map.scrollWheelZoom?.disable?.() if (map.getContainer()) { map.getContainer().style.cursor = 'crosshair' } restoreMapInteractions = () => { if (states.dragging) map.dragging?.enable?.() if (states.doubleClickZoom) map.doubleClickZoom?.enable?.() if (states.boxZoom) map.boxZoom?.enable?.() if (states.keyboard) map.keyboard?.enable?.() if (states.touchZoom) map.touchZoom?.enable?.() if (states.scrollWheelZoom) map.scrollWheelZoom?.enable?.() if (map.getContainer()) { map.getContainer().style.cursor = '' } restoreMapInteractions = null } } const measureLayerGroup = L.layerGroup().addTo(map) const measureState = { panelOpen: false, active: false, points: [], } let measureRoot = null let measureInteraction = null let measureHint = null let measureResults = null let measureStartButton = null let measureCancelButton = null let measureFinishButton = null let measureCloseButton = null function renderMeasureGeometry() { measureLayerGroup.clearLayers() if (!measureState.points.length) return if (measureState.points.length >= 2) { L.polyline(measureState.points, { color: '#1f6fb2', weight: 3, opacity: 0.9, dashArray: measureState.active ? '8 6' : undefined, }).addTo(measureLayerGroup) } if (!measureState.active && measureState.points.length >= 3) { L.polygon(measureState.points, { color: '#1f6fb2', weight: 2, opacity: 0.88, fillColor: '#1f6fb2', fillOpacity: 0.12, }).addTo(measureLayerGroup) } measureState.points.forEach((latlng) => { L.circleMarker(latlng, { radius: 5, color: '#ffffff', weight: 2, fillColor: '#1f6fb2', fillOpacity: 1, pane: 'mesa-indices-pane', }).addTo(measureLayerGroup) }) } function renderMeasurePanel() { if (!measureRoot || !measureInteraction || !measureHint || !measureResults) return measureRoot.classList.toggle('leaflet-control-measure-expanded', measureState.panelOpen) measureInteraction.style.display = measureState.panelOpen ? 'block' : 'none' if (!measureState.panelOpen) return const pointCount = measureState.points.length const lastPoint = pointCount ? measureState.points[pointCount - 1] : null const distance = pointCount >= 2 ? measureState.points.reduce((total, point, index, list) => { if (index === 0) return total return total + map.distance(list[index - 1], point) }, 0) : 0 const area = !measureState.active && pointCount >= 3 ? computePolygonArea(measureState.points) : 0 if (measureState.active) { measureHint.textContent = pointCount ? 'Clique no mapa para adicionar pontos. Dê duplo clique ou finalize para encerrar.' : 'Clique no mapa para iniciar a medição.' } else if (pointCount) { measureHint.textContent = 'Medição concluída.' } else { measureHint.textContent = 'Inicie uma nova medição.' } const parts = [] if (lastPoint) { parts.push( '
' + 'Último ponto' + `${escapeHtml(formatLatLng(lastPoint))}` + `${escapeHtml(formatDecimalLatLng(lastPoint))}` + '
', ) } if (distance > 0) { parts.push( '
' + 'Distância total' + `${escapeHtml(formatDistance(distance))}` + '
', ) } if (area > 0) { parts.push( '
' + 'Área' + `${escapeHtml(formatArea(area))}` + '
', ) } measureResults.innerHTML = parts.join('') measureResults.style.display = parts.length ? 'block' : 'none' measureStartButton.style.display = !measureState.active ? 'inline-flex' : 'none' measureStartButton.textContent = measureState.points.length ? 'Nova medição' : 'Criar nova medição' measureCancelButton.style.display = measureState.active || measureState.points.length ? 'inline-flex' : 'none' measureFinishButton.style.display = measureState.active && measureState.points.length >= 2 ? 'inline-flex' : 'none' measureCloseButton.style.display = !measureState.active ? 'inline-flex' : 'none' } function resetMeasureState({ closePanel = false } = {}) { measureState.active = false measureState.points = [] measureLayerGroup.clearLayers() restoreMapInteractions?.() if (closePanel) { measureState.panelOpen = false } renderMeasurePanel() } function startMeasurement() { measureState.panelOpen = true measureState.active = true measureState.points = [] measureLayerGroup.clearLayers() disableInteractionsForMeasure() renderMeasurePanel() } function finishMeasurement() { if (!measureState.active) return measureState.active = false restoreMapInteractions?.() renderMeasureGeometry() renderMeasurePanel() } function onMeasureMapClick(event) { if (!measureState.active || !event?.latlng) return const lastPoint = measureState.points[measureState.points.length - 1] if (lastPoint && typeof event.latlng.equals === 'function' && event.latlng.equals(lastPoint)) return measureState.points = [...measureState.points, event.latlng] renderMeasureGeometry() renderMeasurePanel() } function onMeasureMapDblClick(event) { if (!measureState.active) return L.DomEvent.stop(event) finishMeasurement() } map.on('click', onMeasureMapClick) map.on('dblclick', onMeasureMapDblClick) const measureControl = L.control({ position: 'topright' }) measureControl.onAdd = () => { measureRoot = L.DomUtil.create('div', 'leaflet-control-measure mesa-leaflet-measure leaflet-bar leaflet-control') measureRoot.setAttribute('aria-haspopup', 'true') const toggle = L.DomUtil.create('a', 'leaflet-control-measure-toggle mesa-leaflet-measure-toggle', measureRoot) toggle.href = '#' toggle.title = 'Medir distâncias' toggle.setAttribute('aria-label', 'Medir distâncias') toggle.innerHTML = 'Medir distâncias' measureInteraction = L.DomUtil.create('div', 'leaflet-control-measure-interaction mesa-leaflet-measure-interaction', measureRoot) measureInteraction.style.display = 'none' const title = L.DomUtil.create('h3', 'mesa-leaflet-measure-title', measureInteraction) title.textContent = 'Medir distâncias' measureHint = L.DomUtil.create('p', 'mesa-leaflet-measure-hint', measureInteraction) measureResults = L.DomUtil.create('div', 'mesa-leaflet-measure-results', measureInteraction) const actions = L.DomUtil.create('div', 'mesa-leaflet-measure-actions', measureInteraction) measureStartButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn is-primary', actions) measureStartButton.type = 'button' measureStartButton.textContent = 'Criar nova medição' measureCancelButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn', actions) measureCancelButton.type = 'button' measureCancelButton.textContent = 'Cancelar' measureFinishButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn', actions) measureFinishButton.type = 'button' measureFinishButton.textContent = 'Finalizar' measureCloseButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn', actions) measureCloseButton.type = 'button' measureCloseButton.textContent = 'Fechar' L.DomEvent.disableClickPropagation(measureRoot) L.DomEvent.disableScrollPropagation(measureRoot) L.DomEvent.on(toggle, 'click', (event) => { L.DomEvent.stop(event) if (measureState.active) return measureState.panelOpen = !measureState.panelOpen renderMeasurePanel() }) L.DomEvent.on(measureStartButton, 'click', (event) => { L.DomEvent.stop(event) startMeasurement() }) L.DomEvent.on(measureCancelButton, 'click', (event) => { L.DomEvent.stop(event) resetMeasureState() }) L.DomEvent.on(measureFinishButton, 'click', (event) => { L.DomEvent.stop(event) finishMeasurement() }) L.DomEvent.on(measureCloseButton, 'click', (event) => { L.DomEvent.stop(event) if (measureState.active) return measureState.panelOpen = false renderMeasurePanel() }) renderMeasurePanel() return measureRoot } measureControl.addTo(map) } if (payload.legend?.title) { const legendControl = construirLegendaControl(payload.legend) if (legendControl) { legendControl.onAdd = () => { const div = L.DomUtil.create('div', 'mesa-leaflet-legend') div.innerHTML = buildLegendHtml(payload.legend) return div } legendControl.addTo(map) } } addNoticeControl(map, payload.notice) map.on('zoomend overlayadd overlayremove', applyResponsiveRadius) if (hoverHighlightMetas.length) { map.on('overlayadd overlayremove', () => { hoverHighlightMetas.forEach((meta) => { clearHoverHighlight(meta.groupKey) }) }) } applyResponsiveRadius() if (!fitMapToPayloadBounds(map, payload, 48)) { setRuntimeError('Falha ao montar mapa interativo.') } return () => { if (dependencyAwareOverlays.length) { map.off('overlayadd', refreshDependencyAwareOverlays) map.off('overlayremove', refreshDependencyAwareOverlays) } disposed = true restoreMapInteractions?.() if (mapRef.current === map) { mapRef.current = null } map.remove() } }, [payload, sessionId]) return (
{runtimeError ?
{runtimeError}
: null}
) }) export default LeafletMapFrame