Files
dkala-front/src/components/map/CustomMap.tsx
T
2026-02-10 10:05:33 +03:30

360 lines
11 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.
'use client';
import React, { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, Polygon } from 'react-leaflet';
import L from 'leaflet';
import { Skeleton } from '@/components/ui/skeleton';
import 'leaflet/dist/leaflet.css';
import './CustomMap.css';
import { ActiveSearchbox } from '../input/ActiveSearchbox';
// Define types for our props
interface ServiceArea {
type: "Polygon";
coordinates: number[][][];
}
interface CustomMapProps {
initialPosition: [number, number];
zoom?: number;
markers?: Array<{
position: [number, number];
title: string;
icon?: L.Icon | L.DivIcon;
}>;
serviceArea?: ServiceArea | null;
onPositionSelect?: (position: [number, number]) => void;
onZoomChange?: (zoom: number) => void;
onClick?: (event: L.LeafletMouseEvent) => void;
searchEnabled?: boolean
markerActive?: boolean
onMapReady?: (map: L.Map) => void;
}
// Component to handle map events and position updates
function MapEvents({
onZoomChange,
onClick,
center,
setMapRef,
}: Pick<CustomMapProps, 'onZoomChange' | 'onClick'> & {
center: [number, number];
setMapRef: (map: L.Map) => void;
}) {
const map = useMapEvents({
zoom: () => {
onZoomChange?.(map.getZoom());
},
click: (e) => {
onClick?.(e);
},
});
// Update map center when center prop changes
React.useEffect(() => {
map.setView(center, map.getZoom(), {
animate: true,
duration: 1
});
}, [map, center]);
// Set map reference
React.useEffect(() => {
setMapRef(map);
}, [map, setMapRef]);
return null;
}
// Component to handle marker updates on zoom
function MarkerUpdater({ markers }: { markers?: CustomMapProps['markers'] }) {
const map = useMap();
useEffect(() => {
// You can implement custom marker scaling or clustering logic here
const zoom = map.getZoom();
// Example: Scale markers based on zoom level
markers?.forEach(marker => {
const element = document.querySelector(`[title="${marker.title}"]`);
if (element) {
const scale = Math.min(1 + (zoom / 20), 1.5);
(element as HTMLElement).style.transform = `scale(${scale})`;
}
});
}, [map, markers]);
return null;
}
// Component to auto-open popup for shop marker
function MarkerWithAutoOpen({ position, icon, title }: { position: [number, number]; icon?: L.Icon | L.DivIcon; title: string }) {
const markerRef = React.useRef<L.Marker>(null);
useEffect(() => {
if (markerRef.current && title === 'موقعیت فروشگاه') {
markerRef.current.openPopup();
}
}, [title]);
return (
<Marker ref={markerRef} position={position} icon={icon}>
<Popup className="shop-popup">
<div className="text-xs font-medium">{title}</div>
</Popup>
</Marker>
);
}
const CustomMap: React.FC<CustomMapProps> = ({
initialPosition,
zoom = 13,
markers = [],
serviceArea,
onPositionSelect,
onZoomChange,
onClick,
markerActive = true,
searchEnabled = true,
onMapReady,
}) => {
const [selectedPosition, setSelectedPosition] = useState<[number, number] | null>(null);
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<Array<{ id: string; title: string; position: [number, number] }>>([]);
const [isMapLoading, setIsMapLoading] = useState(true);
// Handle map load complete
useEffect(() => {
const checkTiles = () => {
if (mapInstance) {
const container = document.querySelector('.leaflet-tile-container');
if (container && container.children.length > 0) {
setIsMapLoading(false);
} else {
setTimeout(checkTiles, 100);
}
}
};
checkTiles();
}, [mapInstance]);
// Notify parent when map is ready
useEffect(() => {
if (mapInstance && onMapReady) {
onMapReady(mapInstance);
}
}, [mapInstance, onMapReady]);
// Initialize default icon on client side only
useEffect(() => {
// Dynamic import of Leaflet on the client side
// Fix for default marker icon in Next.js
delete ((L.Icon.Default.prototype) as { _getIconUrl?: unknown })._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: '/marker-icon-2x.png',
iconUrl: '/marker-icon.png',
shadowUrl: '/marker-shadow.png',
});
}, []);
const handleMapClick = (e: L.LeafletMouseEvent) => {
const newPosition: [number, number] = [e.latlng.lat, e.latlng.lng];
if (markerActive) {
setSelectedPosition(newPosition);
}
onPositionSelect?.(newPosition);
onClick?.(e);
};
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout>();
const handleSearch = async (value: string) => {
if (!value || value.trim().length === 0) {
setSearchResults([]);
return;
}
// Clear previous timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
}
setIsSearching(true);
// Set new timeout
const timeout = setTimeout(async () => {
try {
// Iran's bounding box coordinates (roughly)
const viewbox = '44.0,25.0,63.0,40.0'; // [min lon, min lat, max lon, max lat]
const response = await fetch(
`https://nominatim.openstreetmap.org/search?` +
`format=json&` +
`q=${encodeURIComponent(value)}&` +
`countrycodes=ir&` + // Prioritize Iran
`viewbox=${viewbox}&` +
`bounded=0&` + // Allow results outside viewbox but prioritize inside
`limit=10&` + // Limit results to 10
`namedetails=1&` + // Get native names
`accept-language=fa` // Request Persian results
, {
headers: {
'Accept-Language': 'fa,en;q=0.9' // Prefer Persian, fallback to English
}
});
interface SearchResult {
place_id: string;
display_name: string;
lat: string;
lon: string;
namedetails?: {
name?: string;
'name:fa'?: string;
alt_name?: string;
'alt_name:fa'?: string;
};
}
const data: SearchResult[] = await response.json();
setSearchResults(data.map((item) => ({
id: item.place_id,
// Use Persian name if available, fallback to display_name
title: /* item.namedetails?.['name:fa'] ||
item.namedetails?.name ||
item.namedetails?.['alt_name:fa'] ||
item.namedetails?.alt_name || */
item.display_name,
position: [parseFloat(item.lat), parseFloat(item.lon)] as [number, number]
})));
} catch (error) {
console.error('Search error:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, 500); // Wait 500ms after last keystroke
setSearchTimeout(timeout);
};
const handleSearchResultSelect = (result: { id: string; title: string; position: [number, number] }) => {
console.log(result);
setSelectedPosition(result.position);
if (mapInstance) {
mapInstance.setView(result.position, mapInstance.getZoom());
}
};
return (
<div className="w-full h-full relative">
{searchEnabled &&
<ActiveSearchbox
placeholder="جستجو"
onSearch={handleSearch}
isLoading={isSearching}
results={searchResults}
onResultSelect={(v) => {
// Find the full result object with position
const result = searchResults.find(r => r.id === v.id && r.title === v.title);
if (result) {
handleSearchResultSelect(result);
}
}}
/>
}
<div className="map-zoom-controls">
<button
onClick={(e) => {
e.stopPropagation();
if (mapInstance) {
const center = selectedPosition || mapInstance.getCenter();
mapInstance.setZoomAround(center, mapInstance.getZoom() + 1);
}
}}
className="zoom-button"
aria-label="بزرگنمایی"
>
+
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (mapInstance) {
const center = selectedPosition || mapInstance.getCenter();
mapInstance.setZoomAround(center, mapInstance.getZoom() - 1);
}
}}
className="zoom-button"
aria-label="کوچک نمایی"
>
</button>
</div>
{isMapLoading && (
<div className="absolute inset-0 bg-zinc-50/80 backdrop-blur-sm z-[400]">
<div className="grid grid-cols-3 gap-4 p-8 h-full">
<Skeleton className="h-full" />
<Skeleton className="h-full" />
<Skeleton className="h-full" />
</div>
</div>
)}
<MapContainer
center={initialPosition}
zoom={zoom}
className="map-google-style"
style={{ height: '100%', width: '100%' }}
zoomControl={false} // Disable default zoom control
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Service Area Polygon */}
{serviceArea && serviceArea.coordinates && serviceArea.coordinates.length > 0 && (
<Polygon
positions={serviceArea.coordinates[0].map(coord => [coord[1], coord[0]] as [number, number])}
pathOptions={{
color: '#3b82f6',
fillColor: '#3b82f6',
fillOpacity: 0.2,
weight: 2,
interactive: false,
}}
/>
)}
{/* Custom markers */}
{markers.map((marker, index) => (
<MarkerWithAutoOpen
key={`${marker.position}-${index}`}
position={marker.position}
icon={marker.icon}
title={marker.title}
/>
))}
{/* Selected position marker */}
{selectedPosition && (
<Marker position={selectedPosition}>
<Popup>
<div className="text-sm px-2">موقعیت انتخاب شده</div>
</Popup>
</Marker>
)}
{/* Event handlers */}
<MapEvents
onZoomChange={onZoomChange}
onClick={handleMapClick}
center={initialPosition}
setMapRef={setMapInstance}
/>
{/* Marker updater for zoom effects */}
<MarkerUpdater markers={markers} />
</MapContainer>
</div>
);
};
export default CustomMap;