1- import React , { useEffect } from "react" ;
2- import { MapContainer , TileLayer , useMap } from "react-leaflet" ;
3- import Marker from "../map/Marker" ;
1+ import React , { useEffect , useMemo , useRef , useState } from "react" ;
2+ import MapGL , {
3+ Layer ,
4+ NavigationControl ,
5+ Popup ,
6+ Source ,
7+ type LayerProps ,
8+ type MapRef ,
9+ } from "react-map-gl/mapbox" ;
10+ import type { FeatureCollection , Point } from "geojson" ;
411import { Location } from "../../types" ;
512
613interface MapProps {
@@ -20,6 +27,7 @@ function toDegrees(radians: number): number {
2027function getGeographicCenter (
2128 locations : Location [ ]
2229) : [ number , number ] {
30+ if ( ! locations . length ) return [ 39.9515 , - 75.191 ] ;
2331 let x = 0 ;
2432 let y = 0 ;
2533 let z = 0 ;
@@ -79,48 +87,174 @@ function separateOverlappingPoints(points: Location[], offset = 0.0001) {
7987 return adjustedPoints ;
8088}
8189
82- interface InnerMapProps {
83- locations : Location [ ] ;
84- center : [ number , number ]
85- }
90+ function Map ( { locations, zoom } : MapProps ) {
91+ const mapRef = useRef < MapRef | null > ( null ) ;
92+ const mapboxToken = process . env . NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN ;
93+ const mapboxStyleId = process . env . NEXT_PUBLIC_MAPBOX_STYLE_ID || "mapbox/streets-v12" ;
94+ const [ cursor , setCursor ] = useState < string > ( "" ) ;
95+ const [ selected , setSelected ] = useState < {
96+ longitude : number ;
97+ latitude : number ;
98+ id ?: string ;
99+ room ?: string ;
100+ start ?: number ;
101+ end ?: number ;
102+ color ?: string ;
103+ } | null > ( null ) ;
86104
87- // need inner child component to use useMap hook to run on client
88- function InnerMap ( { locations, center } :InnerMapProps ) {
89- const map = useMap ( ) ;
105+ const mapStyle = useMemo ( ( ) => {
106+ if ( mapboxStyleId . startsWith ( "mapbox://" ) ) return mapboxStyleId ;
107+ return `mapbox://styles/${ mapboxStyleId } ` ;
108+ } , [ mapboxStyleId ] ) ;
90109
91- useEffect ( ( ) => {
92- map . flyTo ( { lat : center [ 0 ] , lng : center [ 1 ] } )
93- } , [ center [ 0 ] , center [ 1 ] ] )
110+ const center = useMemo ( ( ) => getGeographicCenter ( locations ) , [ locations ] ) ;
111+ const points = useMemo ( ( ) => separateOverlappingPoints ( locations ) , [ locations ] ) ;
112+ const markerGeoJson = useMemo <
113+ FeatureCollection < Point , { color ?: string ; id ?: string ; room ?: string ; start ?: number ; end ?: number } >
114+ > ( ( ) => {
115+ return {
116+ type : "FeatureCollection" ,
117+ features : points . map ( ( p ) => ( {
118+ type : "Feature" ,
119+ properties : {
120+ color : p . color ,
121+ id : p . id ,
122+ room : p . room ,
123+ start : p . start ,
124+ end : p . end ,
125+ } ,
126+ geometry : { type : "Point" , coordinates : [ p . lng , p . lat ] } ,
127+ } ) ) ,
128+ } ;
129+ } , [ points ] ) ;
94130
95- return (
96- < >
97- < TileLayer
98- // @ts -ignore
99- attribution = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
100- url = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
101- />
102- { separateOverlappingPoints ( locations ) . map ( ( { lat, lng, color } , i ) => (
103- < Marker key = { i } lat = { lat } lng = { lng } color = { color } />
104- ) ) }
105- </ >
106- )
131+ const markerLayer = useMemo < LayerProps > (
132+ ( ) => ( {
133+ id : "pcp-course-markers" ,
134+ type : "circle" ,
135+ paint : {
136+ "circle-radius" : 6 ,
137+ "circle-color" : [ "coalesce" , [ "get" , "color" ] , "#878ED8" ] ,
138+ "circle-stroke-color" : "rgba(0,0,0,0.35)" ,
139+ "circle-stroke-width" : 2 ,
140+ } ,
141+ } ) ,
142+ [ ]
143+ ) ;
107144
108- }
109145
110- function Map ( { locations, zoom } : MapProps ) {
111- const center = getGeographicCenter ( locations ) ;
112-
146+ const formatTime = ( t ?: number ) => {
147+ if ( t == null ) return "" ;
148+ const hours24 = Math . floor ( t ) ;
149+ const minutes = Math . round ( ( t % 1 ) * 100 ) ;
150+ const period = hours24 >= 12 ? "PM" : "AM" ;
151+ const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12 ;
152+ return `${ hours12 } :${ minutes . toString ( ) . padStart ( 2 , "0" ) } ${ period } ` ;
153+ } ;
154+
155+ useEffect ( ( ) => {
156+ if ( ! mapRef . current ) return ;
157+ mapRef . current . flyTo ( {
158+ center : [ center [ 1 ] , center [ 0 ] ] ,
159+ zoom,
160+ essential : true ,
161+ } ) ;
162+ } , [ center , zoom ] ) ;
163+
164+ if ( ! mapboxToken ) {
165+ return (
166+ < div
167+ style = { {
168+ height : "100%" ,
169+ width : "100%" ,
170+ display : "flex" ,
171+ alignItems : "center" ,
172+ justifyContent : "center" ,
173+ color : "#6b7280" ,
174+ fontSize : "0.9rem" ,
175+ background : "#f9fafb" ,
176+ borderRadius : 8 ,
177+ } }
178+ >
179+ Missing `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`
180+ </ div >
181+ ) ;
182+ }
183+
113184 return (
114- < MapContainer
115- // @ts -ignore
116- center = { center }
117- zoom = { zoom }
118- zoomControl = { false }
119- scrollWheelZoom = { true }
185+ < MapGL
186+ ref = { mapRef }
187+ mapboxAccessToken = { mapboxToken }
188+ mapStyle = { mapStyle }
189+ initialViewState = { {
190+ latitude : center [ 0 ] ,
191+ longitude : center [ 1 ] ,
192+ zoom,
193+ pitch : 0 ,
194+ bearing : 0 ,
195+ } }
120196 style = { { height : "100%" , width : "100%" } }
197+ attributionControl
198+ dragRotate
199+ touchPitch
200+ maxPitch = { 70 }
201+ interactiveLayerIds = { [ "pcp-course-markers" ] }
202+ cursor = { cursor }
203+ onMouseMove = { ( e ) => {
204+ const hovering = ( e . features ?. length || 0 ) > 0 ;
205+ setCursor ( hovering ? "pointer" : "" ) ;
206+ } }
207+ onClick = { ( e ) => {
208+ const f = e . features ?. [ 0 ] ;
209+ if ( ! f || f . geometry . type !== "Point" ) {
210+ setSelected ( null ) ;
211+ return ;
212+ }
213+ const [ lng , lat ] = f . geometry . coordinates as [ number , number ] ;
214+ const props = ( f . properties || { } ) as Record < string , unknown > ;
215+ setSelected ( {
216+ longitude : lng ,
217+ latitude : lat ,
218+ id : typeof props . id === "string" ? props . id : undefined ,
219+ room : typeof props . room === "string" ? props . room : undefined ,
220+ start : typeof props . start === "number" ? props . start : undefined ,
221+ end : typeof props . end === "number" ? props . end : undefined ,
222+ color : typeof props . color === "string" ? props . color : undefined ,
223+ } ) ;
224+ } }
121225 >
122- < InnerMap locations = { locations } center = { center } />
123- </ MapContainer >
226+ < NavigationControl showCompass showZoom visualizePitch position = "top-left" />
227+
228+ < Source id = "pcp-course-markers-source" type = "geojson" data = { markerGeoJson } >
229+ < Layer { ...markerLayer } />
230+ </ Source >
231+
232+ { selected && (
233+ < Popup
234+ longitude = { selected . longitude }
235+ latitude = { selected . latitude }
236+ anchor = "top"
237+ closeButton
238+ closeOnClick = { false }
239+ onClose = { ( ) => setSelected ( null ) }
240+ maxWidth = "260px"
241+ >
242+ < div style = { { fontSize : "0.85rem" , lineHeight : 1.25 } } >
243+ { selected . id && (
244+ < div style = { { fontWeight : 700 , marginBottom : 4 } } >
245+ { selected . id . replace ( / - / g, " " ) }
246+ </ div >
247+ ) }
248+ { ( selected . start != null || selected . end != null ) && (
249+ < div style = { { marginBottom : 2 } } >
250+ { formatTime ( selected . start ) } { selected . end != null ? `–${ formatTime ( selected . end ) } ` : "" }
251+ </ div >
252+ ) }
253+ { selected . room && < div > { selected . room } </ div > }
254+ </ div >
255+ </ Popup >
256+ ) }
257+ </ MapGL >
124258 ) ;
125259} ;
126260
0 commit comments