Files
pet-adviser/src/components/Map/CompanyMap.tsx
2025-08-16 13:08:52 +02:00

152 lines
5.3 KiB
TypeScript

"use client";
import { Box, Badge, Flex, Text } from "@chakra-ui/react";
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
import L, { DivIcon, LatLngBoundsLiteral } from "leaflet";
import "leaflet/dist/leaflet.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
import MarkerClusterGroup from "react-leaflet-markercluster";
import { useEffect, useMemo } from "react";
export type Company = {
id: string;
name: string;
industry: "Tech" | "Retail" | "Manufacturing" | "Food" | "Healthcare";
lat: number;
lng: number;
address?: string;
};
type Props = {
companies: Company[];
selectedId?: string | null;
onSelect?: (id: string) => void;
autoFit?: boolean;
};
const industryEmoji: Record<Company["industry"], string> = {
Tech: "💻",
Retail: "🛒",
Manufacturing: "🏭",
Food: "🍽️",
Healthcare: "🏥",
};
const industryColor: Record<Company["industry"], string> = {
Tech: "#3b82f6",
Retail: "#f59e0b",
Manufacturing: "#6b7280",
Food: "#10b981",
Healthcare: "#ef4444",
};
function makeIcon(industry: Company["industry"], highlighted: boolean): DivIcon {
const size = highlighted ? 40 : 32;
const color = industryColor[industry];
const emoji = industryEmoji[industry];
const border = highlighted ? `3px solid ${color}` : `2px solid ${color}`;
return L.divIcon({
className: "company-div-icon",
html: `
<div style="
width:${size}px;height:${size}px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
background:#fff;border:${border};
box-shadow:0 2px 8px rgba(0,0,0,.15);
font-size:${Math.round(size * 0.55)}px;
">${emoji}</div>
`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -size / 2],
});
}
function FlyTo({ lat, lng }: { lat: number; lng: number }) {
const map = useMap();
useEffect(() => {
map.flyTo([lat, lng], Math.max(map.getZoom(), 13), { duration: 0.7 });
}, [lat, lng, map]);
return null;
}
function FitBoundsOnData({ points }: { points: { lat: number; lng: number }[] }) {
const map = useMap();
useEffect(() => {
if (!points?.length) return;
let bounds: LatLngBoundsLiteral = [
[points[0].lat, points[0].lng],
[points[0].lat, points[0].lng],
];
for (const p of points) {
bounds = L.latLngBounds(bounds).extend([p.lat, p.lng]) as any;
}
map.fitBounds(bounds, { padding: [30, 30] });
}, [points, map]);
return null;
}
export default function CompanyMap({ companies, selectedId, onSelect, autoFit = true }: Props) {
const selected = useMemo(
() => companies.find((c) => c.id === selectedId),
[companies, selectedId]
);
const center = useMemo<[number, number]>(() => {
if (!companies.length) return [46.0569, 14.5058]; // Ljubljana fallback
const lat = companies.reduce((a, c) => a + c.lat, 0) / companies.length;
const lng = companies.reduce((a, c) => a + c.lng, 0) / companies.length;
return [lat, lng];
}, [companies]);
return (
<Box w="100%" h="100%" borderRadius="2xl" overflow="hidden" bg="white">
<MapContainer
center={center}
zoom={6}
style={{ width: "100%", height: "100%" }}
preferCanvas
>
<TileLayer
attribution="&copy; OpenStreetMap"
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MarkerClusterGroup chunkedLoading>
{companies.map((c) => (
<Marker
key={c.id}
position={[c.lat, c.lng]}
icon={makeIcon(c.industry, c.id === selectedId)}
eventHandlers={{
click: () => onSelect?.(c.id),
mouseover: (e) => e.target.openPopup(),
}}
>
<Popup>
<Flex direction="column" gap={1}>
<Text fontWeight="bold">{c.name}</Text>
<Badge
w="fit-content"
colorScheme="gray"
style={{ background: industryColor[c.industry], color: "#fff" }}
>
{c.industry}
</Badge>
{c.address && <Text fontSize="sm">{c.address}</Text>}
</Flex>
</Popup>
</Marker>
))}
</MarkerClusterGroup>
{autoFit && companies.length > 0 && !selected && (
<FitBoundsOnData points={companies.map(({ lat, lng }) => ({ lat, lng }))} />
)}
{selected && <FlyTo lat={selected.lat} lng={selected.lng} />}
</MapContainer>
</Box>
);
}