mesa-react / frontend /src /components /LeafletMapFrame.jsx
Guilherme Silberfarb Costa
Refine map markers and elaboracao diagnostics
1aafde3
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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
function buildTooltipHtml(tooltip) {
const title = String(tooltip?.title || '').trim()
const label = String(tooltip?.label || '').trim()
const value = String(tooltip?.value || '').trim()
return [
'<div style="font-family:\'Segoe UI\',Arial,sans-serif; font-size:14px; line-height:1.7; padding:2px 4px;">',
title ? `<b>${escapeHtml(title)}</b>` : '',
label && value ? `<br><span style="color:#555;">${escapeHtml(label)}:</span> <b>${escapeHtml(value)}</b>` : '',
'</div>',
].join('')
}
function buildPopupErrorHtml(message) {
const text = escapeHtml(message || 'Falha ao carregar os dados do registro.')
return (
'<div style="font-family:\'Segoe UI\'; border-radius:8px; overflow:hidden; width:340px; max-width:340px;">'
+ '<div style="background:#6c757d; color:white; padding:10px 15px; font-weight:600;">Dados do Registro</div>'
+ `<div style="padding:12px 15px; background:#f8f9fa; color:#b42318; font-size:12px;">${text}</div>`
+ '</div>'
)
}
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
? (
'<div class="mesa-leaflet-legend-ticks">'
+ 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 (
`<span class="mesa-leaflet-legend-tick" style="left:${left.toFixed(4)}%;">`
+ `${escapeHtml(String(tickLabels[index] || ''))}`
+ '</span>'
)
}).join('')
+ '</div>'
)
: ''
const scaleHtml = hasTicks
? ''
: (
'<div class="mesa-leaflet-legend-scale">'
+ `<span>${escapeHtml(formatLegendNumber(legend?.vmin))}</span>`
+ `<span>${escapeHtml(formatLegendNumber(legend?.vmax))}</span>`
+ '</div>'
)
return [
title ? `<strong>${title}</strong>` : '',
`<div class="mesa-leaflet-legend-bar" style="background: linear-gradient(90deg, ${gradient});"></div>`,
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 (
'<div style="transform: translate(10px, -14px);display:inline-block;background: rgba(255, 255, 255, 0.9);'
+ 'border: 1px solid rgba(28, 45, 66, 0.45);border-radius: 10px;padding: 1px 6px;font-size: 11px;'
+ 'font-weight: 700;line-height: 1.2;color: #1f2f44;white-space: nowrap;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);'
+ 'pointer-events: none;">'
+ `${escapeHtml(indice)}`
+ '</div>'
)
}
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
? '&copy; OpenStreetMap contributors'
: '&copy; OpenStreetMap contributors &copy; 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(
'<div style="font-family:\'Segoe UI\'; color:#6c757d; font-size:12px;">Carregando detalhes...</div>',
{ 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(
'<div class="mesa-leaflet-measure-row">'
+ '<strong>Último ponto</strong>'
+ `<span>${escapeHtml(formatLatLng(lastPoint))}</span>`
+ `<span>${escapeHtml(formatDecimalLatLng(lastPoint))}</span>`
+ '</div>',
)
}
if (distance > 0) {
parts.push(
'<div class="mesa-leaflet-measure-row">'
+ '<strong>Distância total</strong>'
+ `<span>${escapeHtml(formatDistance(distance))}</span>`
+ '</div>',
)
}
if (area > 0) {
parts.push(
'<div class="mesa-leaflet-measure-row">'
+ '<strong>Área</strong>'
+ `<span>${escapeHtml(formatArea(area))}</span>`
+ '</div>',
)
}
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 = '<span class="mesa-sr-only">Medir distâncias</span>'
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 (
<div className="map-frame leaflet-map-host">
<div ref={hostRef} className="leaflet-map-canvas" />
{runtimeError ? <div className="leaflet-map-runtime-error">{runtimeError}</div> : null}
</div>
)
})
export default LeafletMapFrame