152 lines
5.3 KiB
TypeScript
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="© 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>
|
|
);
|
|
}
|