Spaces:
Running
Running
| import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' | |
| import { apiUrl, getAuthToken } from '../api' | |
| import LeafletMapFrame from './LeafletMapFrame' | |
| function hashHtml(value) { | |
| let hash = 0 | |
| const step = Math.max(1, Math.floor(value.length / 64)) | |
| for (let i = 0; i < value.length; i += step) { | |
| hash = ((hash << 5) - hash) + value.charCodeAt(i) | |
| hash |= 0 | |
| } | |
| return `${value.length}-${Math.abs(hash)}` | |
| } | |
| const MapFrame = forwardRef(function MapFrame({ html, payload = null, sessionId = '' }, ref) { | |
| const shellRef = useRef(null) | |
| const iframeRef = useRef(null) | |
| const leafletRef = useRef(null) | |
| const timersRef = useRef([]) | |
| const [isFullscreen, setIsFullscreen] = useState(false) | |
| const frameKey = useMemo(() => hashHtml(html || ''), [html]) | |
| const clearTimers = useCallback(() => { | |
| timersRef.current.forEach((id) => window.clearTimeout(id)) | |
| timersRef.current = [] | |
| }, []) | |
| const recenterFromLayers = useCallback(() => { | |
| const iframe = iframeRef.current | |
| if (!iframe) return | |
| try { | |
| const win = iframe.contentWindow | |
| const doc = iframe.contentDocument || win?.document | |
| if (!win || !doc || !win.L) return | |
| const maps = Object.values(win).filter( | |
| (item) => item | |
| && typeof item.fitBounds === 'function' | |
| && typeof item.eachLayer === 'function' | |
| && typeof item.invalidateSize === 'function', | |
| ) | |
| const map = maps[0] | |
| if (!map) return | |
| map.invalidateSize(true) | |
| const dataBounds = win.L.latLngBounds([]) | |
| const 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) | |
| }) | |
| } | |
| } | |
| const isBairroLayer = (layer) => { | |
| try { | |
| const nome = String(layer?.options?.name || layer?.layerName || '').toLowerCase() | |
| if (nome.includes('bairro')) return true | |
| const featureName = String(layer?.feature?.properties?.NOME || layer?.feature?.properties?.BAIRRO || '').toLowerCase() | |
| return featureName.length > 0 | |
| } catch { | |
| return false | |
| } | |
| } | |
| const shouldIgnoreForBounds = (layer) => { | |
| try { | |
| return Boolean(layer?.options?.mesaIgnoreBounds) | |
| } catch { | |
| return false | |
| } | |
| } | |
| const visited = new Set() | |
| map.eachLayer((layer) => { | |
| visitLayerTree(layer, visited, (item) => { | |
| try { | |
| if (item instanceof win.L.TileLayer) return | |
| if (isBairroLayer(item)) return | |
| if (shouldIgnoreForBounds(item)) return | |
| if (item instanceof win.L.CircleMarker || item instanceof win.L.Marker) { | |
| const latlng = item.getLatLng() | |
| if (latlng && Number.isFinite(latlng.lat) && Number.isFinite(latlng.lng)) { | |
| dataBounds.extend(latlng) | |
| } | |
| return | |
| } | |
| const isContainerLayer = typeof item.eachLayer === 'function' | |
| if (!isContainerLayer && typeof item.getBounds === 'function') { | |
| const itemBounds = item.getBounds() | |
| if (itemBounds && typeof itemBounds.isValid === 'function' && itemBounds.isValid()) { | |
| dataBounds.extend(itemBounds) | |
| } | |
| } | |
| } catch { | |
| // no-op | |
| } | |
| }) | |
| }) | |
| if (dataBounds.isValid()) { | |
| const size = map.getSize ? map.getSize() : null | |
| const basePadding = size | |
| ? Math.max(34, Math.min(84, Math.round(Math.min(size.x, size.y) * 0.085))) | |
| : 48 | |
| map.fitBounds(dataBounds, { padding: [basePadding, basePadding], maxZoom: 18, animate: false }) | |
| } | |
| } catch { | |
| // no-op | |
| } | |
| }, []) | |
| const scheduleRecenter = useCallback(() => { | |
| clearTimers() | |
| ;[40, 180, 520, 1100].forEach((delay) => { | |
| const timerId = window.setTimeout(() => { | |
| recenterFromLayers() | |
| }, delay) | |
| timersRef.current.push(timerId) | |
| }) | |
| }, [clearTimers, recenterFromLayers]) | |
| const refreshMapSize = useCallback(() => { | |
| if (leafletRef.current?.fitToPayloadBounds) { | |
| window.setTimeout(() => { | |
| leafletRef.current?.fitToPayloadBounds?.(48) | |
| }, 120) | |
| return | |
| } | |
| scheduleRecenter() | |
| }, [scheduleRecenter]) | |
| const compartilharContextoComIframe = useCallback(() => { | |
| const frameWindow = iframeRef.current?.contentWindow | |
| if (!frameWindow) return | |
| try { | |
| frameWindow.__MESA_MAP_CONTEXT__ = { | |
| apiBase: apiUrl(''), | |
| authToken: getAuthToken(), | |
| } | |
| } catch { | |
| // O iframe ainda pode usar o endpoint relativo na versão publicada. | |
| } | |
| }, []) | |
| const toggleFullscreen = useCallback(async (event) => { | |
| event.preventDefault() | |
| event.stopPropagation() | |
| const shell = shellRef.current | |
| if (!shell) return | |
| try { | |
| if (document.fullscreenElement === shell) { | |
| await document.exitFullscreen() | |
| } else if (typeof shell.requestFullscreen === 'function') { | |
| await shell.requestFullscreen() | |
| } | |
| } catch { | |
| // Navegadores podem bloquear fullscreen fora de interação direta. | |
| } | |
| }, []) | |
| useEffect(() => { | |
| return () => { | |
| clearTimers() | |
| } | |
| }, [clearTimers]) | |
| useEffect(() => { | |
| function onFullscreenChange() { | |
| const active = document.fullscreenElement === shellRef.current | |
| setIsFullscreen(active) | |
| refreshMapSize() | |
| } | |
| document.addEventListener('fullscreenchange', onFullscreenChange) | |
| return () => document.removeEventListener('fullscreenchange', onFullscreenChange) | |
| }, [refreshMapSize]) | |
| useImperativeHandle(ref, () => ({ | |
| fitToPayloadBounds(padding = 48) { | |
| if (leafletRef.current?.fitToPayloadBounds) { | |
| return leafletRef.current.fitToPayloadBounds(padding) | |
| } | |
| return false | |
| }, | |
| async downloadSelectionPng(selectionRect, fileName = 'mapa-recorte.png') { | |
| if (leafletRef.current?.downloadSelectionPng) { | |
| return leafletRef.current.downloadSelectionPng(selectionRect, fileName) | |
| } | |
| throw new Error('A exportacao em PNG esta disponivel apenas para mapas interativos.') | |
| }, | |
| async downloadVisiblePng(fileName = 'mapa.png') { | |
| if (leafletRef.current?.downloadVisiblePng) { | |
| return leafletRef.current.downloadVisiblePng(fileName) | |
| } | |
| throw new Error('A exportacao em PNG esta disponivel apenas para mapas interativos.') | |
| }, | |
| }), []) | |
| if (!(payload && payload.type === 'mesa_leaflet_payload') && !html) { | |
| return <div className="empty-box">Mapa indisponivel.</div> | |
| } | |
| const mapContent = payload && payload.type === 'mesa_leaflet_payload' | |
| ? ( | |
| <LeafletMapFrame | |
| ref={leafletRef} | |
| payload={payload} | |
| sessionId={sessionId} | |
| onToggleFullscreen={toggleFullscreen} | |
| /> | |
| ) | |
| : ( | |
| <iframe | |
| key={frameKey} | |
| ref={iframeRef} | |
| title="mapa" | |
| className="map-frame" | |
| srcDoc={html} | |
| sandbox="allow-scripts allow-same-origin allow-popups" | |
| allow="fullscreen" | |
| allowFullScreen | |
| onLoad={() => { | |
| compartilharContextoComIframe() | |
| scheduleRecenter() | |
| }} | |
| /> | |
| ) | |
| return ( | |
| <div ref={shellRef} className={`map-frame-shell${isFullscreen ? ' is-fullscreen' : ''}`}> | |
| {mapContent} | |
| </div> | |
| ) | |
| }) | |
| export default MapFrame | |