Files
Investidor-V2/resources/js/components/interactive-investment-map.tsx
T
2026-09-29 08:41:57 +02:00

180 lines
7.6 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 "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:
'&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][] = [];
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>
);
}