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

520 lines
23 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, 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<string, InvestmentMapArea>();
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<HTMLDivElement>(null);
const map = useRef<LeafletMap | null>(null);
const markers = useRef(new Map<number, Marker>());
const pointLayer = useRef<LayerGroup | null>(null);
const visibility = useRef({
points: true,
investments: true,
administrative: true,
});
const [selected, setSelected] = useState<number | null>(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:
'&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 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<string, LayerGroup> = {};
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<string, number> = {
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: `<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(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 (
<section
className="space-y-3 rounded-xl border bg-white p-4 text-slate-900"
aria-label={title}
>
<h2 className="text-xl font-bold">{title}</h2>
<p>
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.'}
</p>
{error && <p role="status">{error}</p>}
{notice}
{layerControls && (
<div
aria-label="Legenda das camadas"
className="flex flex-wrap gap-4 text-sm"
>
<span>
<span
aria-hidden="true"
className="mr-2 inline-block h-3 w-3 rounded-full bg-emerald-800"
/>
{mapKind === 'investments'
? 'Oportunidade com coordenadas confirmadas'
: 'Local documentado com coordenadas'}
</span>
<span>
<span
aria-hidden="true"
className="mr-2 inline-block h-3 w-4 border-2 border-amber-700 bg-amber-100"
/>
{mapKind === 'investments'
? 'Área de investimento cadastrada'
: 'Área documentada'}
</span>
<span>
<span
aria-hidden="true"
className="mr-2 inline-block h-3 w-4 border-2 border-dashed border-blue-600"
/>
Limite administrativo — não indica terreno disponível
</span>
</div>
)}
<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">
{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.`}
</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) {
visibility.current.points = true;
if (pointLayer.current)
map.current?.addLayer(
pointLayer.current,
);
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>
{layerControls && (
<details className="rounded border p-3">
<summary className="cursor-pointer font-semibold">
Fontes e contexto do mapa
</summary>
<div className="mt-3 space-y-2">
{administrativeInfo}
{allAreas.map((area, index) => (
<p key={index}>
{area.url.startsWith('/') &&
!area.url.startsWith('//') ? (
<a className="underline" href={area.url}>
{area.title}
</a>
) : (
area.title
)}{' '}
· {areaDescription(area)} · Fonte:{' '}
{area.source_reference ?? 'Não indicada'} ·
Edição: {area.source_date ?? 'Não indicada'}
</p>
))}
</div>
</details>
)}
{!layerControls &&
allAreas.map((area, index) => (
<p key={index}>
<a className="underline" href={area.url}>
{area.title}
</a>{' '}
· {areaDescription(area)} · Fonte:{' '}
{area.source_reference ?? 'Não indicada'} · Data:{' '}
{area.source_date ?? 'Não documentada'}
</p>
))}
</section>
);
}