1616 * Features:
1717 * - Interactive map with markers
1818 * - Location-based data visualization
19- * - Marker clustering (when many points)
2019 * - Popup/tooltip on marker click
2120 * - Works with object/api/value data providers
22- *
23- * Note: This is a basic implementation. For production use, integrate with a
24- * proper mapping library like Mapbox, Leaflet, or Google Maps.
2521 */
2622
2723import React , { useEffect , useState , useMemo } from 'react' ;
2824import type { ObjectGridSchema , DataSource , ViewData } from '@object-ui/types' ;
2925import { z } from 'zod' ;
26+ import Map , { NavigationControl , Marker , Popup } from 'react-map-gl/maplibre' ;
27+ import maplibregl from 'maplibre-gl' ;
28+ import 'maplibre-gl/dist/maplibre-gl.css' ;
3029
3130const MapConfigSchema = z . object ( {
3231 latitudeField : z . string ( ) . optional ( ) ,
@@ -222,7 +221,7 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
222221 const [ loading , setLoading ] = useState ( true ) ;
223222 const [ error , setError ] = useState < Error | null > ( null ) ;
224223 const [ objectSchema , setObjectSchema ] = useState < any > ( null ) ;
225- const [ selectedMarker , setSelectedMarker ] = useState < string | null > ( null ) ;
224+ const [ selectedMarkerId , setSelectedMarkerId ] = useState < string | null > ( null ) ;
226225
227226 const rawDataConfig = getDataConfig ( schema ) ;
228227 // Memoize dataConfig using deep comparison to prevent infinite loops
@@ -336,48 +335,58 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
336335 const title = mapConfig . titleField ? record [ mapConfig . titleField ] : 'Marker' ;
337336 const description = mapConfig . descriptionField ? record [ mapConfig . descriptionField ] : undefined ;
338337
338+ // Ensure lat/lng are within valid ranges
339+ const [ lat , lng ] = coordinates ;
340+ if ( lat < - 90 || lat > 90 || lng < - 180 || lng > 180 ) {
341+ console . warn ( `Invalid coordinates for marker ${ index } : [${ lat } , ${ lng } ]` ) ;
342+ return null ;
343+ }
344+
339345 return {
340346 id : record . id || record . _id || `marker-${ index } ` ,
341347 title,
342348 description,
343- coordinates,
349+ coordinates : [ lng , lat ] as [ number , number ] , // maplibre uses [lng, lat]
344350 data : record ,
345351 } ;
346352 } )
347353 . filter ( ( marker ) : marker is NonNullable < typeof marker > => marker !== null ) ;
348354 } , [ data , mapConfig ] ) ;
349355
356+ const selectedMarker = useMemo ( ( ) =>
357+ markers . find ( m => m . id === selectedMarkerId ) ,
358+ [ markers , selectedMarkerId ] ) ;
359+
350360 // Calculate map bounds
351- const bounds = useMemo ( ( ) => {
361+ const initialViewState = useMemo ( ( ) => {
352362 if ( ! markers . length ) {
353363 return {
354- center : mapConfig . center || [ 0 , 0 ] ,
355- minLat : ( mapConfig . center ?. [ 0 ] || 0 ) - 0.1 ,
356- maxLat : ( mapConfig . center ?. [ 0 ] || 0 ) + 0.1 ,
357- minLng : ( mapConfig . center ?. [ 1 ] || 0 ) - 0.1 ,
358- maxLng : ( mapConfig . center ?. [ 1 ] || 0 ) + 0.1 ,
364+ longitude : mapConfig . center ?. [ 1 ] || 0 ,
365+ latitude : mapConfig . center ?. [ 0 ] || 0 ,
366+ zoom : mapConfig . zoom || 2
359367 } ;
360368 }
361369
362- const lats = markers . map ( m => m . coordinates [ 0 ] ) ;
363- const lngs = markers . map ( m => m . coordinates [ 1 ] ) ;
370+ // Simple bounds calculation
371+ const lngs = markers . map ( m => m . coordinates [ 0 ] ) ;
372+ const lats = markers . map ( m => m . coordinates [ 1 ] ) ;
373+
374+ const minLng = Math . min ( ...lngs ) ;
375+ const maxLng = Math . max ( ...lngs ) ;
376+ const minLat = Math . min ( ...lats ) ;
377+ const maxLat = Math . max ( ...lats ) ;
364378
365379 return {
366- center : [
367- ( Math . min ( ...lats ) + Math . max ( ...lats ) ) / 2 ,
368- ( Math . min ( ...lngs ) + Math . max ( ...lngs ) ) / 2 ,
369- ] as [ number , number ] ,
370- minLat : Math . min ( ...lats ) ,
371- maxLat : Math . max ( ...lats ) ,
372- minLng : Math . min ( ...lngs ) ,
373- maxLng : Math . max ( ...lngs ) ,
380+ longitude : ( minLng + maxLng ) / 2 ,
381+ latitude : ( minLat + maxLat ) / 2 ,
382+ zoom : mapConfig . zoom || 3 , // Auto-zoom logic could be improved here
374383 } ;
375- } , [ markers , mapConfig . center ] ) ;
384+ } , [ markers , mapConfig ] ) ;
376385
377386 if ( loading ) {
378387 return (
379388 < div className = { className } >
380- < div className = "flex items-center justify-center h-96 bg-muted rounded-lg" >
389+ < div className = "flex items-center justify-center h-96 bg-muted rounded-lg border " >
381390 < div className = "text-muted-foreground" > Loading map...</ div >
382391 </ div >
383392 </ div >
@@ -387,7 +396,7 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
387396 if ( error ) {
388397 return (
389398 < div className = { className } >
390- < div className = "flex items-center justify-center h-96 bg-muted rounded-lg" >
399+ < div className = "flex items-center justify-center h-96 bg-muted rounded-lg border " >
391400 < div className = "text-destructive" > Error: { error . message } </ div >
392401 </ div >
393402 </ div >
@@ -396,75 +405,60 @@ export const ObjectMap: React.FC<ObjectMapProps> = ({
396405
397406 return (
398407 < div className = { className } >
399- < div className = "relative border rounded-lg overflow-hidden bg-muted" style = { { height : '600px' } } >
400- { /* Placeholder map - in production, replace with actual map library */ }
401- < div className = "absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-50 to-green-50 dark:from-blue-950 dark:to-green-950" >
402- < div className = "text-center" >
403- < div className = "text-4xl mb-2" > 🗺️</ div >
404- < div className = "text-sm text-muted-foreground mb-4" >
405- Map Visualization (Placeholder)
406- </ div >
407- < div className = "text-xs text-muted-foreground max-w-md mx-auto" >
408- This is a basic map placeholder. For production, integrate with Mapbox, Leaflet, or Google Maps.
409- < br />
410- < br />
411- < strong > Map Info:</ strong >
412- < br />
413- Center: [{ bounds . center [ 0 ] . toFixed ( 4 ) } , { bounds . center [ 1 ] . toFixed ( 4 ) } ]
414- < br />
415- Markers: { markers . length }
416- </ div >
417- </ div >
418- </ div >
419-
420- { /* Marker List Overlay */ }
421- < div className = "absolute top-4 right-4 w-64 bg-background border rounded-lg shadow-lg max-h-96 overflow-y-auto" >
422- < div className = "p-3 border-b font-semibold bg-muted" >
423- Locations ({ markers . length } )
424- </ div >
425- { markers . length === 0 ? (
426- < div className = "p-4 text-sm text-muted-foreground text-center" >
427- No locations found with valid coordinates
428- </ div >
429- ) : (
430- < div >
431- { markers . map ( marker => (
432- < div
433- key = { marker . id }
434- className = { `p-3 border-b hover:bg-muted/50 cursor-pointer transition-colors ${
435- selectedMarker === marker . id ? 'bg-muted' : ''
436- } `}
437- onClick = { ( ) => {
438- setSelectedMarker ( marker . id ) ;
439- onMarkerClick ?.( marker . data ) ;
440- } }
408+ < div className = "relative border rounded-lg overflow-hidden bg-muted" style = { { height : '600px' , width : '100%' } } >
409+ < Map
410+ initialViewState = { initialViewState }
411+ style = { { width : '100%' , height : '100%' } }
412+ mapStyle = "https://demotiles.maplibre.org/style.json"
413+ >
414+ < NavigationControl position = "top-right" />
415+
416+ { markers . map ( marker => (
417+ < Marker
418+ key = { marker . id }
419+ longitude = { marker . coordinates [ 0 ] }
420+ latitude = { marker . coordinates [ 1 ] }
421+ anchor = "bottom"
422+ onClick = { ( e ) => {
423+ e . originalEvent . stopPropagation ( ) ;
424+ setSelectedMarkerId ( marker . id ) ;
425+ onMarkerClick ?.( marker . data ) ;
426+ } }
441427 >
442- < div className = "font-medium text-sm" > { marker . title } </ div >
443- { marker . description && (
444- < div className = "text-xs text-muted-foreground mt-1 line-clamp-2" >
445- { marker . description }
428+ < div className = "text-2xl cursor-pointer hover:scale-110 transition-transform" >
429+ 📍
446430 </ div >
447- ) }
448- < div className = "text-xs text-muted-foreground mt-1" >
449- 📍 { marker . coordinates [ 0 ] . toFixed ( 4 ) } , { marker . coordinates [ 1 ] . toFixed ( 4 ) }
450- </ div >
451- </ div >
452- ) ) }
453- </ div >
454- ) }
455- </ div >
456-
457- { /* Legend */ }
458- < div className = "absolute bottom-4 left-4 bg-background border rounded-lg shadow-lg p-3" >
459- < div className = "text-xs font-semibold mb-2" > Legend</ div >
460- < div className = "text-xs space-y-1" >
461- < div className = "flex items-center gap-2" >
462- < div className = "w-3 h-3 rounded-full bg-blue-500" />
463- < span > Location Marker</ span >
464- </ div >
465- </ div >
466- </ div >
431+ </ Marker >
432+ ) ) }
433+
434+ { selectedMarker && (
435+ < Popup
436+ longitude = { selectedMarker . coordinates [ 0 ] }
437+ latitude = { selectedMarker . coordinates [ 1 ] }
438+ anchor = "top"
439+ onClose = { ( ) => setSelectedMarkerId ( null ) }
440+ closeOnClick = { false }
441+ >
442+ < div className = "p-2 min-w-[200px]" >
443+ < h3 className = "font-bold text-sm mb-1" > { selectedMarker . title } </ h3 >
444+ { selectedMarker . description && (
445+ < p className = "text-xs text-muted-foreground" > { selectedMarker . description } </ p >
446+ ) }
447+ < div className = "mt-2 text-xs flex gap-2" >
448+ { onEdit && (
449+ < button className = "text-blue-500 hover:underline" onClick = { ( ) => onEdit ( selectedMarker . data ) } > Edit</ button >
450+ ) }
451+ { onDelete && (
452+ < button className = "text-red-500 hover:underline" onClick = { ( ) => onDelete ( selectedMarker . data ) } > Delete</ button >
453+ ) }
454+ </ div >
455+ </ div >
456+ </ Popup >
457+ ) }
458+ </ Map >
467459 </ div >
468460 </ div >
469461 ) ;
470462} ;
463+
464+ export default ObjectMap ;
0 commit comments