1212 */
1313import { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
1414import dynamic from 'next/dynamic' ;
15- import { StadiaTileProvider , type TimeBandMin } from '@ilsochrone/providers' ;
15+ import {
16+ StadiaTileProvider ,
17+ type Poi ,
18+ type PoiCategory ,
19+ type TimeBandMin ,
20+ } from '@ilsochrone/providers' ;
1621import { TimeSelector } from '@/components/controls/TimeSelector' ;
1722import { ModeSelector } from '@/components/controls/ModeSelector' ;
23+ import { CategoryToggles } from '@/components/controls/CategoryToggles' ;
1824import { DestinationCard } from '@/components/destination/DestinationCard' ;
1925import { tryGeolocate } from '@/lib/geolocation' ;
2026import { useIsochrone } from '@/lib/hooks/useIsochrone' ;
27+ import { usePois } from '@/lib/hooks/usePois' ;
28+ import { bboxOf } from '@/lib/polygon' ;
2129import { PUBLIC_CONFIG } from '@/lib/config' ;
2230import {
2331 DEFAULT_CATEGORIES ,
@@ -26,11 +34,15 @@ import {
2634 type AppUrlState ,
2735} from '@/lib/url-state' ;
2836
29- // Client-only import : MapLibre touches `window` at module load.
37+ // Client-only imports : MapLibre touches `window` at module load.
3038const IlsochroneMap = dynamic (
3139 ( ) => import ( '@/components/map/IlsochroneMap.client' ) . then ( ( m ) => m . IlsochroneMap ) ,
3240 { ssr : false , loading : ( ) => < div className = "h-full w-full bg-muted" aria-hidden /> } ,
3341) ;
42+ const PoiLayer = dynamic (
43+ ( ) => import ( '@/components/map/PoiLayer' ) . then ( ( m ) => m . PoiLayer ) ,
44+ { ssr : false } ,
45+ ) ;
3446
3547export default function HomePage ( ) {
3648 const tileProvider = useMemo (
@@ -59,6 +71,7 @@ export default function HomePage() {
5971
6072 const [ state , setState ] = useState < AppUrlState > ( initialState ) ;
6173 const [ destination , setDestination ] = useState < { lng : number ; lat : number } | null > ( null ) ;
74+ const [ selectedPoi , setSelectedPoi ] = useState < Poi | null > ( null ) ;
6275
6376 // On mount: if no `lng`/`lat` in URL, ask for geolocation and use it.
6477 const askedGeoRef = useRef ( false ) ;
@@ -97,11 +110,30 @@ export default function HomePage() {
97110 setState ( ( s ) => ( { ...s , mode } ) ) ;
98111 } , [ ] ) ;
99112
100- const onMapClick = useCallback ( ( lngLat : { lng : number ; lat : number } ) => {
113+ const onPickDestination = useCallback ( ( lngLat : { lng : number ; lat : number } ) => {
101114 setDestination ( lngLat ) ;
115+ setSelectedPoi ( null ) ;
116+ } , [ ] ) ;
117+
118+ const onMapBackgroundClick = useCallback ( ( ) => {
119+ // Plain click dismisses any open destination card. No pin is dropped.
120+ setDestination ( null ) ;
121+ setSelectedPoi ( null ) ;
122+ } , [ ] ) ;
123+
124+ const onDismissDestination = useCallback ( ( ) => {
125+ setDestination ( null ) ;
126+ setSelectedPoi ( null ) ;
102127 } , [ ] ) ;
103128
104- const onDismissDestination = useCallback ( ( ) => setDestination ( null ) , [ ] ) ;
129+ const onCategoriesChange = useCallback ( ( categories : PoiCategory [ ] ) => {
130+ setState ( ( s ) => ( { ...s , categories } ) ) ;
131+ } , [ ] ) ;
132+
133+ const onPoiSelect = useCallback ( ( poi : Poi ) => {
134+ setSelectedPoi ( poi ) ;
135+ setDestination ( null ) ;
136+ } , [ ] ) ;
105137
106138 const { data, error, isLoading } = useIsochrone ( {
107139 lng : state . origin . lng ,
@@ -110,6 +142,42 @@ export default function HomePage() {
110142 mode : state . mode ,
111143 } ) ;
112144
145+ // POIs are scoped to the polygon's bbox; we filter to inside the polygon
146+ // client-side in PoiLayer. Skip fetch entirely if no polygon or no categories.
147+ const polygonBbox = useMemo (
148+ ( ) => ( data ?. polygon ? bboxOf ( data . polygon ) : null ) ,
149+ [ data ?. polygon ] ,
150+ ) ;
151+ const { data : poiData } = usePois ( {
152+ bbox : polygonBbox ,
153+ categories : state . categories ,
154+ } ) ;
155+ const pois = poiData ?. pois ?? [ ] ;
156+
157+ // Determine which destination (if any) gets the popup. POI selection wins
158+ // over a right-click drop point.
159+ const activeDestination = useMemo ( ( ) => {
160+ if ( selectedPoi ) {
161+ return {
162+ lng : selectedPoi . lngLat [ 0 ] ,
163+ lat : selectedPoi . lngLat [ 1 ] ,
164+ title : selectedPoi . name ,
165+ subtitle : selectedPoi . category ,
166+ sourceUrl : selectedPoi . sourceUrl ,
167+ } ;
168+ }
169+ if ( destination ) {
170+ return {
171+ lng : destination . lng ,
172+ lat : destination . lat ,
173+ title : 'Drop point' ,
174+ subtitle : `${ destination . lat . toFixed ( 5 ) } , ${ destination . lng . toFixed ( 5 ) } ` ,
175+ sourceUrl : undefined ,
176+ } ;
177+ }
178+ return null ;
179+ } , [ selectedPoi , destination ] ) ;
180+
113181 return (
114182 < main className = "relative h-screen w-screen overflow-hidden" >
115183 < div className = "absolute inset-0" >
@@ -130,17 +198,22 @@ export default function HomePage() {
130198 origin = { state . origin }
131199 onOriginDragEnd = { onOriginDragEnd }
132200 polygon = { data ?. polygon }
133- onMapClick = { onMapClick }
201+ onPickDestination = { onPickDestination }
202+ onMapBackgroundClick = { onMapBackgroundClick }
134203 destination = {
135- destination
204+ activeDestination
136205 ? {
137- lng : destination . lng ,
138- lat : destination . lat ,
206+ lng : activeDestination . lng ,
207+ lat : activeDestination . lat ,
139208 popup : (
140209 < DestinationCard
141- title = "Drop point"
142- subtitle = { `${ destination . lat . toFixed ( 5 ) } , ${ destination . lng . toFixed ( 5 ) } ` }
143- destination = { destination }
210+ title = { activeDestination . title }
211+ subtitle = { activeDestination . subtitle }
212+ destination = { {
213+ lng : activeDestination . lng ,
214+ lat : activeDestination . lat ,
215+ name : selectedPoi ?. name ,
216+ } }
144217 origin = { state . origin }
145218 mode = { state . mode }
146219 onClose = { onDismissDestination }
@@ -149,17 +222,33 @@ export default function HomePage() {
149222 }
150223 : undefined
151224 }
225+ poiMarkers = {
226+ < PoiLayer
227+ pois = { pois }
228+ polygon = { data ?. polygon }
229+ onSelect = { onPoiSelect }
230+ selectedId = { selectedPoi ?. id }
231+ />
232+ }
152233 />
153234 </ div >
154235
155236 < header className = "pointer-events-none absolute left-0 right-0 top-0 flex flex-wrap items-start justify-between gap-3 p-4" >
156237 < div className = "pointer-events-auto rounded-lg bg-background/95 px-4 py-2 shadow-md ring-1 ring-border backdrop-blur" >
157238 < h1 className = "text-base font-semibold" > Ilsochrone</ h1 >
158- < p className = "text-xs text-muted-foreground" > Where can you get in { state . minutes } min?</ p >
239+ < p className = "text-xs text-muted-foreground" >
240+ Where can you get in { state . minutes } min?
241+ </ p >
242+ < p className = "mt-0.5 text-[10px] text-muted-foreground/70" >
243+ Drag the pin to move origin · right-click the map to drop a destination
244+ </ p >
159245 </ div >
160- < div className = "pointer-events-auto flex flex-wrap gap-2" >
161- < ModeSelector value = { state . mode } onChange = { onModeChange } />
162- < TimeSelector value = { state . minutes } onChange = { onMinutesChange } />
246+ < div className = "pointer-events-auto flex flex-col items-end gap-2" >
247+ < div className = "flex flex-wrap gap-2" >
248+ < ModeSelector value = { state . mode } onChange = { onModeChange } />
249+ < TimeSelector value = { state . minutes } onChange = { onMinutesChange } />
250+ </ div >
251+ < CategoryToggles value = { state . categories } onChange = { onCategoriesChange } />
163252 </ div >
164253 </ header >
165254
0 commit comments