Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
27 changes: 25 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,27 @@ const ChapterMap = ({

markerClusterGroup.addLayers(markers)

if (showLocal && validGeoLocData.length > 0) {
// Zoom in when user shares location
if (userLocation && validGeoLocData.length > 0) {
const maxNearestChapters = 5
const localChapters = validGeoLocData.slice(0, maxNearestChapters - 1)
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 +146,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
104 changes: 104 additions & 0 deletions frontend/src/utils/geolocationUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
export interface UserLocation {
latitude: number
longitude: number
city?: string
country?: string
}

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
}

// # NOSONAR Geolocation permission is necessary for "Find chapters near you" feature.
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,
}
)
})
}

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

const lng =
(chapter._geoloc as Record<string, unknown>)?.lng ??
(chapter._geoloc as Record<string, unknown>)?.lon ??
(chapter.geoLocation as Record<string, unknown>)?.lng ??
(chapter.geoLocation as Record<string, unknown>)?.lon ??
(chapter.geoLocation as Record<string, unknown>)?.longitude ??
(chapter.location as Record<string, unknown>)?.lng ??
(chapter.location as Record<string, unknown>)?.lon ??
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 }
>
}