Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
99 changes: 99 additions & 0 deletions frontend/src/utils/geolocationUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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
}

/* 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,
}
)
})
}

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)
.sort((a, b) => a!.distance - b!.distance)
}