1- import { useLocalStorage } from '@vueuse/core' ;
1+ import { useDebounceFn , useLocalStorage } from '@vueuse/core' ;
22import BoxArrowUpRight from 'bootstrap-icons/icons/box-arrow-up-right.svg?raw' ;
3+ import Search from 'bootstrap-icons/icons/search.svg?raw' ;
34import Stack from 'bootstrap-icons/icons/stack.svg?raw' ;
45import maplibregl from 'maplibre-gl' ;
5- import { onMounted , type Ref } from 'vue' ;
6+ import { onMounted , onUnmounted , type Ref } from 'vue' ;
7+
8+ import { search as nominatimSearch , type NominatimResult } from '../api/nominatim' ;
69
710import 'maplibre-gl/dist/maplibre-gl.css' ;
811
@@ -100,8 +103,11 @@ class BaseLayerControl implements maplibregl.IControl {
100103 const container = document . createElement ( 'div' ) ;
101104 container . className = 'maplibregl-ctrl maplibregl-ctrl-group lt-layers' ;
102105
103- const toggle = document . createElement ( 'div' ) ;
106+ const toggle = document . createElement ( 'button' ) ;
107+ toggle . type = 'button' ;
104108 toggle . className = 'lt-layers-toggle' ;
109+ toggle . setAttribute ( 'aria-label' , 'Base layer' ) ;
110+ toggle . setAttribute ( 'aria-haspopup' , 'true' ) ;
105111 toggle . innerHTML = Stack ;
106112 container . append ( toggle ) ;
107113
@@ -142,6 +148,73 @@ class BaseLayerControl implements maplibregl.IControl {
142148 }
143149}
144150
151+ /** Custom control to search for a place or address, backed by Nominatim. */
152+ class GeocoderControl implements maplibregl . IControl {
153+ private map ?: maplibregl . Map ;
154+ private results ?: HTMLElement ;
155+ private requestId = 0 ;
156+ private readonly search = useDebounceFn ( ( query : string ) => this . runSearch ( query ) , 300 ) ;
157+
158+ onAdd ( map : maplibregl . Map ) : HTMLElement {
159+ this . map = map ;
160+ const container = document . createElement ( 'div' ) ;
161+ container . className = 'maplibregl-ctrl maplibregl-ctrl-group lt-geocoder' ;
162+
163+ const icon = document . createElement ( 'span' ) ;
164+ icon . className = 'lt-geocoder-icon' ;
165+ icon . setAttribute ( 'aria-hidden' , 'true' ) ;
166+ icon . innerHTML = Search ;
167+ container . append ( icon ) ;
168+
169+ const input = document . createElement ( 'input' ) ;
170+ input . type = 'search' ;
171+ input . placeholder = '…' ;
172+ input . setAttribute ( 'aria-label' , 'Search for a place or address' ) ;
173+ input . addEventListener ( 'input' , ( ) => this . search ( input . value ) ) ;
174+ container . append ( input ) ;
175+
176+ const results = document . createElement ( 'ul' ) ;
177+ results . className = 'lt-geocoder-results' ;
178+ container . append ( results ) ;
179+ this . results = results ;
180+
181+ return container ;
182+ }
183+
184+ private async runSearch ( query : string ) : Promise < void > {
185+ const requestId = ++ this . requestId ;
186+ const results = this . results ;
187+ if ( ! results ) return ;
188+ results . replaceChildren ( ) ;
189+ if ( ! query . trim ( ) ) return ;
190+ const places = await nominatimSearch ( query ) ;
191+ if ( requestId !== this . requestId ) return ;
192+ for ( const place of places ) {
193+ const li = document . createElement ( 'li' ) ;
194+ const button = document . createElement ( 'button' ) ;
195+ button . type = 'button' ;
196+ button . textContent = place . display_name ;
197+ button . addEventListener ( 'click' , ( ) => this . select ( place ) ) ;
198+ li . append ( button ) ;
199+ results . append ( li ) ;
200+ }
201+ }
202+
203+ private select ( place : NominatimResult ) : void {
204+ const [ south , north , west , east ] = place . boundingbox . map ( Number ) ;
205+ this . map ?. fitBounds ( [
206+ [ west , south ] ,
207+ [ east , north ]
208+ ] ) ;
209+ this . results ?. replaceChildren ( ) ;
210+ }
211+
212+ onRemove ( ) : void {
213+ this . map = undefined ;
214+ this . results = undefined ;
215+ }
216+ }
217+
145218export function useMaplibreMap ( mapRef : Ref < HTMLElement | null > ) {
146219 const mapLayer = useLocalStorage ( 'mapLayer' , '' ) ;
147220 const mapView = useLocalStorage < MapView > ( 'mapView' , {
@@ -171,6 +244,7 @@ export function useMaplibreMap(mapRef: Ref<HTMLElement | null>) {
171244 } )
172245 ) ;
173246 map . addControl ( new maplibregl . NavigationControl ( ) , 'top-left' ) ;
247+ map . addControl ( new GeocoderControl ( ) , 'top-left' ) ;
174248 map . addControl ( new BaseLayerControl ( mapLayer , osm ) , 'top-right' ) ;
175249
176250 map . on ( 'moveend' , ( ) => {
@@ -179,6 +253,8 @@ export function useMaplibreMap(mapRef: Ref<HTMLElement | null>) {
179253 } ) ;
180254 } ) ;
181255
256+ onUnmounted ( ( ) => map ?. remove ( ) ) ;
257+
182258 return {
183259 get map ( ) {
184260 return map ;
0 commit comments