282 lines
12 KiB
TypeScript
282 lines
12 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import type { Map as LeafletMap, Marker } 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 = {
|
||
selected_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 default function InteractiveInvestmentMap({
|
||
points,
|
||
areas = noAreas,
|
||
contextOnly = false,
|
||
}: {
|
||
points: InvestmentMapFeature[] | null;
|
||
areas?: InvestmentMapArea[];
|
||
contextOnly?: boolean;
|
||
}) {
|
||
const element = useRef<HTMLDivElement>(null);
|
||
const map = useRef<LeafletMap | null>(null);
|
||
const markers = useRef(new Map<number, Marker>());
|
||
const [selected, setSelected] = useState<number | null>(null);
|
||
const [error, setError] = useState('');
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
let observer: ResizeObserver | undefined;
|
||
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:
|
||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 bounds: [number, number][] = [];
|
||
const hasSelectedBoundary = areas.some((area) => area.selected_territory);
|
||
const administrativeLevel: Record<string, number> = {
|
||
province: 1, district: 2, admin_post: 3, locality: 4,
|
||
};
|
||
const closestContextLevel = contextOnly
|
||
? Math.max(0, ...areas.map((area) => administrativeLevel[area.location_type ?? ''] ?? 0))
|
||
: 0;
|
||
for (const area of areas) {
|
||
const label = document.createElement('p');
|
||
label.textContent = `${area.title} · ${area.purpose === 'administrative_boundary' ? 'Limite administrativo da edição indicada; não representa um local de investimento' : 'Polígono cadastrado, limites oficiais por verificar'} · 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,
|
||
},
|
||
}
|
||
: {},
|
||
)
|
||
.bindPopup(label)
|
||
.addTo(instance);
|
||
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()],
|
||
);
|
||
}
|
||
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: `<span style="display:grid;place-items:center;width:32px;height:32px;border-radius:50%;background:#065f46;color:white;border:2px solid white;font-weight:bold">${index + 1}</span>`,
|
||
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(instance);
|
||
markers.current.set(index, marker);
|
||
bounds.push([latitude, longitude]);
|
||
}
|
||
if (bounds.length)
|
||
instance.fitBounds(bounds, {
|
||
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();
|
||
};
|
||
}, [points, areas]);
|
||
return (
|
||
<section
|
||
className="space-y-3 rounded-xl border bg-white p-4 text-slate-900"
|
||
aria-label={
|
||
contextOnly
|
||
? 'Mapa dos limites administrativos'
|
||
: 'Mapa interactivo dos investimentos'
|
||
}
|
||
>
|
||
<h2 className="text-xl font-bold">
|
||
{contextOnly
|
||
? 'Contexto administrativo'
|
||
: 'Mapa dos investimentos'}
|
||
</h2>
|
||
<p>
|
||
Arraste para navegar; use +/− para ampliar. Pode seleccionar um
|
||
{contextOnly
|
||
? 'limite cadastrado para consultar a sua edição.'
|
||
: 'investimento na lista para o localizar.'}
|
||
</p>
|
||
{error && <p role="status">{error}</p>}
|
||
<div
|
||
ref={element}
|
||
className="relative z-0 h-[460px] w-full min-w-0 overflow-hidden rounded"
|
||
role="region"
|
||
aria-label="Mapa; navegação por setas e teclas mais e menos"
|
||
/>
|
||
{points === null ? (
|
||
<p role="status">A carregar localizações…</p>
|
||
) : (
|
||
<p role="status">
|
||
{contextOnly
|
||
? `${areas.length} limites administrativos disponíveis.`
|
||
: `${points.length} locais com coordenadas disponíveis.`}
|
||
</p>
|
||
)}
|
||
<ul className="grid gap-2 md:grid-cols-2">
|
||
{(points ?? []).map((p, index) => (
|
||
<li
|
||
key={index}
|
||
className={
|
||
selected === index
|
||
? 'rounded border-2 border-emerald-700 p-3'
|
||
: 'rounded border p-3'
|
||
}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="text-left font-bold underline"
|
||
aria-pressed={selected === index}
|
||
onClick={() => {
|
||
const marker = markers.current.get(index);
|
||
if (marker) {
|
||
map.current?.setView(
|
||
marker.getLatLng(),
|
||
14,
|
||
{ animate: false },
|
||
);
|
||
marker.openPopup();
|
||
setSelected(index);
|
||
}
|
||
}}
|
||
>
|
||
{p.properties.title}
|
||
</button>
|
||
<p>
|
||
{p.properties.category} · {p.properties.location}
|
||
</p>
|
||
<p>
|
||
Fonte:{' '}
|
||
{p.properties.source_reference ?? 'Não indicada'} ·
|
||
Data:{' '}
|
||
{p.properties.source_date ?? 'Não documentada'}
|
||
</p>
|
||
<a className="underline" href={p.properties.url}>
|
||
Abrir ficha
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{areas.map((area, index) => (
|
||
<p key={index}>
|
||
<a className="underline" href={area.url}>
|
||
{area.title}
|
||
</a>{' '}
|
||
·{' '}
|
||
{area.purpose === 'administrative_boundary'
|
||
? 'Limite administrativo da edição indicada; não representa um local de investimento'
|
||
: 'Polígono cadastrado; comparação com limite administrativo pendente'}{' '}
|
||
· Fonte: {area.source_reference ?? 'Não indicada'} · Data:{' '}
|
||
{area.source_date ?? 'Não documentada'}
|
||
</p>
|
||
))}
|
||
</section>
|
||
);
|
||
}
|