import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' import L from 'leaflet' import 'leaflet.heat' import { apiUrl, downloadBlob, getAuthToken } from '../api' const geojsonCacheByUrl = new Map() const geojsonPromiseByUrl = new Map() 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 normalizeCssColor(value, fallback = '#607d8b') { const text = String(value || '').trim() if (/^#[0-9a-fA-F]{3,8}$/.test(text)) return text if (/^rgba?\(\s*[\d.\s%,]+\)$/.test(text)) return text return fallback } function isTrueFlag(value) { return value === true || value === 1 || value === '1' } function isFalseFlag(value) { return value === false || value === 0 || value === '0' } function naturalCompare(a, b) { return String(a ?? '').localeCompare(String(b ?? ''), 'pt-BR', { numeric: true, sensitivity: 'base' }) } function buildGroupedMarketMarkerHtml(item, size = 22) { const count = Math.max(2, Number(item?.count) || 2) const color = normalizeCssColor(item?.color || item?.fill_color || '#607d8b') const markerSize = Math.max(14, Math.min(38, Number(size) || 22)) const fontSize = Math.max(9, Math.min(12, markerSize * 0.38)) const borderSize = markerSize >= 24 ? 3 : 2 const haloSize = markerSize >= 24 ? 4 : 2 const labelHtml = isTrueFlag(item?.hide_count) ? '' : `${escapeHtml(count > 99 ? '99+' : count)}` return ( `
` + labelHtml + '
' ) } function buildGroupedMarketIcon(item, size = 22) { const markerSize = Math.max(14, Math.min(38, Number(size) || 22)) return L.divIcon({ html: buildGroupedMarketMarkerHtml(item, markerSize), iconSize: [markerSize, markerSize], iconAnchor: [markerSize / 2, markerSize / 2], className: 'mesa-market-group-icon', }) } function hasValidRowId(value) { if (value === null || value === undefined || value === '') return false return Number.isInteger(Number(value)) && Number(value) >= 0 } 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 carregarGeojson(url) { const endpoint = String(url || '').trim() if (!endpoint) return null if (geojsonCacheByUrl.has(endpoint)) return geojsonCacheByUrl.get(endpoint) if (geojsonPromiseByUrl.has(endpoint)) return geojsonPromiseByUrl.get(endpoint) // Em desenvolvimento, o mapa é servido pelo Vite e a API pelo backend. // Usar `apiUrl` evita que /api/... seja solicitado à porta do frontend. const requestUrl = /^https?:\/\//i.test(endpoint) ? endpoint : apiUrl(endpoint) const promise = fetch(requestUrl, { headers: (() => { const token = getAuthToken() return token ? { 'X-Auth-Token': token } : {} })(), }) .then(async (response) => { if (!response.ok) { throw new Error('Falha ao carregar camada vetorial.') } return response.json() }) .then((payload) => { geojsonCacheByUrl.set(endpoint, payload) return payload }) .finally(() => { geojsonPromiseByUrl.delete(endpoint) }) geojsonPromiseByUrl.set(endpoint, promise) return promise } 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 pointInPolygon(point, polygon) { if (!point || !Array.isArray(polygon) || polygon.length < 3) return false let inside = false for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { const xi = polygon[i].x const yi = polygon[i].y const xj = polygon[j].x const yj = polygon[j].y const intersects = ((yi > point.y) !== (yj > point.y)) && (point.x < ((xj - xi) * (point.y - yi)) / ((yj - yi) || Number.EPSILON) + xi) if (intersects) inside = !inside } return inside } 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 emitOpenTrabalhoTecnicoMessage(trabalhoId, origem = 'pesquisa_mapa') { const id = String(trabalhoId || '').trim() if (!id) return false const payload = { type: 'mesa:open-trabalho-tecnico', trabalhoId: id, origem: String(origem || 'pesquisa_mapa').trim() || 'pesquisa_mapa', } try { window.postMessage(payload, '*') return true } catch { return false } } 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', }) } // Alguns mapas antigos ainda usam o payload legado. Mantemos a camada aqui // para que a disponibilidade da inundação seja igual à dos mapas novos. if (payload?.show_inundacao !== false) { overlays.push({ id: 'inundacao_535', label: 'Inundação — cota 5,35', show: false, lazy: true, geojson_url: payload?.inundacao_geojson_url || '/api/visualizacao/map/inundacao.geojson', geojson_pane: 'mesa-inundacao-pane', geojson_style: { color: '#1677c8', weight: 1.2, opacity: 0.9, fillColor: '#3ca6e8', fillOpacity: 0.2, }, geojson_tooltip_properties: ['contourmax'], geojson_tooltip_label: 'Cota', }) } 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: !item?.grouped && hasValidRowId(item?.row_id) ? { kind: 'visualizacao_row', row_id: Number(item.row_id) } : null, })), }) overlays.push({ id: 'indices', label: 'INDICES', 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: payload?.trabalhos_tecnicos_show !== false, 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 } function addFullscreenControl(map, onToggleFullscreen) { const control = L.control({ position: 'topright' }) let root = null control.onAdd = () => { root = L.DomUtil.create('div', 'mesa-leaflet-fullscreen leaflet-bar leaflet-control') const button = L.DomUtil.create('a', 'mesa-leaflet-fullscreen-toggle', root) button.href = '#' button.title = 'Abrir mapa em tela cheia' button.setAttribute('aria-label', 'Abrir mapa em tela cheia') button.innerHTML = 'Abrir mapa em tela cheia' const updateState = () => { const active = Boolean(document.fullscreenElement) root?.classList.toggle('is-active', active) button.title = active ? 'Sair da tela cheia' : 'Abrir mapa em tela cheia' button.setAttribute('aria-label', active ? 'Sair da tela cheia' : 'Abrir mapa em tela cheia') } const fallbackToggle = async () => { if (document.fullscreenElement) { await document.exitFullscreen() return } const container = map.getContainer() if (typeof container?.requestFullscreen === 'function') { await container.requestFullscreen() } } L.DomEvent.disableClickPropagation(root) L.DomEvent.disableScrollPropagation(root) L.DomEvent.on(button, 'click', (event) => { L.DomEvent.stop(event) const result = onToggleFullscreen ? onToggleFullscreen(event) : fallbackToggle() Promise.resolve(result).catch(() => {}).finally(() => { window.setTimeout(() => { map.invalidateSize?.(true) }, 120) }) }) document.addEventListener('fullscreenchange', updateState) root._mesaFullscreenCleanup = () => { document.removeEventListener('fullscreenchange', updateState) } updateState() return root } control.onRemove = () => { root?._mesaFullscreenCleanup?.() root = null } control.addTo(map) return control } 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 marketGroupPane = exportMap.createPane('mesa-market-group-pane') const trabalhosPane = exportMap.createPane('mesa-trabalhos-pane') const indicesPane = exportMap.createPane('mesa-indices-pane') bairrosPane.style.zIndex = '410' marketPane.style.zIndex = '420' marketGroupPane.style.zIndex = '425' 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 if (item?.grouped) { const marker = L.marker(latlng, { icon: buildGroupedMarketIcon(item, 16), pane: String(item?.pane || 'mesa-market-group-pane'), interactive: false, keyboard: false, bubblingMouseEvents: false, }) marker.options.mesaBaseRadius = Number(item?.base_radius) || 4 marker.options.mesaGroupedMarketMarker = true marker.options.mesaGroupedMarketItem = item layerGroup.addLayer(marker) 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 carregarGeojson(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, onToggleFullscreen }, ref) { const hostRef = useRef(null) const mapRef = useRef(null) const payloadRef = useRef(payload) const popupCacheRef = useRef(new Map()) const popupLoaderRef = useRef(null) const groupSelectionMarkerRef = useRef(null) const [runtimeError, setRuntimeError] = useState('') const [groupSelection, setGroupSelection] = useState(null) 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 }, }), []) const closeGroupSelection = useCallback(() => { setGroupSelection(null) groupSelectionMarkerRef.current = null }, []) const selectGroupedMarketItem = useCallback((item) => { const popupRequest = item?.popup_request const marker = groupSelectionMarkerRef.current const loader = popupLoaderRef.current if (!popupRequest || !marker || !loader) return setGroupSelection(null) void loader(popupRequest, marker) }, []) useEffect(() => { if (!hostRef.current || !payload) return undefined let disposed = false setRuntimeError('') setGroupSelection(null) groupSelectionMarkerRef.current = null hostRef.current.innerHTML = '' const map = L.map(hostRef.current, { zoomControl: true, preferCanvas: true, }) mapRef.current = map const mapContainer = map.getContainer() let restoreMapInteractions = null let cleanupLasso = null const bairrosPane = map.createPane('mesa-bairros-pane') const inundacaoPane = map.createPane('mesa-inundacao-pane') const marketPane = map.createPane('mesa-market-pane') const marketGroupPane = map.createPane('mesa-market-group-pane') const trabalhosPane = map.createPane('mesa-trabalhos-pane') const indicesPane = map.createPane('mesa-indices-pane') bairrosPane.style.zIndex = '410' inundacaoPane.style.zIndex = '412' marketPane.style.zIndex = '420' marketGroupPane.style.zIndex = '425' 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: String(layerDef?.attribution || '© OpenStreetMap contributors'), 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) => { 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)) const radius = clamp(base * expFactor, dynamicMin, dynamicMax) if (layer.options?.mesaGroupedMarketMarker && typeof layer.setIcon === 'function') { const iconSize = Math.max(16, Math.min(36, Math.round(radius * 2.35))) layer.setIcon(buildGroupedMarketIcon(layer.options.mesaGroupedMarketItem || {}, iconSize)) return } if (typeof layer.setRadius !== 'function') return layer.setRadius(radius) }) }) } async function carregarPopupRegistro(popupRequest, layer) { const kind = String(popupRequest?.kind || '').trim() const rowId = Number(popupRequest?.row_id) const source = String(popupRequest?.source || '').trim() const endpoint = kind === 'elaboracao_row' ? '/api/elaboracao/map/popup' : '/api/visualizacao/map/popup' const cacheKey = `${kind || 'visualizacao_row'}:${source || 'default'}:${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 body = { session_id: sessionId, row_id: rowId, } if (source) { body.source = source } const response = await fetch(apiUrl(endpoint), { method: 'POST', headers: { 'Content-Type': 'application/json', ...(getAuthToken() ? { 'X-Auth-Token': getAuthToken() } : {}), }, body: JSON.stringify(body), }) 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() } } popupLoaderRef.current = carregarPopupRegistro function handlePopupActionClick(event) { const target = event?.target if (!(target instanceof Element)) return const trigger = target.closest('[data-mesa-open-trabalho-tecnico="1"]') if (!trigger) return const trabalhoId = String(trigger.getAttribute('data-trabalho-id') || '').trim() if (!trabalhoId) return event.preventDefault() event.stopPropagation() emitOpenTrabalhoTecnicoMessage( trabalhoId, String(trigger.getAttribute('data-origem') || 'pesquisa_mapa').trim() || 'pesquisa_mapa', ) } mapContainer?.addEventListener('click', handlePopupActionClick, true) 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 const popupKind = String(popupRequest?.kind || '').trim() if ( ['visualizacao_row', 'elaboracao_row'].includes(popupKind) && Number.isFinite(Number(popupRequest?.row_id)) && sessionId ) { marker.on('click', () => { void carregarPopupRegistro(popupRequest, 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 const hoverFillOpacity = Number(layer.options.mesaHoverFillOpacity) let nextOpacity = baseOpacity let nextFillOpacity = baseFillOpacity let nextWeight = baseWeight if (state === 'dim') { nextOpacity = Math.max(0.28, baseOpacity * 0.45) nextFillOpacity = baseFillOpacity > 0 ? Math.max(0.06, baseFillOpacity * 0.55) : baseFillOpacity nextWeight = Math.max(1, baseWeight * 0.92) } else if (state === 'highlight') { nextOpacity = Math.max(baseOpacity, 0.98) nextFillOpacity = Number.isFinite(hoverFillOpacity) ? Math.max(baseFillOpacity, hoverFillOpacity) : ( baseFillOpacity > 0 ? Math.max(baseFillOpacity, Math.min(0.46, (baseFillOpacity * 2.3) + 0.12)) : baseFillOpacity ) nextWeight = Math.max(baseWeight + 1.2, baseWeight * 1.25) } layer.setStyle({ opacity: nextOpacity, fill: layer.options.fill === true || Number.isFinite(hoverFillOpacity), 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 clearHoverHighlight(groupKey) { const metas = hoverHighlightMetas.filter((entry) => entry.groupKey === groupKey) metas.forEach((meta) => { applyGroupState(meta, 'normal') }) } 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') }) } 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 if (item?.grouped) { if (isTrueFlag(item?.hide_count)) { 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 }) } marker.on('click', () => { map.closePopup() marker.unbindPopup() const groupItems = Array.isArray(item?.group_items) ? item.group_items : [] if (!groupItems.length) return groupSelectionMarkerRef.current = marker setGroupSelection({ title: String(item?.group_title || 'Dados neste local'), count: Number(item?.count) || groupItems.length, hideCount: isTrueFlag(item?.hide_group_count), items: groupItems, }) }) layerGroup.addLayer(marker) return } const marker = L.marker(latlng, { icon: buildGroupedMarketIcon(item, 16), pane: String(item?.pane || 'mesa-market-group-pane'), interactive: item?.interactive !== false, keyboard: item?.keyboard !== false, bubblingMouseEvents: false, }) marker.options.mesaBaseRadius = Number(item?.base_radius) || 4 marker.options.mesaGroupedMarketMarker = true marker.options.mesaGroupedMarketItem = item const tooltipHtml = String(item?.tooltip_html || '').trim() if (tooltipHtml) { marker.bindTooltip(tooltipHtml, { sticky: item?.tooltip_sticky !== false }) } marker.on('click', () => { map.closePopup() marker.unbindPopup() const groupItems = Array.isArray(item?.group_items) ? item.group_items : [] if (!groupItems.length) return groupSelectionMarkerRef.current = marker setGroupSelection({ title: String(item?.group_title || `${groupItems.length} dados neste local`), count: Number(item?.count) || groupItems.length, hideCount: isTrueFlag(item?.hide_group_count), items: groupItems, }) }) layerGroup.addLayer(marker) 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 const specId = String(spec?.id || '').trim() const hoverGroup = String(spec?.hover_highlight_group || '').trim() const hoverHighlight = hoverGroup && specId ? { groupKey: hoverGroup, id: specId } : null addShapeOverlays(layerGroup, spec?.shapes, hoverHighlight) addMarkerOverlays(layerGroup, filterDependencyAwareMarkers(spec?.markers)) } function refreshDependencyAwareOverlays() { dependencyAwareOverlays.forEach(({ spec, layerGroup }) => { renderDependencyAwareOverlay(spec, layerGroup) }) } function addShapeOverlays(layerGroup, shapes, hoverHighlight = null) { 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 const hoverFillOpacity = shape?.hover_fill_opacity === false ? NaN : Number(shape?.hover_fill_opacity) if (Number.isFinite(hoverFillOpacity)) { layer.options.mesaHoverFillOpacity = hoverFillOpacity } 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 }) } if (hoverHighlight?.groupKey && hoverHighlight?.id && typeof layer.setStyle === 'function') { layer.on('mouseover', () => setHoveredHighlight(hoverHighlight.groupKey, hoverHighlight.id)) layer.on('mouseout', () => clearHoverHighlight(hoverHighlight.groupKey)) } else if (shapeType === 'polygon' && style.fill === true && Number.isFinite(hoverFillOpacity)) { layer.on('mouseover', () => applyVisualState(layer, 'highlight')) layer.on('mouseout', () => applyVisualState(layer, 'normal')) } 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 let carregada = false let carregando = false const carregarQuandoNecessario = () => { if (carregada || carregando || disposed) return carregando = true void carregarGeojson(spec.geojson_url) .then((geojson) => { if (disposed || !geojson) return geojsonLayer.addData(geojson) carregada = true }) .catch((error) => { // A camada é opcional, mas o aviso facilita diagnosticar uma falha // de rede ou de autenticação sem impedir o uso do mapa. console.warn('Não foi possível carregar a camada vetorial.', error) }) .finally(() => { carregando = false }) } // O evento da própria camada é disparado sempre que ela entra no mapa, // inclusive por controles ou fluxos que não disparam `overlayadd`. layerGroup.on('add', carregarQuandoNecessario) if (map.hasLayer(layerGroup)) carregarQuandoNecessario() map.on('overlayadd', (event) => { if (event?.layer === layerGroup) carregarQuandoNecessario() }) } 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 (!isFalseFlag(spec?.show)) { 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 const hoverGroup = String(spec?.hover_highlight_group || '').trim() const hoverHighlight = hoverGroup && specId ? { groupKey: hoverGroup, id: specId } : null if (hasMarkerDependencies || hasOverlayDependencies) { dependencyAwareOverlays.push({ spec, layerGroup }) renderDependencyAwareOverlay(spec, layerGroup) } else { addShapeOverlays(layerGroup, spec?.shapes, hoverHighlight) addMarkerOverlays(layerGroup, spec?.markers) } if (hoverGroup && specId) { hoverHighlightMetas.push({ id: specId, groupKey: hoverGroup, layerGroup, }) } }) if (dependencyAwareOverlays.length) { map.on('overlayadd', refreshDependencyAwareOverlays) map.on('overlayremove', refreshDependencyAwareOverlays) } function syncInitialOverlayVisibility(layerControl = null) { overlaySpecs.forEach((spec) => { const label = String(spec?.label || spec?.id || '').trim() if (!label) return const layerGroup = overlayLayers[label] if (!layerGroup) return if (isFalseFlag(spec?.show)) { map.removeLayer(layerGroup) } }) const container = layerControl?.getContainer?.() if (!container) return const inputs = Array.from(container.querySelectorAll('.leaflet-control-layers-overlays input[type="checkbox"]')) inputs.forEach((input) => { const layerId = String(input?.layerId || '') const matchingLabel = Object.entries(overlayLayers).find(([, layer]) => String(L.Util.stamp(layer)) === layerId)?.[0] if (!matchingLabel) return const spec = overlaySpecs.find((item) => String(item?.label || item?.id || '').trim() === matchingLabel) if (isFalseFlag(spec?.show)) { input.checked = false } }) } function addLayerControlCloseButton(layerControl) { const container = layerControl?.getContainer?.() if (!container || container.querySelector('.mesa-layer-control-close')) return const button = L.DomUtil.create('button', 'mesa-layer-control-close', container) button.type = 'button' button.setAttribute('aria-label', 'Fechar camadas') button.title = 'Fechar camadas' button.textContent = 'Fechar' L.DomEvent.disableClickPropagation(button) L.DomEvent.on(button, 'click', (event) => { L.DomEvent.stop(event) setLayerControlOpen(layerControl, false) }) } function updateLayerControlToggleState(layerControl) { const container = layerControl?.getContainer?.() const toggle = layerControl?._layersLink || container?.querySelector?.('.leaflet-control-layers-toggle') if (!container || !toggle) return toggle.setAttribute('aria-expanded', container.classList.contains('leaflet-control-layers-expanded') ? 'true' : 'false') } function setLayerControlOpen(layerControl, open) { const container = layerControl?.getContainer?.() if (!container) return if (open && typeof layerControl.expand === 'function') { layerControl.expand() } else if (!open && typeof layerControl.collapse === 'function') { layerControl.collapse() } else { container.classList.toggle('leaflet-control-layers-expanded', !!open) } updateLayerControlToggleState(layerControl) } function enableLayerControlClickToggle(layerControl) { const container = layerControl?.getContainer?.() if (!container) return container.classList.add('mesa-layer-control-click-toggle') if (layerControl?._map && typeof layerControl.collapse === 'function') { layerControl._map.off('click', layerControl.collapse, layerControl) } if (typeof layerControl?._expandSafely === 'function') { L.DomEvent.off(container, 'mouseenter', layerControl._expandSafely, layerControl) } if (typeof layerControl?.collapse === 'function') { L.DomEvent.off(container, 'mouseleave', layerControl.collapse, layerControl) } const currentToggle = layerControl?._layersLink || container.querySelector('.leaflet-control-layers-toggle') if (!currentToggle || currentToggle.dataset.mesaClickToggle === '1') { updateLayerControlToggleState(layerControl) return } const toggle = currentToggle.cloneNode(true) toggle.dataset.mesaClickToggle = '1' toggle.href = '#' toggle.title = 'Camadas' toggle.setAttribute('role', 'button') toggle.setAttribute('aria-label', 'Abrir ou fechar camadas') currentToggle.parentNode.replaceChild(toggle, currentToggle) layerControl._layersLink = toggle const togglePanel = (event) => { L.DomEvent.stop(event) const expanded = container.classList.contains('leaflet-control-layers-expanded') setLayerControlOpen(layerControl, !expanded) } L.DomEvent.disableClickPropagation(toggle) L.DomEvent.on(toggle, 'click', togglePanel) L.DomEvent.on(toggle, 'keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ' && event.keyCode !== 13 && event.keyCode !== 32) return togglePanel(event) }) updateLayerControlToggleState(layerControl) } syncInitialOverlayVisibility() if (payload.controls?.layer_control) { const collapsed = !isFalseFlag(payload.controls?.layer_control_collapsed) const layerControl = L.control.layers(baseLayers, overlayLayers, { collapsed }).addTo(map) enableLayerControlClickToggle(layerControl) addLayerControlCloseButton(layerControl) syncInitialOverlayVisibility(layerControl) } 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.controls?.market_lasso) { const lassoLayerGroup = L.layerGroup().addTo(map) const lassoState = { panelOpen: false, active: false, drawing: false, points: [], selectedModels: [], error: '', downloading: false, } let lassoRoot = null let lassoInteraction = null let lassoHint = null let lassoResults = null let lassoStartButton = null let lassoCancelButton = null let lassoDownloadButton = null let lassoCloseButton = null function disableInteractionsForLasso() { 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 } } function renderLassoGeometry() { lassoLayerGroup.clearLayers() if (!lassoState.points.length) return if (lassoState.points.length >= 2) { L.polyline(lassoState.points, { color: '#0f766e', weight: 2.6, opacity: 0.95, dashArray: lassoState.drawing ? '7 5' : undefined, }).addTo(lassoLayerGroup) } if (!lassoState.drawing && lassoState.points.length >= 3) { L.polygon(lassoState.points, { color: '#0f766e', weight: 2, opacity: 0.95, fillColor: '#0f766e', fillOpacity: 0.12, }).addTo(lassoLayerGroup) } } function getSelectedMarketModels(points) { if (!Array.isArray(points) || points.length < 3) return [] const polygonPoints = points.map((latlng) => map.latLngToLayerPoint(latlng)) const selectedByModel = new Map() overlaySpecs.forEach((spec) => { const specId = String(spec?.id || '').trim() const modelLayer = specId ? overlayGroupsById.get(specId) : null if (!Array.isArray(spec?.points) || !spec.points.length) return if (modelLayer && !map.hasLayer(modelLayer)) return spec.points.forEach((item) => { const latlng = parseLatLonPair(item?.lat, item?.lon) if (!latlng) return const layerPoint = map.latLngToLayerPoint(L.latLng(latlng[0], latlng[1])) if (!pointInPolygon(layerPoint, polygonPoints)) return const modelKey = String(item?.selection_model_id || specId || spec?.label || 'modelo') const modelName = String(item?.selection_model_name || spec?.label || modelKey) const indices = Array.isArray(item?.selection_indices) && item.selection_indices.length ? item.selection_indices : [item?.indice] const entry = selectedByModel.get(modelKey) || { nome: modelName, indices: [], seen: new Set(), } indices.forEach((indice) => { const text = String(indice ?? '').trim() if (!text || entry.seen.has(text)) return entry.seen.add(text) entry.indices.push(text) }) selectedByModel.set(modelKey, entry) }) }) return Array.from(selectedByModel.values()) .map((entry) => ({ nome: entry.nome, indices: entry.indices.sort(naturalCompare), })) .filter((entry) => entry.indices.length) } function selectedTotal(models) { return (models || []).reduce((total, item) => total + (item.indices?.length || 0), 0) } function renderLassoPanel() { if (!lassoRoot || !lassoInteraction || !lassoHint || !lassoResults) return lassoRoot.classList.toggle('leaflet-control-measure-expanded', lassoState.panelOpen) lassoRoot.classList.toggle('is-active', lassoState.active) lassoInteraction.style.display = lassoState.panelOpen ? 'block' : 'none' if (!lassoState.panelOpen) return const total = selectedTotal(lassoState.selectedModels) const hasSelection = total > 0 const hasAttempt = lassoState.points.length > 0 || !!lassoState.error if (lassoState.error) { lassoHint.textContent = lassoState.error } else if (lassoState.active) { lassoHint.textContent = 'Arraste no mapa para contornar os dados. Solte para concluir.' } else if (lassoState.selectedModels.length) { lassoHint.textContent = `${total.toLocaleString('pt-BR')} INDICE(s) selecionado(s).` } else { lassoHint.textContent = 'Crie um laço sobre os dados de mercado.' } lassoResults.innerHTML = lassoState.selectedModels.length ? lassoState.selectedModels.map((item) => ( '
' + `${escapeHtml(item.nome)}` + `${escapeHtml(String(item.indices.length))} INDICE(s)` + '
' )).join('') : '' lassoResults.style.display = lassoState.selectedModels.length ? 'block' : 'none' lassoStartButton.style.display = lassoState.active ? 'none' : 'inline-flex' lassoStartButton.textContent = hasSelection ? 'Novo laço' : (hasAttempt ? 'Tentar novamente' : 'Criar laço') lassoStartButton.disabled = lassoState.downloading lassoStartButton.style.order = hasSelection ? '2' : '1' lassoCancelButton.style.display = lassoState.active ? 'inline-flex' : 'none' lassoCancelButton.textContent = 'Cancelar' lassoCancelButton.disabled = lassoState.downloading lassoCancelButton.style.order = '1' lassoDownloadButton.style.display = hasSelection && !lassoState.active ? 'inline-flex' : 'none' lassoDownloadButton.disabled = lassoState.downloading lassoDownloadButton.textContent = lassoState.downloading ? 'Baixando...' : 'Baixar XLSX' lassoDownloadButton.style.order = '1' lassoCloseButton.style.display = !lassoState.active ? 'inline-flex' : 'none' lassoCloseButton.disabled = lassoState.downloading lassoCloseButton.style.order = '3' } function resetLassoState({ closePanel = false } = {}) { lassoState.active = false lassoState.drawing = false lassoState.points = [] lassoState.selectedModels = [] lassoState.error = '' lassoLayerGroup.clearLayers() restoreMapInteractions?.() if (closePanel) { lassoState.panelOpen = false } renderLassoPanel() } function startLasso() { lassoState.panelOpen = true lassoState.active = true lassoState.drawing = false lassoState.points = [] lassoState.selectedModels = [] lassoState.error = '' lassoLayerGroup.clearLayers() disableInteractionsForLasso() renderLassoPanel() } function finishLasso() { if (!lassoState.active) return lassoState.active = false lassoState.drawing = false restoreMapInteractions?.() if (lassoState.points.length < 3) { lassoState.error = 'Desenhe uma área maior para selecionar.' lassoState.selectedModels = [] renderLassoGeometry() renderLassoPanel() return } lassoState.selectedModels = getSelectedMarketModels(lassoState.points) if (!lassoState.selectedModels.length) { lassoState.error = 'Nenhum dado de mercado selecionado.' } renderLassoGeometry() renderLassoPanel() } function onLassoMouseDown(event) { if (!lassoState.active || !event?.latlng) return L.DomEvent.stop(event) lassoState.drawing = true lassoState.points = [event.latlng] lassoState.selectedModels = [] lassoState.error = '' renderLassoGeometry() renderLassoPanel() } function onLassoMouseMove(event) { if (!lassoState.active || !lassoState.drawing || !event?.latlng) return const lastPoint = lassoState.points[lassoState.points.length - 1] if (lastPoint && map.distance(lastPoint, event.latlng) < 2) return lassoState.points = [...lassoState.points, event.latlng] renderLassoGeometry() } function onLassoMouseUp(event) { if (!lassoState.active || !lassoState.drawing) return L.DomEvent.stop(event) finishLasso() } async function downloadLassoSelection() { if (!lassoState.selectedModels.length || lassoState.downloading) return lassoState.downloading = true lassoState.error = '' renderLassoPanel() try { const response = await fetch(apiUrl('/api/pesquisa/mapa-indices-selecionados/export'), { method: 'POST', headers: { 'Content-Type': 'application/json', ...(getAuthToken() ? { 'X-Auth-Token': getAuthToken() } : {}), }, body: JSON.stringify({ modelos: lassoState.selectedModels }), }) if (!response.ok) { const payloadResp = await readJsonSafely(response) throw new Error(String(payloadResp?.detail || 'Falha ao exportar seleção.')) } const blob = await response.blob() downloadBlob(blob, 'indices_mapa_pesquisa.xlsx') } catch (error) { lassoState.error = error?.message || 'Falha ao exportar seleção.' } finally { lassoState.downloading = false renderLassoPanel() } } map.on('mousedown', onLassoMouseDown) map.on('mousemove', onLassoMouseMove) map.on('mouseup', onLassoMouseUp) cleanupLasso = () => { map.off('mousedown', onLassoMouseDown) map.off('mousemove', onLassoMouseMove) map.off('mouseup', onLassoMouseUp) lassoLayerGroup.clearLayers() } const lassoControl = L.control({ position: 'topright' }) lassoControl.onAdd = () => { lassoRoot = L.DomUtil.create('div', 'leaflet-control-measure mesa-leaflet-lasso leaflet-bar leaflet-control') lassoRoot.setAttribute('aria-haspopup', 'true') const toggle = L.DomUtil.create('a', 'leaflet-control-measure-toggle mesa-leaflet-lasso-toggle', lassoRoot) toggle.href = '#' toggle.title = 'Selecionar dados por laço' toggle.setAttribute('aria-label', 'Selecionar dados por laço') toggle.innerHTML = 'Selecionar dados por laço' lassoInteraction = L.DomUtil.create('div', 'leaflet-control-measure-interaction mesa-leaflet-measure-interaction', lassoRoot) lassoInteraction.style.display = 'none' const title = L.DomUtil.create('h3', 'mesa-leaflet-measure-title', lassoInteraction) title.textContent = 'Selecionar por laço' lassoHint = L.DomUtil.create('p', 'mesa-leaflet-measure-hint', lassoInteraction) lassoResults = L.DomUtil.create('div', 'mesa-leaflet-measure-results', lassoInteraction) const actions = L.DomUtil.create('div', 'mesa-leaflet-measure-actions', lassoInteraction) lassoStartButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn is-primary', actions) lassoStartButton.type = 'button' lassoStartButton.textContent = 'Criar laço' lassoCancelButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn', actions) lassoCancelButton.type = 'button' lassoCancelButton.textContent = 'Cancelar' lassoDownloadButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn is-primary', actions) lassoDownloadButton.type = 'button' lassoDownloadButton.textContent = 'Baixar XLSX' lassoCloseButton = L.DomUtil.create('button', 'mesa-leaflet-measure-btn', actions) lassoCloseButton.type = 'button' lassoCloseButton.textContent = 'Fechar' L.DomEvent.disableClickPropagation(lassoRoot) L.DomEvent.disableScrollPropagation(lassoRoot) L.DomEvent.on(toggle, 'click', (event) => { L.DomEvent.stop(event) if (lassoState.active) return lassoState.panelOpen = !lassoState.panelOpen renderLassoPanel() }) L.DomEvent.on(lassoStartButton, 'click', (event) => { L.DomEvent.stop(event) startLasso() }) L.DomEvent.on(lassoCancelButton, 'click', (event) => { L.DomEvent.stop(event) resetLassoState() }) L.DomEvent.on(lassoDownloadButton, 'click', (event) => { L.DomEvent.stop(event) void downloadLassoSelection() }) L.DomEvent.on(lassoCloseButton, 'click', (event) => { L.DomEvent.stop(event) if (lassoState.active) return lassoState.panelOpen = false renderLassoPanel() }) renderLassoPanel() return lassoRoot } lassoControl.addTo(map) } if (payload.controls?.fullscreen) { addFullscreenControl(map, onToggleFullscreen) } 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?.() cleanupLasso?.() if (popupLoaderRef.current === carregarPopupRegistro) { popupLoaderRef.current = null } mapContainer?.removeEventListener('click', handlePopupActionClick, true) groupSelectionMarkerRef.current = null if (mapRef.current === map) { mapRef.current = null } map.remove() } }, [payload, sessionId, onToggleFullscreen]) return (
{runtimeError ?
{runtimeError}
: null} {groupSelection ? (
event.stopPropagation()} >
{groupSelection.title} {!groupSelection.hideCount ? ( {Number(groupSelection.count) || groupSelection.items?.length || 0} registros sobrepostos ) : null}
{(groupSelection.items || []).map((item, index) => { const disabled = !item?.popup_request return ( ) })}
) : null}
) }) export default LeafletMapFrame