180 lines
7.6 KiB
TypeScript
180 lines
7.6 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import type { Map as LeafletMap, Marker } from "leaflet";
|
||
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[];
|
||
};
|
||
};
|
||
|
||
export default function InteractiveInvestmentMap({
|
||
points,
|
||
}: {
|
||
points: InvestmentMapFeature[] | null;
|
||
}) {
|
||
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,
|
||
}).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][] = [];
|
||
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,
|
||
`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)
|
||
.on("click", () => setSelected(p.id))
|
||
.addTo(instance);
|
||
markers.current.set(p.id, marker);
|
||
bounds.push([latitude, longitude]);
|
||
}
|
||
if (bounds.length) instance.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 });
|
||
observer = new ResizeObserver(() => instance.invalidateSize());
|
||
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]);
|
||
return (
|
||
<section
|
||
className="space-y-3 rounded-xl border bg-white p-4 text-slate-900"
|
||
aria-label="Mapa interactivo dos investimentos"
|
||
>
|
||
<h2 className="text-xl font-bold">Mapa dos investimentos</h2>
|
||
<p>
|
||
Arraste para navegar; use +/− para ampliar. Pode seleccionar um investimento na
|
||
lista para o localizar.
|
||
</p>
|
||
{error && <p role="status">{error}</p>}
|
||
<div
|
||
ref={element}
|
||
className="relative z-0 h-[460px] w-full rounded"
|
||
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">{points.length} investimentos com coordenadas disponíveis.</p>
|
||
)}
|
||
<ul className="grid gap-2 md:grid-cols-2">
|
||
{(points ?? []).map((p) => (
|
||
<li
|
||
key={p.properties.id}
|
||
className={
|
||
selected === p.properties.id
|
||
? "rounded border-2 border-emerald-700 p-3"
|
||
: "rounded border p-3"
|
||
}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="text-left font-bold underline"
|
||
aria-pressed={selected === p.properties.id}
|
||
onClick={() => {
|
||
const marker = markers.current.get(p.properties.id);
|
||
if (marker) {
|
||
map.current?.setView(marker.getLatLng(), 14);
|
||
marker.openPopup();
|
||
setSelected(p.properties.id);
|
||
}
|
||
}}
|
||
>
|
||
{p.properties.title}
|
||
</button>
|
||
<p>
|
||
{p.properties.category} · {p.properties.location}
|
||
</p>
|
||
<a className="underline" href={p.properties.url}>
|
||
Abrir ficha
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
);
|
||
}
|