1+ 'use client' ;
2+
3+ import { useEffect , useState } from 'react' ;
4+ import { MapContainer , TileLayer , Marker , Popup , useMap } from 'react-leaflet' ;
5+ import L from 'leaflet' ;
6+
7+ // Fix for default markers in React Leaflet
8+ delete ( L . Icon . Default . prototype as L . Icon . Default & { _getIconUrl ?: ( ) => string } ) . _getIconUrl ;
9+ L . Icon . Default . mergeOptions ( {
10+ iconRetinaUrl : 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png' ,
11+ iconUrl : 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png' ,
12+ shadowUrl : 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png' ,
13+ } ) ;
14+
15+ // Create custom icons for different marker types
16+ const qthIcon = new L . Icon ( {
17+ iconUrl : 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png' ,
18+ shadowUrl : 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png' ,
19+ iconSize : [ 25 , 41 ] ,
20+ iconAnchor : [ 12 , 41 ] ,
21+ popupAnchor : [ 1 , - 34 ] ,
22+ shadowSize : [ 41 , 41 ]
23+ } ) ;
24+
25+ const contactIcon = new L . Icon ( {
26+ iconUrl : 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png' ,
27+ shadowUrl : 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png' ,
28+ iconSize : [ 25 , 41 ] ,
29+ iconAnchor : [ 12 , 41 ] ,
30+ popupAnchor : [ 1 , - 34 ] ,
31+ shadowSize : [ 41 , 41 ]
32+ } ) ;
33+
34+ interface ContactLocation {
35+ callsign : string ;
36+ name ?: string ;
37+ qth ?: string ;
38+ grid_locator ?: string ;
39+ latitude ?: number ;
40+ longitude ?: number ;
41+ country ?: string ;
42+ }
43+
44+ interface User {
45+ id : number ;
46+ email : string ;
47+ name : string ;
48+ callsign ?: string ;
49+ grid_locator ?: string ;
50+ }
51+
52+ interface ContactLocationMapProps {
53+ contact : ContactLocation ;
54+ user ?: User | null ;
55+ height ?: string ;
56+ }
57+
58+ // Function to convert grid locator to lat/lng (simplified implementation)
59+ const gridToLatLng = ( grid : string ) : [ number , number ] | null => {
60+ if ( ! grid || grid . length < 4 ) return null ;
61+
62+ const grid_upper = grid . toUpperCase ( ) ;
63+ const lon_field = grid_upper . charCodeAt ( 0 ) - 65 ;
64+ const lat_field = grid_upper . charCodeAt ( 1 ) - 65 ;
65+ const lon_square = parseInt ( grid_upper . charAt ( 2 ) ) ;
66+ const lat_square = parseInt ( grid_upper . charAt ( 3 ) ) ;
67+
68+ let lon = - 180 + ( lon_field * 20 ) + ( lon_square * 2 ) ;
69+ let lat = - 90 + ( lat_field * 10 ) + ( lat_square * 1 ) ;
70+
71+ // Add subsquare precision if available
72+ if ( grid . length >= 6 ) {
73+ const lon_subsquare = grid_upper . charCodeAt ( 4 ) - 65 ;
74+ const lat_subsquare = grid_upper . charCodeAt ( 5 ) - 65 ;
75+ lon += ( lon_subsquare * 2 / 24 ) + ( 1 / 24 ) ;
76+ lat += ( lat_subsquare * 1 / 24 ) + ( 1 / 48 ) ;
77+ } else {
78+ // Default to center of square
79+ lon += 1 ;
80+ lat += 0.5 ;
81+ }
82+
83+ return [ lat , lon ] ;
84+ } ;
85+
86+ // Component to handle map bounds fitting after markers are loaded
87+ function MapBoundsController ( { contact, user } : { contact : ContactLocation , user ?: User | null } ) {
88+ const map = useMap ( ) ;
89+
90+ useEffect ( ( ) => {
91+ if ( ! map ) return ;
92+
93+ // Collect all marker positions
94+ const allPositions : [ number , number ] [ ] = [ ] ;
95+
96+ // Add user's QTH if available
97+ if ( user ?. grid_locator ) {
98+ const userLocation = gridToLatLng ( user . grid_locator ) ;
99+ if ( userLocation ) {
100+ allPositions . push ( userLocation ) ;
101+ }
102+ }
103+
104+ // Add contact position
105+ let contactPosition : [ number , number ] | null = null ;
106+ if ( contact . latitude && contact . longitude ) {
107+ contactPosition = [ contact . latitude , contact . longitude ] ;
108+ } else if ( contact . grid_locator ) {
109+ contactPosition = gridToLatLng ( contact . grid_locator ) ;
110+ }
111+
112+ if ( contactPosition ) {
113+ allPositions . push ( contactPosition ) ;
114+ }
115+
116+ // Fit bounds to show all markers
117+ if ( allPositions . length > 0 ) {
118+ if ( allPositions . length === 1 ) {
119+ // If only one position, center on it with reasonable zoom
120+ map . setView ( allPositions [ 0 ] , 8 ) ;
121+ } else {
122+ // If multiple positions, fit bounds with padding
123+ const bounds = L . latLngBounds ( allPositions ) ;
124+ map . fitBounds ( bounds , {
125+ padding : [ 20 , 20 ] ,
126+ maxZoom : 10 // Prevent zooming in too much
127+ } ) ;
128+ }
129+ } else {
130+ // Fallback to default view if no contact location
131+ const mapCenter : [ number , number ] = user ?. grid_locator
132+ ? ( gridToLatLng ( user . grid_locator ) || [ 39.8283 , - 98.5795 ] )
133+ : [ 39.8283 , - 98.5795 ] ;
134+ map . setView ( mapCenter , user ?. grid_locator ? 8 : 4 ) ;
135+ }
136+ } , [ map , contact , user ] ) ;
137+
138+ return null ;
139+ }
140+
141+ export default function ContactLocationMap ( { contact, user, height = '300px' } : ContactLocationMapProps ) {
142+ const [ mounted , setMounted ] = useState ( false ) ;
143+
144+ useEffect ( ( ) => {
145+ setMounted ( true ) ;
146+ } , [ ] ) ;
147+
148+ if ( ! mounted ) {
149+ return < div className = "w-full bg-muted rounded-lg flex items-center justify-center" style = { { height } } >
150+ < span className = "text-muted-foreground" > Loading map...</ span >
151+ </ div > ;
152+ }
153+
154+ // Check if contact has location data
155+ const hasContactLocation = ( contact . latitude && contact . longitude ) ||
156+ ( contact . grid_locator && contact . grid_locator . length >= 4 ) ;
157+
158+ if ( ! hasContactLocation ) {
159+ return (
160+ < div className = "w-full bg-muted/50 rounded-lg border border-dashed border-muted-foreground/50 flex items-center justify-center text-center p-6" style = { { height } } >
161+ < div >
162+ < p className = "text-muted-foreground font-medium" > 📍 No Location Data</ p >
163+ < p className = "text-sm text-muted-foreground mt-1" >
164+ Location information not available for this callsign
165+ </ p >
166+ </ div >
167+ </ div >
168+ ) ;
169+ }
170+
171+ // Determine initial map center
172+ const getInitialMapCenter = ( ) : [ number , number ] => {
173+ // Try contact location first
174+ if ( contact . latitude && contact . longitude ) {
175+ return [ contact . latitude , contact . longitude ] ;
176+ } else if ( contact . grid_locator ) {
177+ const contactLocation = gridToLatLng ( contact . grid_locator ) ;
178+ if ( contactLocation ) return contactLocation ;
179+ }
180+
181+ // Fallback to user location
182+ if ( user ?. grid_locator ) {
183+ const userLocation = gridToLatLng ( user . grid_locator ) ;
184+ if ( userLocation ) return userLocation ;
185+ }
186+
187+ // Default center (US center)
188+ return [ 39.8283 , - 98.5795 ] ;
189+ } ;
190+
191+ const initialMapCenter = getInitialMapCenter ( ) ;
192+
193+ return (
194+ < div className = "w-full rounded-lg overflow-hidden border" style = { { height } } >
195+ < MapContainer
196+ center = { initialMapCenter }
197+ zoom = { 8 }
198+ style = { { height : '100%' , width : '100%' } }
199+ className = "z-0"
200+ >
201+ < TileLayer
202+ attribution = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
203+ url = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
204+ />
205+
206+ { /* Component to handle automatic bounds fitting */ }
207+ < MapBoundsController contact = { contact } user = { user } />
208+
209+ { /* User's QTH marker */ }
210+ { user ?. grid_locator && ( ( ) => {
211+ const userLocation = gridToLatLng ( user . grid_locator ) ;
212+ if ( userLocation ) {
213+ return (
214+ < Marker position = { userLocation } icon = { qthIcon } >
215+ < Popup >
216+ < div className = "min-w-[200px]" >
217+ < h3 className = "font-semibold text-lg text-red-600" > 🏠 Your QTH</ h3 >
218+ < div className = "mt-2 space-y-1 text-sm" >
219+ < p > < strong > Callsign:</ strong > { user . callsign || 'Not set' } </ p >
220+ < p > < strong > Name:</ strong > { user . name } </ p >
221+ < p > < strong > Grid:</ strong > { user . grid_locator } </ p >
222+ </ div >
223+ </ div >
224+ </ Popup >
225+ </ Marker >
226+ ) ;
227+ }
228+ return null ;
229+ } ) ( ) }
230+
231+ { /* Contact marker */ }
232+ { ( ( ) => {
233+ let position : [ number , number ] | null = null ;
234+
235+ // Use exact coordinates if available
236+ if ( contact . latitude && contact . longitude ) {
237+ position = [ contact . latitude , contact . longitude ] ;
238+ }
239+ // Otherwise convert grid locator
240+ else if ( contact . grid_locator ) {
241+ position = gridToLatLng ( contact . grid_locator ) ;
242+ }
243+
244+ if ( ! position ) return null ;
245+
246+ return (
247+ < Marker position = { position } icon = { contactIcon } >
248+ < Popup >
249+ < div className = "min-w-[200px]" >
250+ < h3 className = "font-semibold text-lg text-green-600" > 📻 { contact . callsign } </ h3 >
251+ { contact . name && < p className = "text-sm text-muted-foreground" > { contact . name } </ p > }
252+ < div className = "mt-2 space-y-1 text-sm" >
253+ { contact . qth && < p > < strong > QTH:</ strong > { contact . qth } </ p > }
254+ { contact . grid_locator && (
255+ < p > < strong > Grid:</ strong > { contact . grid_locator } </ p >
256+ ) }
257+ { contact . country && (
258+ < p > < strong > Country:</ strong > { contact . country } </ p >
259+ ) }
260+ { contact . latitude && contact . longitude && (
261+ < p > < strong > Coords:</ strong > { contact . latitude . toFixed ( 4 ) } , { contact . longitude . toFixed ( 4 ) } </ p >
262+ ) }
263+ </ div >
264+ </ div >
265+ </ Popup >
266+ </ Marker >
267+ ) ;
268+ } ) ( ) }
269+ </ MapContainer >
270+ </ div >
271+ ) ;
272+ }
0 commit comments