mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
413 lines
12 KiB
TypeScript
413 lines
12 KiB
TypeScript
import React, { useEffect, useRef } from 'react';
|
|
import { Box } from '@chakra-ui/react';
|
|
|
|
// Dynamically load Leaflet
|
|
let L: any = null;
|
|
|
|
interface ContactMapProps {
|
|
latitude: number;
|
|
longitude: number;
|
|
zoom?: number;
|
|
address?: string;
|
|
clubName?: string;
|
|
mapStyle?: string;
|
|
height?: number;
|
|
clubPrimaryColor?: string;
|
|
clubSecondaryColor?: string;
|
|
}
|
|
|
|
// Available map styles
|
|
export const MAP_STYLES = {
|
|
// Clean & Minimal
|
|
'positron': {
|
|
name: 'Positron (Light)',
|
|
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
|
attribution: '© OpenStreetMap © CartoDB',
|
|
description: 'Clean light map, perfect for overlays'
|
|
},
|
|
'positron-no-labels': {
|
|
name: 'Positron No Labels',
|
|
url: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png',
|
|
attribution: '© OpenStreetMap © CartoDB',
|
|
description: 'Minimal light map without labels'
|
|
},
|
|
|
|
// Dark Themes
|
|
'dark': {
|
|
name: 'Dark Matter',
|
|
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
|
attribution: '© OpenStreetMap © CartoDB',
|
|
description: 'Dark theme, great for night mode'
|
|
},
|
|
'dark-no-labels': {
|
|
name: 'Dark No Labels',
|
|
url: 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
|
|
attribution: '© OpenStreetMap © CartoDB',
|
|
description: 'Dark map without labels'
|
|
},
|
|
|
|
// Black & White
|
|
'toner': {
|
|
name: 'Toner (B&W)',
|
|
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}{r}.png',
|
|
attribution: '© Stamen Design © OpenStreetMap',
|
|
description: 'High contrast black and white'
|
|
},
|
|
'toner-lite': {
|
|
name: 'Toner Lite (B&W)',
|
|
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.png',
|
|
attribution: '© Stamen Design © OpenStreetMap',
|
|
description: 'Subtle black and white'
|
|
},
|
|
|
|
// Colorful Options
|
|
'voyager': {
|
|
name: 'Voyager',
|
|
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
|
attribution: '© OpenStreetMap © CartoDB',
|
|
description: 'Balanced colors, good readability'
|
|
},
|
|
'terrain': {
|
|
name: 'Terrain',
|
|
url: 'https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}{r}.jpg',
|
|
attribution: '© Stamen Design © OpenStreetMap',
|
|
description: 'Natural terrain visualization'
|
|
},
|
|
'watercolor': {
|
|
name: 'Watercolor',
|
|
url: 'https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg',
|
|
attribution: '© Stamen Design © OpenStreetMap',
|
|
description: 'Artistic watercolor style'
|
|
},
|
|
|
|
// Default
|
|
'default': {
|
|
name: 'OpenStreetMap',
|
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
attribution: '© OpenStreetMap contributors',
|
|
description: 'Standard OpenStreetMap'
|
|
},
|
|
|
|
// Satellite
|
|
'satellite': {
|
|
name: 'Satellite',
|
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
|
attribution: '© Esri',
|
|
description: 'Satellite imagery'
|
|
},
|
|
};
|
|
|
|
const ContactMap: React.FC<ContactMapProps> = ({
|
|
latitude,
|
|
longitude,
|
|
zoom = 15,
|
|
address,
|
|
clubName,
|
|
mapStyle = 'default',
|
|
height = 400,
|
|
clubPrimaryColor,
|
|
clubSecondaryColor,
|
|
}) => {
|
|
const mapRef = useRef<HTMLDivElement>(null);
|
|
const mapInstanceRef = useRef<any>(null);
|
|
const tileLayerRef = useRef<any>(null);
|
|
const markerRef = useRef<any>(null);
|
|
const [isLoaded, setIsLoaded] = React.useState(false);
|
|
const [loadError, setLoadError] = React.useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Load Leaflet CSS and JS dynamically
|
|
const loadLeaflet = async () => {
|
|
try {
|
|
// Check if already loaded
|
|
if ((window as any).L) {
|
|
L = (window as any).L;
|
|
setIsLoaded(true);
|
|
return;
|
|
}
|
|
|
|
// Load CSS
|
|
if (!document.getElementById('leaflet-css')) {
|
|
const link = document.createElement('link');
|
|
link.id = 'leaflet-css';
|
|
link.rel = 'stylesheet';
|
|
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
|
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
|
link.crossOrigin = '';
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
// Load JS
|
|
if (!document.getElementById('leaflet-js')) {
|
|
const script = document.createElement('script');
|
|
script.id = 'leaflet-js';
|
|
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
|
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=';
|
|
script.crossOrigin = '';
|
|
|
|
script.onload = () => {
|
|
L = (window as any).L;
|
|
setIsLoaded(true);
|
|
};
|
|
|
|
script.onerror = () => {
|
|
setLoadError('Failed to load map library');
|
|
};
|
|
|
|
document.head.appendChild(script);
|
|
}
|
|
} catch (error) {
|
|
setLoadError('Error loading map');
|
|
}
|
|
};
|
|
|
|
loadLeaflet();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isLoaded || !L || !mapRef.current || mapInstanceRef.current) return;
|
|
|
|
try {
|
|
// Initialize map
|
|
const map = L.map(mapRef.current, {
|
|
center: [latitude, longitude],
|
|
zoom: zoom,
|
|
scrollWheelZoom: false, // Disable scroll zoom for better UX
|
|
});
|
|
|
|
mapInstanceRef.current = map;
|
|
|
|
// Initial tile layer
|
|
const { tileUrl, attribution } = (() => {
|
|
let url = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
let attr = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
|
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) {
|
|
const style = MAP_STYLES[mapStyle as keyof typeof MAP_STYLES];
|
|
url = style.url;
|
|
attr = style.attribution;
|
|
} else if (mapStyle && mapStyle.startsWith('http')) {
|
|
url = mapStyle;
|
|
}
|
|
return { tileUrl: url, attribution: attr };
|
|
})();
|
|
|
|
tileLayerRef.current = L.tileLayer(tileUrl, {
|
|
attribution,
|
|
maxZoom: 19,
|
|
}).addTo(map);
|
|
|
|
// Create custom marker icon with club colors
|
|
const markerColor = clubPrimaryColor || '#3388ff';
|
|
const customIcon = createCustomMarkerIcon(markerColor, L);
|
|
|
|
// Add marker
|
|
markerRef.current = L.marker([latitude, longitude], { icon: customIcon }).addTo(map);
|
|
|
|
// Add popup if address is provided
|
|
if (clubName || address) {
|
|
let popupContent = '';
|
|
if (clubName) popupContent += `<b>${clubName}</b><br>`;
|
|
if (address) popupContent += address;
|
|
markerRef.current.bindPopup(popupContent);
|
|
}
|
|
|
|
// Enable scroll zoom on click
|
|
map.on('click', () => {
|
|
map.scrollWheelZoom.enable();
|
|
});
|
|
|
|
// Disable scroll zoom on mouseout
|
|
map.on('mouseout', () => {
|
|
map.scrollWheelZoom.disable();
|
|
});
|
|
|
|
// Invalidate size after initial mount to ensure tiles render fully
|
|
// (use a short delay to let layout settle)
|
|
try {
|
|
setTimeout(() => {
|
|
try { map.invalidateSize(); } catch {}
|
|
}, 150);
|
|
} catch {}
|
|
|
|
} catch (error) {
|
|
console.error('Error initializing map:', error);
|
|
setLoadError('Failed to initialize map');
|
|
}
|
|
|
|
// Cleanup on unmount
|
|
return () => {
|
|
try {
|
|
if (mapInstanceRef.current) {
|
|
mapInstanceRef.current.remove();
|
|
}
|
|
} finally {
|
|
mapInstanceRef.current = null;
|
|
tileLayerRef.current = null;
|
|
markerRef.current = null;
|
|
}
|
|
};
|
|
}, [isLoaded]);
|
|
|
|
// Observe container visibility to invalidate size when it becomes visible (e.g., tabs, accordions)
|
|
useEffect(() => {
|
|
if (!mapRef.current) return;
|
|
const el = mapRef.current;
|
|
const observer = new IntersectionObserver((entries) => {
|
|
const e = entries[0];
|
|
if (e && e.isIntersecting && mapInstanceRef.current) {
|
|
try {
|
|
// Next frame ensures correct layout measurement
|
|
requestAnimationFrame(() => {
|
|
try { mapInstanceRef.current!.invalidateSize(); } catch {}
|
|
});
|
|
} catch {}
|
|
}
|
|
}, { root: null, threshold: 0.1 });
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [isLoaded]);
|
|
|
|
// Observe container resize to keep Leaflet in sync with layout changes
|
|
useEffect(() => {
|
|
if (!mapRef.current || !('ResizeObserver' in window)) return;
|
|
const el = mapRef.current;
|
|
const ro = new ResizeObserver(() => {
|
|
if (!mapInstanceRef.current) return;
|
|
try { mapInstanceRef.current.invalidateSize(); } catch {}
|
|
});
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, [isLoaded]);
|
|
|
|
// Update center/zoom and marker when coords/zoom change
|
|
useEffect(() => {
|
|
if (!mapInstanceRef.current || !L) return;
|
|
try {
|
|
const map = mapInstanceRef.current;
|
|
if (typeof latitude === 'number' && typeof longitude === 'number') {
|
|
map.setView([latitude, longitude], typeof zoom === 'number' ? zoom : map.getZoom());
|
|
if (markerRef.current) {
|
|
markerRef.current.setLatLng([latitude, longitude]);
|
|
}
|
|
}
|
|
} catch {}
|
|
}, [latitude, longitude, zoom]);
|
|
|
|
// Update map style (tile layer) when mapStyle changes
|
|
useEffect(() => {
|
|
if (!mapInstanceRef.current || !L) return;
|
|
try {
|
|
const map = mapInstanceRef.current;
|
|
// Compute URL and attribution
|
|
let url = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
let attr = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
|
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) {
|
|
const style = MAP_STYLES[mapStyle as keyof typeof MAP_STYLES];
|
|
url = style.url;
|
|
attr = style.attribution;
|
|
} else if (mapStyle && mapStyle.startsWith('http')) {
|
|
url = mapStyle;
|
|
}
|
|
if (tileLayerRef.current) {
|
|
map.removeLayer(tileLayerRef.current);
|
|
}
|
|
tileLayerRef.current = L.tileLayer(url, { attribution: attr, maxZoom: 19 }).addTo(map);
|
|
} catch {}
|
|
}, [mapStyle]);
|
|
|
|
// Update popup content when clubName/address change
|
|
useEffect(() => {
|
|
try {
|
|
if (markerRef.current) {
|
|
if (clubName || address) {
|
|
let popupContent = '';
|
|
if (clubName) popupContent += `<b>${clubName}</b><br>`;
|
|
if (address) popupContent += address;
|
|
markerRef.current.bindPopup(popupContent);
|
|
} else {
|
|
markerRef.current.unbindPopup();
|
|
}
|
|
}
|
|
} catch {}
|
|
}, [clubName, address]);
|
|
|
|
// Helper function to create color filter
|
|
function createColorFilter(color: string): string | null {
|
|
try {
|
|
// Validate and normalize color
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.style.color = color;
|
|
document.body.appendChild(tempDiv);
|
|
const computedColor = window.getComputedStyle(tempDiv).color;
|
|
document.body.removeChild(tempDiv);
|
|
return computedColor;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Helper function to create custom marker with club colors
|
|
function createCustomMarkerIcon(color: string, leaflet: any) {
|
|
// Create SVG marker with custom color
|
|
const svgIcon = `
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="36" height="54">
|
|
<defs>
|
|
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
|
|
<feOffset dx="0" dy="2" result="offsetblur"/>
|
|
<feComponentTransfer>
|
|
<feFuncA type="linear" slope="0.3"/>
|
|
</feComponentTransfer>
|
|
<feMerge>
|
|
<feMergeNode/>
|
|
<feMergeNode in="SourceGraphic"/>
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
<path fill="${color}" stroke="#fff" stroke-width="1.5" filter="url(#shadow)"
|
|
d="M12 0C7.03 0 3 4.03 3 9c0 7.5 9 18 9 18s9-10.5 9-18c0-4.97-4.03-9-9-9z"/>
|
|
<circle cx="12" cy="9" r="3" fill="#fff"/>
|
|
</svg>
|
|
`;
|
|
|
|
const iconUrl = 'data:image/svg+xml;base64,' + btoa(svgIcon);
|
|
|
|
return leaflet.icon({
|
|
iconUrl: iconUrl,
|
|
iconSize: [36, 54],
|
|
iconAnchor: [18, 54],
|
|
popupAnchor: [0, -54],
|
|
});
|
|
}
|
|
|
|
if (loadError) {
|
|
return (
|
|
<Box
|
|
ref={mapRef}
|
|
w="100%"
|
|
h={`${height}px`}
|
|
bg="gray.100"
|
|
display="flex"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
borderRadius="md"
|
|
>
|
|
{loadError}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
ref={mapRef}
|
|
w="100%"
|
|
h={`${height}px`}
|
|
borderRadius="md"
|
|
overflow="hidden"
|
|
boxShadow="md"
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default ContactMap;
|