'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'; // ── Mapbox layer IDs ────────────────────────────────────────────────────────── const SOURCE_ID = 'poi-source'; const LAYER_CLUSTERS = 'poi-clusters'; const LAYER_CLUSTER_COUNT = 'poi-cluster-count'; const LAYER_UNCLUSTERED = 'poi-unclustered'; /** * Color lookup per POI category — kept in sync with `POI_CATEGORY_CONFIG`. * Used in Mapbox `match` expressions so the map layer drives coloring without * requiring separate image assets for each category. */ const CATEGORY_COLORS: Record = { school: '#3B82F6', hospital: '#EF4444', transit: '#8B5CF6', shopping: '#F59E0B', restaurant: '#F97316', park: '#22C55E', }; /** Build a GeoJSON FeatureCollection from `pois`, filtered to `activeCategories`. */ function buildGeoJson( pois: POIItem[], activeCategories: Set, ): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: pois .filter((poi) => activeCategories.has(poi.category)) .map((poi) => ({ type: 'Feature' as const, geometry: { type: 'Point' as const, coordinates: [poi.lng, poi.lat] }, properties: { id: poi.id, name: poi.name, category: poi.category, categoryLabel: POI_CATEGORY_CONFIG[poi.category].label, distance: poi.distance ?? null, }, })), }; } 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 centerMarkerRef = React.useRef(null); const mapStyle = useMapboxStyle(); const [mapLoaded, setMapLoaded] = React.useState(false); 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'); map.on('load', () => setMapLoaded(true)); mapRef.current = map; return () => { map.remove(); mapRef.current = null; setMapLoaded(false); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ── Re-apply style and rebuild state on theme change ──────────────────────── React.useEffect(() => { const map = mapRef.current; if (!map) return; setMapLoaded(false); map.setStyle(mapStyle); const onStyleLoad = () => setMapLoaded(true); map.once('style.load', onStyleLoad); return () => { map.off('style.load', onStyleLoad); }; }, [mapStyle]); // ── Fly to center when prop changes ───────────────────────────────────────── React.useEffect(() => { mapRef.current?.flyTo({ center: [center.lng, center.lat], zoom }); }, [center, zoom]); // ── Property centre marker (DOM, single, no clustering) ───────────────────── React.useEffect(() => { const map = mapRef.current; if (!map || !mapLoaded) return; centerMarkerRef.current?.remove(); const el = document.createElement('div'); el.style.cssText = ` width: 16px; height: 16px; border-radius: 50%; background: hsl(var(--primary)); border: 3px solid white; box-shadow: 0 0 0 2px hsl(var(--primary)), 0 2px 8px rgba(0,0,0,0.3); `; centerMarkerRef.current = new mapboxgl.Marker({ element: el, anchor: 'center' }) .setLngLat([center.lng, center.lat]) .addTo(map); return () => { centerMarkerRef.current?.remove(); centerMarkerRef.current = null; }; }, [mapLoaded, center]); // ── POI GeoJSON source + cluster layers ───────────────────────────────────── React.useEffect(() => { const map = mapRef.current; if (!map || !mapLoaded) return; const geoJson = buildGeoJson(pois, activeCategories); // If the source already exists (e.g. category toggle or pois prop update) // just refresh the data — no need to recreate layers. const existing = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined; if (existing) { existing.setData(geoJson); return; } // ── GeoJSON source with built-in clustering ────────────────────────────── map.addSource(SOURCE_ID, { type: 'geojson', data: geoJson, cluster: true, clusterMaxZoom: 13, // stop clustering above zoom 13 clusterRadius: 50, // pixels radius for merging }); // ── Cluster bubble ─────────────────────────────────────────────────────── map.addLayer({ id: LAYER_CLUSTERS, type: 'circle', source: SOURCE_ID, filter: ['has', 'point_count'], paint: { // Small clusters: primary; medium: amber; large: red 'circle-color': [ 'step', ['get', 'point_count'], 'hsl(var(--primary))', 5, '#f59e0b', 20, '#ef4444', ], 'circle-radius': [ 'step', ['get', 'point_count'], 18, // < 5 5, 24, // 5–19 20, 32, // ≥ 20 ], 'circle-stroke-width': 2, 'circle-stroke-color': 'white', 'circle-opacity': 0.9, }, }); // ── Cluster count label ────────────────────────────────────────────────── map.addLayer({ id: LAYER_CLUSTER_COUNT, type: 'symbol', source: SOURCE_ID, filter: ['has', 'point_count'], layout: { 'text-field': '{point_count_abbreviated}', 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], 'text-size': 12, }, paint: { 'text-color': '#ffffff', }, }); // ── Individual POI circle (unclustered) ────────────────────────────────── map.addLayer({ id: LAYER_UNCLUSTERED, type: 'circle', source: SOURCE_ID, filter: ['!', ['has', 'point_count']], paint: { 'circle-radius': 10, 'circle-color': [ 'match', ['get', 'category'], 'school', CATEGORY_COLORS.school, 'hospital', CATEGORY_COLORS.hospital, 'transit', CATEGORY_COLORS.transit, 'shopping', CATEGORY_COLORS.shopping, 'restaurant', CATEGORY_COLORS.restaurant, 'park', CATEGORY_COLORS.park, '#888888', ], 'circle-stroke-width': 2, 'circle-stroke-color': 'white', 'circle-opacity': 0.95, }, }); // ── Click cluster → zoom in / expand ──────────────────────────────────── map.on('click', LAYER_CLUSTERS, (e) => { const features = map.queryRenderedFeatures(e.point, { layers: [LAYER_CLUSTERS] }); if (!features.length) return; const clusterId = features[0].properties?.cluster_id as number; (map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource).getClusterExpansionZoom( clusterId, (err, expansionZoom) => { if (err || expansionZoom == null) return; const coords = (features[0].geometry as GeoJSON.Point).coordinates as [number, number]; map.easeTo({ center: coords, zoom: expansionZoom }); }, ); }); // ── Click unclustered POI → popup ──────────────────────────────────────── map.on('click', LAYER_UNCLUSTERED, (e) => { const features = map.queryRenderedFeatures(e.point, { layers: [LAYER_UNCLUSTERED] }); if (!features.length) return; const { name, categoryLabel, distance } = features[0].properties ?? {}; const coords = (features[0].geometry as GeoJSON.Point).coordinates.slice() as [ number, number, ]; new mapboxgl.Popup({ closeButton: true, closeOnClick: true, offset: 12 }) .setLngLat(coords) .setHTML( `

${name}

${categoryLabel}${distance ? ` · ${distance}m` : ''}

`, ) .addTo(map); }); // ── Cursor changes ─────────────────────────────────────────────────────── map.on('mouseenter', LAYER_CLUSTERS, () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', LAYER_CLUSTERS, () => { map.getCanvas().style.cursor = ''; }); map.on('mouseenter', LAYER_UNCLUSTERED, () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', LAYER_UNCLUSTERED, () => { map.getCanvas().style.cursor = ''; }); }, [mapLoaded, pois, activeCategories]); 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

)}
); }