Spaces:
Sleeping
Sleeping
| import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' | |
| import L from 'leaflet' | |
| import 'leaflet.fullscreen' | |
| import 'leaflet.heat' | |
| import { apiUrl, downloadBlob, getAuthToken } from '../api' | |
| let bairrosGeojsonCache = null | |
| let bairrosGeojsonPromise = null | |
| function escapeHtml(value) { | |
| return String(value ?? '') | |
| .replaceAll('&', '&') | |
| .replaceAll('<', '<') | |
| .replaceAll('>', '>') | |
| .replaceAll('"', '"') | |
| } | |
| function buildTooltipHtml(tooltip) { | |
| const title = String(tooltip?.title || '').trim() | |
| const label = String(tooltip?.label || '').trim() | |
| const value = String(tooltip?.value || '').trim() | |
| return [ | |
| '<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 | |
| ? '© OpenStreetMap contributors' | |
| : '© OpenStreetMap contributors © CARTO', | |
| crossOrigin: 'anonymous', | |
| detectRetina: true, | |
| }) | |
| const label = String(layerDef?.label || layerDef?.id || `Base ${index + 1}`) | |
| baseLayers[label] = tileLayer | |
| if (index === 0) { | |
| tileLayer.addTo(map) | |
| } | |
| }) | |
| const overlaySpecs = Array.isArray(payload.overlay_layers) && payload.overlay_layers.length | |
| ? payload.overlay_layers | |
| : buildLegacyOverlayLayers(payload) | |
| const responsivePointContainers = [] | |
| const radiusBehavior = payload.radius_behavior || {} | |
| const minRadius = Number(radiusBehavior.min_radius) || 1.6 | |
| const maxRadius = Number(radiusBehavior.max_radius) || 52.0 | |
| const referenceZoom = Number(radiusBehavior.reference_zoom) || 12.0 | |
| const growthFactor = Number(radiusBehavior.growth_factor) || 0.2 | |
| function clamp(value, min, max) { | |
| return Math.max(min, Math.min(max, value)) | |
| } | |
| function visitLayerTree(layer, visited, fn) { | |
| if (!layer || !layer._leaflet_id || visited.has(layer._leaflet_id)) return | |
| visited.add(layer._leaflet_id) | |
| fn(layer) | |
| if (typeof layer.eachLayer === 'function') { | |
| layer.eachLayer((child) => { | |
| visitLayerTree(child, visited, fn) | |
| }) | |
| } | |
| } | |
| function applyResponsiveRadius() { | |
| const zoom = typeof map.getZoom === 'function' ? map.getZoom() : referenceZoom | |
| const zoomDelta = zoom - referenceZoom | |
| const expFactor = 2 ** (zoomDelta * growthFactor) | |
| let floorScale = 1.0 | |
| if (zoomDelta >= 0) { | |
| floorScale = 1 + zoomDelta * 0.22 | |
| if (zoom >= 15) { | |
| floorScale += (zoom - 14) * 0.30 | |
| } | |
| } else { | |
| floorScale = Math.max(0.28, 1 + zoomDelta * 0.20) | |
| } | |
| responsivePointContainers.forEach((container) => { | |
| if (!container || typeof container.eachLayer !== 'function') return | |
| container.eachLayer((layer) => { | |
| if (typeof layer.setRadius !== 'function') return | |
| const base = Number(layer.options?.mesaBaseRadius || layer.options?.radius || 4) | |
| const dynamicMin = Math.max(minRadius, base * floorScale) | |
| const dynamicMax = Math.max(dynamicMin + 0.1, Math.min(maxRadius, base * 8.0)) | |
| layer.setRadius(clamp(base * expFactor, dynamicMin, dynamicMax)) | |
| }) | |
| }) | |
| } | |
| async function carregarPopupVisualizacao(rowId, layer) { | |
| const cacheKey = String(rowId) | |
| const cached = popupCacheRef.current.get(cacheKey) | |
| if (cached) { | |
| const cachedHtml = typeof cached === 'string' ? cached : String(cached?.html || '') | |
| const cachedWidth = typeof cached === 'string' ? 420 : (Number(cached?.width) || 420) | |
| layer.unbindPopup() | |
| layer.bindPopup(cachedHtml, { maxWidth: cachedWidth }).openPopup() | |
| window.setTimeout(() => { | |
| const root = layer.getPopup()?.getElement()?.querySelector('[data-pager]') | |
| if (root) ensurePopupPager(root) | |
| }, 0) | |
| return | |
| } | |
| layer.bindPopup( | |
| '<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 | |