Files
Bookra/generate_map.tsx
T
Tomas Dvorak 48c3e15a38 cleanup
2026-05-05 09:48:07 +02:00

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;