Skip to content

Commit ae02841

Browse files
Copilotpatrickrb
andauthored
Enhancement: Show Contact Location on Map in New Contact Form (#133)
* Initial plan * Implement contact location map enhancement Co-authored-by: patrickrb <[email protected]> * Add tests for contact location map enhancement Co-authored-by: patrickrb <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: patrickrb <[email protected]>
1 parent 76e6012 commit ae02841

File tree

3 files changed

+364
-0
lines changed

3 files changed

+364
-0
lines changed

src/app/new-contact/page.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Switch } from '@/components/ui/switch';
1212
import { Textarea } from '@/components/ui/textarea';
1313
import { ArrowLeft, Loader2, Search, Check, AlertCircle, Radio, Clock } from 'lucide-react';
1414
import Navbar from '@/components/Navbar';
15+
import ContactLocationMap from '@/components/ContactLocationMap';
1516

1617
interface Station {
1718
id: number;
@@ -52,15 +53,24 @@ export default function NewContactPage() {
5253
grid_locator?: string;
5354
latitude?: number;
5455
longitude?: number;
56+
country?: string;
5557
error?: string;
5658
} | null>(null);
59+
const [currentUser, setCurrentUser] = useState<{
60+
id: number;
61+
email: string;
62+
name: string;
63+
callsign?: string;
64+
grid_locator?: string;
65+
} | null>(null);
5766
const router = useRouter();
5867

5968
const modes = ['SSB', 'CW', 'FM', 'AM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'OLIVIA', 'CONTESTIA'];
6069
const bands = ['160M', '80M', '60M', '40M', '30M', '20M', '17M', '15M', '12M', '10M', '6M', '2M', '1.25M', '70CM', '33CM', '23CM'];
6170

6271
useEffect(() => {
6372
fetchStations();
73+
fetchCurrentUser();
6474
}, []);
6575

6676
// Live logging effect - update datetime every second when enabled
@@ -193,6 +203,18 @@ export default function NewContactPage() {
193203
}
194204
};
195205

206+
const fetchCurrentUser = async () => {
207+
try {
208+
const response = await fetch('/api/user');
209+
if (response.ok) {
210+
const userData = await response.json();
211+
setCurrentUser(userData);
212+
}
213+
} catch {
214+
// Silent error handling for user fetch
215+
}
216+
};
217+
196218
// Validation functions
197219
const validateCallsign = (callsign: string): string | null => {
198220
if (!callsign.trim()) return null;
@@ -751,6 +773,30 @@ export default function NewContactPage() {
751773
</div>
752774
</div>
753775

776+
{/* Contact Location Map Section */}
777+
{lookupResult && lookupResult.found && (
778+
<div className="space-y-4">
779+
<div>
780+
<h3 className="text-lg font-medium text-foreground mb-4 pb-2 border-b border-border">
781+
Contact Location
782+
</h3>
783+
</div>
784+
<ContactLocationMap
785+
contact={{
786+
callsign: formData.callsign,
787+
name: formData.name,
788+
qth: formData.qth,
789+
grid_locator: formData.gridLocator,
790+
latitude: formData.latitude,
791+
longitude: formData.longitude,
792+
country: lookupResult.country
793+
}}
794+
user={currentUser}
795+
height="300px"
796+
/>
797+
</div>
798+
)}
799+
754800
<div className="space-y-2">
755801
<Label htmlFor="notes">Notes</Label>
756802
<Textarea
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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='&copy; <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

Comments
 (0)