initian commit
This commit is contained in:
151
src/components/Map/CompanyMap.tsx
Normal file
151
src/components/Map/CompanyMap.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user