'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'; const VN_CENTER: [number, number] = [106.0, 16.0]; const DEFAULT_ZOOM = 5; const SOURCE_ID = 'osm-parks'; const CLUSTER_LAYER_ID = 'osm-parks-clusters'; const CLUSTER_COUNT_LAYER_ID = 'osm-parks-cluster-count'; const POINT_LAYER_ID = 'osm-parks-points'; const BOUNDARY_FILL_LAYER_ID = 'osm-parks-boundaries-fill'; const BOUNDARY_LINE_LAYER_ID = 'osm-parks-boundaries-line'; interface OsmParkBboxMapProps { className?: string; /** Override the bbox API path. Default = `${NEXT_PUBLIC_API_URL}/industrial/parks/by-bbox`. */ apiPath?: string; /** Show raw OSM-imported parks (admin tools). Default false. */ includeOsmRaw?: boolean; } /** * Viewport-driven KCN map. Pulls parks from the bbox endpoint as the user * pans/zooms — clusters at low zoom (<12), shows polygon outlines at * high zoom. Designed for the public catalog where we have ~2000 OSM * imports + 50 curated rows; loading the entire dataset eagerly would * be wasteful. */ export function OsmParkBboxMap({ className, apiPath, includeOsmRaw = false, }: OsmParkBboxMapProps) { const containerRef = React.useRef(null); const mapRef = React.useRef(null); const fetchAbortRef = React.useRef(null); const mapStyle = useMapboxStyle(); const apiBase = React.useMemo(() => { if (apiPath) return apiPath; const apiUrl = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3201/api/v1'; return `${apiUrl}/industrial/parks/by-bbox`; }, [apiPath]); // Capture the current includeOsmRaw value via a ref so the moveend // handler always sees the latest without re-binding the listener. const includeOsmRawRef = React.useRef(includeOsmRaw); // Bumping this triggers a manual refetch when the toggle changes — // the moveend handler alone doesn't fire on prop changes. const refetchTokenRef = React.useRef<(() => void) | null>(null); React.useEffect(() => { includeOsmRawRef.current = includeOsmRaw; refetchTokenRef.current?.(); }, [includeOsmRaw]); React.useEffect(() => { if (!containerRef.current) return; const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; if (!token) return; mapboxgl.accessToken = token; const map = new mapboxgl.Map({ container: containerRef.current, style: mapStyle, center: VN_CENTER, zoom: DEFAULT_ZOOM, attributionControl: false, }); map.addControl(new mapboxgl.NavigationControl(), 'top-right'); map.addControl( new mapboxgl.AttributionControl({ compact: true, customAttribution: 'Data © OSM' }), 'bottom-right', ); mapRef.current = map; const fetchParks = async () => { try { // Cancel any in-flight request — only the latest viewport matters. fetchAbortRef.current?.abort(); const controller = new AbortController(); fetchAbortRef.current = controller; const bounds = map.getBounds(); if (!bounds) return; const sw = bounds.getSouthWest(); const ne = bounds.getNorthEast(); const zoom = Math.round(map.getZoom()); const params = new URLSearchParams({ south: sw.lat.toString(), west: sw.lng.toString(), north: ne.lat.toString(), east: ne.lng.toString(), zoom: zoom.toString(), ...(includeOsmRawRef.current ? { includeOsmRaw: 'true' } : {}), }); const res = await fetch(`${apiBase}?${params}`, { credentials: 'include', signal: controller.signal, }); if (!res.ok) return; const fc = (await res.json()) as GeoJSON.FeatureCollection; const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined; if (src) src.setData(fc); } catch (err) { if (err instanceof Error && err.name === 'AbortError') return; console.warn('[osm-park-bbox-map] fetch failed:', err); } }; map.on('load', () => { // Empty source — populated by the first fetchParks() call below. map.addSource(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, cluster: true, clusterRadius: 50, clusterMaxZoom: 11, clusterProperties: { // No extra metrics yet — total count is built-in. }, }); // Cluster bubbles. Mapbox color parser only accepts literal colors, // so we use hex constants matching our design-system primary token. map.addLayer({ id: CLUSTER_LAYER_ID, type: 'circle', source: SOURCE_ID, filter: ['has', 'point_count'], paint: { 'circle-color': [ 'step', ['get', 'point_count'], '#22c55e', // primary 10, '#f59e0b', 50, '#ef4444', ], 'circle-radius': [ 'step', ['get', 'point_count'], 16, 10, 22, 50, 30, ], 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff', 'circle-opacity': 0.9, }, }); map.addLayer({ id: CLUSTER_COUNT_LAYER_ID, type: 'symbol', source: SOURCE_ID, filter: ['has', 'point_count'], layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], 'text-size': 12, }, paint: { 'text-color': '#ffffff' }, }); // Individual park markers (centroid Points) when not clustered. // Color is data-driven: green = curated (MANUAL / OSM_PROMOTED), // amber = raw OSM imports awaiting admin review. map.addLayer({ id: POINT_LAYER_ID, type: 'circle', source: SOURCE_ID, filter: [ 'all', ['!', ['has', 'point_count']], ['==', ['get', '_kind'], 'point'], ], paint: { 'circle-color': [ 'case', ['==', ['get', 'dataSource'], 'OSM'], '#f59e0b', // amber for raw OSM '#22c55e', // green for curated ], 'circle-radius': [ 'case', ['==', ['get', 'dataSource'], 'OSM'], 5, 6, ], 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 1.5, 'circle-opacity': [ 'case', ['==', ['get', 'dataSource'], 'OSM'], 0.7, 1, ], }, }); // Polygon outlines — only present when zoom >= 12 (server omits them // at lower zoom). Fill layer for hit-test, line layer for stroke. map.addLayer({ id: BOUNDARY_FILL_LAYER_ID, type: 'fill', source: SOURCE_ID, filter: ['==', ['get', '_kind'], 'polygon'], paint: { 'fill-color': [ 'case', ['==', ['get', 'dataSource'], 'OSM'], '#f59e0b', '#22c55e', ], 'fill-opacity': [ 'case', ['==', ['get', 'dataSource'], 'OSM'], 0.1, 0.18, ], }, }); map.addLayer({ id: BOUNDARY_LINE_LAYER_ID, type: 'line', source: SOURCE_ID, filter: ['==', ['get', '_kind'], 'polygon'], paint: { 'line-color': [ 'case', ['==', ['get', 'dataSource'], 'OSM'], '#f59e0b', '#22c55e', ], 'line-width': 2, 'line-opacity': [ 'case', ['==', ['get', 'dataSource'], 'OSM'], 0.4, 0.6, ], }, }); // Click handler on point/polygon → navigate to detail. const onClick = (e: mapboxgl.MapLayerMouseEvent) => { const f = e.features?.[0]; if (!f) return; const slug = (f.properties as Record | null)?.['slug']; if (typeof slug === 'string' && slug.length > 0) { window.location.href = `/vi/khu-cong-nghiep/${slug}`; } }; map.on('click', POINT_LAYER_ID, onClick); map.on('click', BOUNDARY_FILL_LAYER_ID, onClick); // Cursor feedback for (const layerId of [POINT_LAYER_ID, BOUNDARY_FILL_LAYER_ID, CLUSTER_LAYER_ID]) { map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; }); } // Cluster click — zoom in map.on('click', CLUSTER_LAYER_ID, (e) => { const features = map.queryRenderedFeatures(e.point, { layers: [CLUSTER_LAYER_ID] }); const clusterFeature = features[0]; if (!clusterFeature) return; const clusterId = clusterFeature.properties?.['cluster_id']; const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource; if (typeof clusterId === 'number') { src.getClusterExpansionZoom( clusterId, (err: Error | null | undefined, zoom: number | null | undefined) => { if (err || zoom == null) return; const geom = clusterFeature.geometry; if (geom.type === 'Point') { map.easeTo({ center: geom.coordinates as [number, number], zoom }); } }, ); } }); // Initial fetch + listen to viewport changes. void fetchParks(); // Wire up the prop-change refetch (used when `includeOsmRaw` flips // — the moveend listener alone doesn't fire on parent re-renders). refetchTokenRef.current = () => { void fetchParks(); }; }); map.on('moveend', () => { void fetchParks(); }); return () => { fetchAbortRef.current?.abort(); map.remove(); mapRef.current = null; }; // We intentionally do NOT depend on includeOsmRaw — the ref-based // approach avoids tearing down the map on every prop tick. // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiBase]); // Sync mapStyle (theme switch) without rebuilding the map. React.useEffect(() => { const map = mapRef.current; if (!map) return; map.setStyle(mapStyle); }, [mapStyle]); const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; return (
{!hasToken && (
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ
)}
); }