Files
Investidor-V2/resources/js/components/interactive-investment-map.tsx
T

282 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:
'&copy; <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>
);
}