520 lines
23 KiB
TypeScript
520 lines
23 KiB
TypeScript
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:
|
||
'© <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>
|
||
);
|
||
}
|