'use client'; /* eslint-disable import-x/no-named-as-default-member */ import mapboxgl from 'mapbox-gl'; import * as React from 'react'; import 'mapbox-gl/dist/mapbox-gl.css'; import { useMapboxStyle } from '@/lib/mapbox-style'; import { cn } from '@/lib/utils'; import { type POIItem, type POICategory, POI_CATEGORY_CONFIG } from './types'; /** * Hard-coded inline SVG markup for the 6 POI categories. Sourced from * lucide-react (same icons referenced in POI_CATEGORY_CONFIG). Used to render * the Lucide glyph inside Mapbox marker DOM where we can't mount a React tree. */ const POI_MARKER_SVG: Record = { school: '', hospital: '', transit: '', shopping: '', restaurant: '', park: '', }; interface NeighborhoodPOIMapProps { center: { lat: number; lng: number }; pois: POIItem[]; zoom?: number; height?: string; className?: string; } export function NeighborhoodPOIMap({ center, pois, zoom = 14, height = '400px', className, }: NeighborhoodPOIMapProps) { const mapContainerRef = React.useRef(null); const mapRef = React.useRef(null); const markersRef = React.useRef([]); const mapStyle = useMapboxStyle(); const [activeCategories, setActiveCategories] = React.useState>( () => new Set(Object.keys(POI_CATEGORY_CONFIG) as POICategory[]), ); const toggleCategory = React.useCallback((category: POICategory) => { setActiveCategories((prev) => { const next = new Set(prev); if (next.has(category)) { next.delete(category); } else { next.add(category); } return next; }); }, []); // Initialize map React.useEffect(() => { if (!mapContainerRef.current) return; const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; if (!token) return; mapboxgl.accessToken = token; const map = new mapboxgl.Map({ container: mapContainerRef.current, style: mapStyle, center: [center.lng, center.lat], zoom, attributionControl: false, }); map.addControl(new mapboxgl.NavigationControl(), 'top-right'); map.addControl( new mapboxgl.AttributionControl({ compact: true }), 'bottom-right', ); mapRef.current = map; return () => { map.remove(); mapRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Sync style changes with theme React.useEffect(() => { const map = mapRef.current; if (!map) return; map.setStyle(mapStyle); }, [mapStyle]); // Update center when prop changes React.useEffect(() => { mapRef.current?.flyTo({ center: [center.lng, center.lat], zoom }); }, [center, zoom]); // Render POI markers based on active categories React.useEffect(() => { const map = mapRef.current; if (!map) return; // Clear existing markers markersRef.current.forEach((m) => m.remove()); markersRef.current = []; const visiblePois = pois.filter((poi) => activeCategories.has(poi.category)); visiblePois.forEach((poi) => { const config = POI_CATEGORY_CONFIG[poi.category]; // Mapbox Marker writes its own `transform: translate(Xpx, Ypx)…` on // the element it's given. If we mutate `el.style.transform` (e.g. to // scale on hover), it clobbers the translate and the marker snaps to // (0, 0). Wrap the visible circle in an INNER div and scale that // instead, leaving Mapbox's outer transform untouched. const el = document.createElement('div'); el.className = 'poi-marker'; el.style.cssText = `width: 32px; height: 32px; cursor: pointer;`; el.title = `${poi.name} (${config.label})`; const inner = document.createElement('div'); inner.style.cssText = ` width: 100%; height: 100%; border-radius: 50%; background: ${config.color}; border: 2px solid white; box-shadow: 0 2px 6px rgba(0,0,0,0.25); display: flex; align-items: center; justify-content: center; transition: transform 0.15s; transform: scale(1); pointer-events: none; `; inner.innerHTML = POI_MARKER_SVG[poi.category]; el.appendChild(inner); el.addEventListener('mouseenter', () => { inner.style.transform = 'scale(1.3)'; }); el.addEventListener('mouseleave', () => { inner.style.transform = 'scale(1)'; }); const popup = new mapboxgl.Popup({ offset: 20, closeButton: true, closeOnClick: true }) .setHTML( `

${poi.name}

${config.label}${poi.distance ? ` · ${poi.distance}m` : ''}

`, ); const marker = new mapboxgl.Marker({ element: el, anchor: 'center' }) .setLngLat([poi.lng, poi.lat]) .setPopup(popup) .addTo(map); markersRef.current.push(marker); }); }, [pois, activeCategories]); // Add property center marker React.useEffect(() => { const map = mapRef.current; if (!map) return; const el = document.createElement('div'); el.style.cssText = ` width: 16px; height: 16px; border-radius: 50%; background: hsl(var(--primary)); border: 3px solid hsl(var(--card)); box-shadow: 0 0 0 2px hsl(var(--primary)), 0 2px 8px rgba(0,0,0,0.3); `; const marker = new mapboxgl.Marker({ element: el, anchor: 'center' }) .setLngLat([center.lng, center.lat]) .addTo(map); return () => { marker.remove(); }; }, [center]); const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; const allCategories = Object.entries(POI_CATEGORY_CONFIG) as [ POICategory, (typeof POI_CATEGORY_CONFIG)[POICategory], ][]; return (
{/* Layer toggle controls */}
{allCategories.map(([key, config]) => { const isActive = activeCategories.has(key); const poiCount = pois.filter((p) => p.category === key).length; return ( ); })}
{/* Fallback when no Mapbox token */} {!hasToken && (

Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ POI

)}
); }