import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import type { Map as LeafletMap, Marker, LayerGroup, LayersControlEvent, } from 'leaflet'; import type { GeoJsonObject } from 'geojson'; import 'leaflet/dist/leaflet.css'; export type InvestmentMapFeature = { geometry: { coordinates: [number, number] }; properties: { id: number; title: string; location: string; url: string; category?: string; clusters?: string[]; corridors?: string[]; projects?: string[]; source_reference?: string | null; source_date?: string | null; }; }; export type InvestmentMapArea = { opportunity_id?: number; selected_territory?: boolean; child_territory?: boolean; location_type?: string; purpose?: string; geometry: GeoJsonObject; title: string; source_reference?: string | null; source_date?: string | null; url: string; }; const noAreas: InvestmentMapArea[] = []; export type InvestmentMapKind = | 'investments' | 'administrative' | 'documented_locations' | 'declared_parcel'; export default function InteractiveInvestmentMap({ points, areas = noAreas, contextOnly = false, mapKind = contextOnly ? 'administrative' : 'investments', administrativeAreas = noAreas, layerControls = false, administrativeInfo, notice, }: { points: InvestmentMapFeature[] | null; areas?: InvestmentMapArea[]; contextOnly?: boolean; mapKind?: InvestmentMapKind; administrativeAreas?: InvestmentMapArea[]; layerControls?: boolean; administrativeInfo?: ReactNode; notice?: ReactNode; }) { const title = { investments: 'Mapa dos investimentos', administrative: 'Contexto administrativo', documented_locations: 'Mapa dos locais documentados', declared_parcel: 'Perímetro declarado pelo Promotor', }[mapKind]; const areaDescription = (area: InvestmentMapArea) => area.purpose === 'administrative_boundary' ? `${area.selected_territory ? 'Território seleccionado' : area.child_territory ? 'Subdivisão do território seleccionado' : 'Contexto de nível superior'} · Limite administrativo da edição indicada; não representa um local de investimento` : area.purpose === 'declared_parcel' ? 'Perímetro declarado pelo Promotor; não é limite administrativo nem comprova disponibilidade legal' : mapKind === 'documented_locations' ? 'Área documentada na fonte; não confirma implantação de investimento' : 'Polígono cadastrado; comparação com limite administrativo pendente'; const allAreas = useMemo(() => { const unique = new Map(); for (const area of [ ...areas, ...administrativeAreas.map((area) => ({ ...area, purpose: 'administrative_boundary', })), ]) { const key = JSON.stringify([ area.geometry, area.purpose, area.title, area.source_reference, area.source_date, ]); const previous = unique.get(key); unique.set(key, { ...area, selected_territory: Boolean( previous?.selected_territory || area.selected_territory, ), }); } return [...unique.values()]; }, [areas, administrativeAreas]); const element = useRef(null); const map = useRef(null); const markers = useRef(new Map()); const pointLayer = useRef(null); const visibility = useRef({ points: true, investments: true, administrative: true, }); const [selected, setSelected] = useState(null); const [error, setError] = useState(''); useEffect(() => { let cancelled = false; let observer: ResizeObserver | undefined; setError(''); setSelected(null); void import('leaflet') .then((L) => { if (cancelled || !element.current) return; const instance = L.map(element.current, { scrollWheelZoom: false, keyboard: true, zoomAnimation: false, fadeAnimation: false, markerZoomAnimation: false, }).setView([-18.5, 35], 5); map.current = instance; L.tileLayer( import.meta.env.VITE_MAP_TILE_URL || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors', }, ) .on('tileerror', () => { if (!cancelled) setError( 'Mapa base indisponível. Os marcadores e a lista continuam acessíveis.', ); }) .addTo(instance); L.control.scale({ imperial: false }).addTo(instance); const groups = { points: L.layerGroup(), investments: L.layerGroup(), administrative: L.layerGroup(), }; pointLayer.current = groups.points; for (const key of Object.keys( groups, ) as (keyof typeof groups)[]) { if (!layerControls || visibility.current[key]) groups[key].addTo(instance); } if (layerControls) { const overlays: Record = {}; if (points === null || points.length) overlays[ mapKind === 'investments' ? 'Oportunidades com coordenadas' : 'Locais documentados com coordenadas' ] = groups.points; if ( allAreas.some( (area) => area.purpose !== 'administrative_boundary', ) ) overlays[ mapKind === 'investments' ? 'Áreas de investimento cadastradas' : 'Áreas documentadas' ] = groups.investments; if ( allAreas.some( (area) => area.purpose === 'administrative_boundary', ) ) overlays['Limites administrativos'] = groups.administrative; L.control .layers(undefined, overlays, { collapsed: false }) .addTo(instance); instance.on('overlayadd overlayremove', (event) => { const changed = event as LayersControlEvent; for (const key of Object.keys( groups, ) as (keyof typeof groups)[]) { if (changed.layer === groups[key]) visibility.current[key] = event.type === 'overlayadd'; } }); } const bounds: [number, number][] = []; const selectedBounds: [number, number][] = []; const opportunityBounds: [number, number][] = []; const contextBounds: [number, number][] = []; const hasSelectedBoundary = allAreas.some( (area) => area.selected_territory, ); const administrativeLevel: Record = { province: 1, district: 2, admin_post: 3, locality: 4, }; const closestContextLevel = contextOnly || layerControls ? Math.max( 0, ...allAreas .filter( (area) => area.purpose === 'administrative_boundary', ) .map( (area) => administrativeLevel[ area.location_type ?? '' ] ?? 0, ), ) : 0; for (const area of allAreas) { const label = document.createElement('p'); label.textContent = `${area.title} · ${areaDescription(area)} · Fonte: ${area.source_reference ?? 'Não indicada'} · Data: ${area.source_date ?? 'Não documentada'}`; const layer = L.geoJSON( area.geometry, area.purpose === 'administrative_boundary' ? { style: { color: '#2563eb', dashArray: '5 5', fillOpacity: 0.06, }, } : layerControls ? { style: { color: '#b45309', fillOpacity: 0.16, }, } : {}, ) .bindPopup(label) .addTo( area.purpose === 'administrative_boundary' ? groups.administrative : groups.investments, ); const extent = layer.getBounds(); const focusBoundary = hasSelectedBoundary ? area.selected_territory : !closestContextLevel || administrativeLevel[area.location_type ?? ''] === closestContextLevel; if (extent.isValid() && focusBoundary) bounds.push( [extent.getSouth(), extent.getWest()], [extent.getNorth(), extent.getEast()], ); if (extent.isValid()) { const corners: [number, number][] = [ [extent.getSouth(), extent.getWest()], [extent.getNorth(), extent.getEast()], ]; if (area.purpose !== 'administrative_boundary') opportunityBounds.push(...corners); else { if (area.selected_territory) selectedBounds.push(...corners); if (focusBoundary) contextBounds.push(...corners); } } } for (const [index, point] of (points ?? []).entries()) { const [longitude, latitude] = point.geometry.coordinates; if ( !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180 ) continue; const { properties: p } = point; const popup = document.createElement('div'); const heading = document.createElement('strong'); heading.textContent = p.title; popup.append(heading); for (const text of [ p.category, p.location, `Fonte: ${p.source_reference ?? 'Não indicada'} · Data: ${p.source_date ?? 'Não documentada'}`, `Corredor: ${p.corridors?.join(', ') || 'Não associado'}`, `Cluster: ${p.clusters?.join(', ') || 'Não associado'}`, `Projecto âncora: ${p.projects?.join(', ') || 'Não associado'}`, ]) { const line = document.createElement('p'); line.textContent = text ?? ''; popup.append(line); } if (p.url.startsWith('/') && !p.url.startsWith('//')) { const link = document.createElement('a'); link.href = p.url; link.textContent = 'Abrir ficha detalhada'; popup.append(link); } const icon = L.divIcon({ className: '', html: `${index + 1}`, iconSize: [32, 32], iconAnchor: [16, 16], }); const marker = L.marker([latitude, longitude], { icon, title: p.title, alt: p.title, keyboard: true, }) .bindPopup(popup, { autoPan: false }) .on('click', () => setSelected(index)) .addTo(groups.points); markers.current.set(index, marker); bounds.push([latitude, longitude]); opportunityBounds.push([latitude, longitude]); } const focusBounds = layerControls ? selectedBounds.length ? selectedBounds : opportunityBounds.length ? opportunityBounds : contextBounds : bounds; if (focusBounds.length) instance.fitBounds(focusBounds, { padding: [40, 40], maxZoom: 14, animate: false, }); observer = new ResizeObserver(() => { if (!cancelled) instance.invalidateSize({ animate: false, pan: false }); }); observer.observe(element.current); }) .catch(() => { if (!cancelled) setError('Não foi possível iniciar o mapa.'); }); return () => { cancelled = true; observer?.disconnect(); map.current?.remove(); map.current = null; markers.current.clear(); pointLayer.current = null; }; }, [points, allAreas, contextOnly, layerControls, mapKind]); return (

{title}

Arraste para navegar; use +/− para ampliar.{' '} {mapKind === 'declared_parcel' ? 'Esta pré-visualização representa a declaração do Promotor, não o limite administrativo.' : mapKind === 'administrative' ? 'Seleccione um limite cadastrado para consultar a sua edição.' : 'Seleccione um local na lista para o localizar.'}

{error &&

{error}

} {notice} {layerControls && (
)}
{points === null ? (

A carregar localizações…

) : (

{mapKind === 'declared_parcel' ? `${allAreas.length} perímetro declarado em pré-visualização.` : mapKind === 'administrative' ? `${areas.length} limites administrativos disponíveis.` : `${points.length} locais com coordenadas disponíveis.`}

)}
    {(points ?? []).map((p, index) => (
  • {p.properties.category} · {p.properties.location}

    Fonte:{' '} {p.properties.source_reference ?? 'Não indicada'} · Data:{' '} {p.properties.source_date ?? 'Não documentada'}

    Abrir ficha
  • ))}
{layerControls && (
Fontes e contexto do mapa
{administrativeInfo} {allAreas.map((area, index) => (

{area.url.startsWith('/') && !area.url.startsWith('//') ? ( {area.title} ) : ( area.title )}{' '} · {areaDescription(area)} · Fonte:{' '} {area.source_reference ?? 'Não indicada'} · Edição: {area.source_date ?? 'Não indicada'}

))}
)} {!layerControls && allAreas.map((area, index) => (

{area.title} {' '} · {areaDescription(area)} · Fonte:{' '} {area.source_reference ?? 'Não indicada'} · Data:{' '} {area.source_date ?? 'Não documentada'}

))}
); }