Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/app/chapters/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const ChaptersPage = () => {
<ChapterMapWrapper
geoLocData={searchQuery ? chapters : geoLocData}
showLocal={true}
showLocationSharing={true}
style={{
height: '400px',
width: '100%',
Expand Down
26 changes: 24 additions & 2 deletions frontend/src/components/ChapterMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import L, { MarkerClusterGroup } from 'leaflet'
import React, { useEffect, useRef, useState } from 'react'
import type { Chapter } from 'types/chapter'
import type { UserLocation } from 'utils/geolocationUtils'
import 'leaflet.markercluster'
import 'leaflet/dist/leaflet.css'
import 'leaflet.markercluster/dist/MarkerCluster.css'
Expand All @@ -12,10 +13,12 @@ const ChapterMap = ({
geoLocData,
showLocal,
style,
userLocation,
}: {
geoLocData: Chapter[]
showLocal: boolean
style: React.CSSProperties
userLocation?: UserLocation | null
}) => {
const mapRef = useRef<L.Map | null>(null)
const markerClusterRef = useRef<MarkerClusterGroup | null>(null)
Expand Down Expand Up @@ -103,7 +106,26 @@ const ChapterMap = ({

markerClusterGroup.addLayers(markers)

if (showLocal && validGeoLocData.length > 0) {
if (userLocation && validGeoLocData.length > 0) {
const maxNearestChapters = 5
const localChapters = validGeoLocData.slice(0, maxNearestChapters)
const localBounds = L.latLngBounds(
localChapters.map((chapter) => [
chapter._geoloc?.lat ?? chapter.geoLocation?.lat,
chapter._geoloc?.lng ?? chapter.geoLocation?.lng,
])
)
const maxZoom = 12
const nearestChapter = validGeoLocData[0]
map.setView(
[
nearestChapter._geoloc?.lat ?? nearestChapter.geoLocation?.lat,
nearestChapter._geoloc?.lng ?? nearestChapter.geoLocation?.lng,
],
maxZoom
)
map.fitBounds(localBounds, { maxZoom: maxZoom })
} else if (showLocal && validGeoLocData.length > 0) {
const maxNearestChapters = 5
const localChapters = validGeoLocData.slice(0, maxNearestChapters - 1)
const localBounds = L.latLngBounds(
Expand All @@ -123,7 +145,7 @@ const ChapterMap = ({
)
map.fitBounds(localBounds, { maxZoom: maxZoom })
}
}, [geoLocData, showLocal])
}, [geoLocData, showLocal, userLocation])

return (
<div className="relative" style={style}>
Expand Down
98 changes: 94 additions & 4 deletions frontend/src/components/ChapterMapWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,105 @@
import { faLocationDot } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Button } from '@heroui/button'
import dynamic from 'next/dynamic'
import React from 'react'
import React, { useState } from 'react'
import type { Chapter } from 'types/chapter'
import {
getUserLocationFromBrowser,
sortChaptersByDistance,
type UserLocation,
} from 'utils/geolocationUtils'

const ChapterMap = dynamic(() => import('./ChapterMap'), { ssr: false })

const ChapterMapWrapper = (props: {
interface ChapterMapWrapperProps {
geoLocData: Chapter[]
showLocal: boolean
style: React.CSSProperties
}) => {
return <ChapterMap {...props} />
showLocationSharing?: boolean
}

const ChapterMapWrapper: React.FC<ChapterMapWrapperProps> = (props) => {
const [userLocation, setUserLocation] = useState<UserLocation | null>(null)
const [isLoadingLocation, setIsLoadingLocation] = useState(false)
const [sortedData, setSortedData] = useState<Chapter[] | null>(null)

const enableLocationSharing = props.showLocationSharing === true

if (!enableLocationSharing) {
return <ChapterMap {...props} />
}

const handleShareLocation = async () => {
if (userLocation) {
setUserLocation(null)
setSortedData(null)
return
}

setIsLoadingLocation(true)

try {
const location = await getUserLocationFromBrowser()

if (location) {
setUserLocation(location)
const sorted = sortChaptersByDistance(props.geoLocData, location)
setSortedData(sorted.map(({ _distance, ...chapter }) => chapter as unknown as Chapter))
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error detecting location:', error)
} finally {
setIsLoadingLocation(false)
}
}

const mapData = sortedData ?? props.geoLocData

return (
<div className="space-y-4">
<div className="mb-4 flex items-center gap-3 rounded-lg bg-gray-100 p-4 shadow-md dark:bg-gray-800">
<Button
isIconOnly
className="bg-blue-500 text-white hover:bg-blue-600"
onClick={handleShareLocation}
isLoading={isLoadingLocation}
disabled={isLoadingLocation}
aria-label={
userLocation ? 'Reset location filter' : 'Share location to find nearby chapters'
}
title={userLocation ? 'Reset location filter' : 'Share location to find nearby chapters'}
>
<FontAwesomeIcon icon={faLocationDot} size="lg" />
</Button>

<div className="text-sm text-gray-700 dark:text-gray-300">
{userLocation ? (
<>
<div className="font-semibold text-blue-600 dark:text-blue-400">
📍 Showing chapters near you
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
Location: {userLocation.latitude.toFixed(2)}, {userLocation.longitude.toFixed(2)}
</div>
</>
) : (
<>
<div className="font-semibold text-gray-800 dark:text-gray-200">
Find chapters near you
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
Click the blue button to use your current location
</div>
</>
)}
</div>
</div>

<ChapterMap {...props} geoLocData={mapData} userLocation={userLocation} />
</div>
)
}

export default ChapterMapWrapper
101 changes: 101 additions & 0 deletions frontend/src/utils/geolocationUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
export interface UserLocation {
latitude: number
longitude: number
city?: string
country?: string
}
Comment on lines +1 to +6
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove or document unused optional fields.

The city and country fields are defined but never populated by getUserLocationFromBrowser (which only sets latitude and longitude). Either remove these unused fields or add a comment indicating they're reserved for future enhancement.

Apply this diff to remove the unused fields:

 export interface UserLocation {
   latitude: number
   longitude: number
-  city?: string
-  country?: string
 }

Alternatively, if these are planned for future use, add a comment:

 export interface UserLocation {
   latitude: number
   longitude: number
+  // Reserved for future enhancement (reverse geocoding)
   city?: string
   country?: string
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export interface UserLocation {
latitude: number
longitude: number
city?: string
country?: string
}
export interface UserLocation {
latitude: number
longitude: number
}
Suggested change
export interface UserLocation {
latitude: number
longitude: number
city?: string
country?: string
}
export interface UserLocation {
latitude: number
longitude: number
// Reserved for future enhancement (reverse geocoding)
city?: string
country?: string
}
🤖 Prompt for AI Agents
In frontend/src/utils/geolocationUtils.ts around lines 1 to 6, the UserLocation
interface currently declares optional city and country fields that are never
populated by getUserLocationFromBrowser; either remove these unused fields from
the interface or retain them but add a clear comment on their intended future
use (e.g., "reserved for future reverse-geocoding/population") so reviewers know
they are intentional; update the interface accordingly and, if keeping them, add
a brief comment above each field explaining why it's present and when it will be
populated.


interface ChapterCoordinates {
lat: number | null
lng: number | null
}

export const calculateDistance = (
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number => {
const R = 6371
const dLat = ((lat2 - lat1) * Math.PI) / 180
const dLon = ((lon2 - lon1) * Math.PI) / 180

const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2

const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}

export const getUserLocationFromBrowser = (): Promise<UserLocation | null> => {
return new Promise((resolve) => {
if (!navigator.geolocation) {
// eslint-disable-next-line no-console
console.warn('Geolocation API not supported')
resolve(null)
return
}

/* Geolocation permission is required for the "Find chapters near you" feature.
The user must explicitly opt in by clicking a button. The location data never
leaves the client and is not sent to the backend. It is used only to calculate
distances between the user and nearby chapters.
*/
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
})
},
(error) => {
// eslint-disable-next-line no-console
console.warn('Browser geolocation error:', error.message)
resolve(null)
},
{
enableHighAccuracy: false,
timeout: 10000,
maximumAge: 0,
}
)
}) // NOSONAR
}

const extractChapterCoordinates = (chapter: Record<string, unknown>): ChapterCoordinates => {
const lat =
(chapter._geoloc as Record<string, unknown>)?.lat ??
(chapter.geoLocation as Record<string, unknown>)?.lat ??
null

const lng =
(chapter._geoloc as Record<string, unknown>)?.lng ??
(chapter.geoLocation as Record<string, unknown>)?.lng ??
null

return { lat: lat as number | null, lng: lng as number | null }
}

/**
* Sort chapters by distance from user
*/
export const sortChaptersByDistance = (
chapters: Record<string, unknown>[],
userLocation: UserLocation
): Array<Record<string, unknown> & { distance: number }> => {
return chapters
.map((chapter) => {
const { lat, lng } = extractChapterCoordinates(chapter)

if (typeof lat !== 'number' || typeof lng !== 'number') return null

const distance = calculateDistance(userLocation.latitude, userLocation.longitude, lat, lng)

return { ...chapter, distance }
})
.filter((item) => item !== null) // remove invalid ones
.sort((a, b) => a!.distance - b!.distance) as Array<
Record<string, unknown> & { distance: number }
>
}