UNPKG

leaflet-geosearch

Version:

Adds support for address lookup (a.k.a. geocoding / geosearching) to Leaflet.

499 lines (417 loc) 12.4 kB
import * as L from 'leaflet'; import { ControlPosition, FeatureGroup, MarkerOptions, Map } from 'leaflet'; import SearchElement from './SearchElement'; import ResultList from './resultList'; import debounce from './lib/debounce'; import { createElement, addClassName, removeClassName, stopPropagation, } from './domUtils'; import { ENTER_KEY, SPECIAL_KEYS, ARROW_UP_KEY, ARROW_DOWN_KEY, ESCAPE_KEY, } from './constants'; import AbstractProvider, { SearchResult } from './providers/provider'; import { Provider } from './providers'; const defaultOptions: Omit<SearchControlProps, 'provider'> = { position: 'topleft', style: 'button', showMarker: true, showPopup: false, popupFormat: ({ result }) => `${result.label}`, resultFormat: ({ result }) => `${result.label}`, marker: { icon: L && L.Icon ? new L.Icon.Default() : undefined, draggable: false, }, maxMarkers: 1, maxSuggestions: 5, retainZoomLevel: false, animateZoom: true, searchLabel: 'Enter address', clearSearchLabel: 'Clear search', notFoundMessage: '', messageHideDelay: 3000, zoomLevel: 18, classNames: { container: 'leaflet-bar leaflet-control leaflet-control-geosearch', button: 'leaflet-bar-part leaflet-bar-part-single', resetButton: 'reset', msgbox: 'leaflet-bar message', form: '', input: '', resultlist: '', item: '', notfound: 'leaflet-bar-notfound', }, autoComplete: true, autoCompleteDelay: 250, autoClose: false, keepResult: false, updateMap: true, resetButton: '×', }; const UNINITIALIZED_ERR = 'Leaflet must be loaded before instantiating the GeoSearch control'; interface SearchControlProps { /** the provider to use for searching */ provider: Provider; /** the leaflet position to render the element in */ position: ControlPosition; /** * the stye of the search element * @default bar **/ style: 'button' | 'bar'; marker: MarkerOptions; maxMarkers: number; showMarker: boolean; showPopup: boolean; popupFormat<T = any>(args: { query: Selection; result: SearchResult<T>; }): string; resultFormat<T = any>(args: { result: SearchResult<T> }): string; searchLabel: string; clearSearchLabel: string; notFoundMessage: string; messageHideDelay: number; animateZoom: boolean; zoomLevel: number; retainZoomLevel: boolean; classNames: { container: string; button: string; resetButton: string; msgbox: string; form: string; input: string; resultlist: string; item: string; notfound: string; }; autoComplete: boolean; autoCompleteDelay: number; maxSuggestions: number; autoClose: boolean; keepResult: boolean; updateMap: boolean; resetButton: string; } export type SearchControlOptions = Partial<SearchControlProps> & { provider: Provider; }; interface Selection { query: string; data?: SearchResult; } interface SearchControl { options: Omit<SearchControlProps, 'provider'> & { provider?: SearchControlProps['provider']; }; markers: FeatureGroup; searchElement: SearchElement; resultList: ResultList; classNames: SearchControlProps['classNames']; container: HTMLDivElement; input: HTMLInputElement; button: HTMLAnchorElement; resetButton: HTMLAnchorElement; map: Map; // [key: string]: any; initialize(options: SearchControlProps): void; onSubmit(result: Selection): void; open(): void; close(): void; onClick(event: Event): void; clearResults(event?: KeyboardEvent | null, force?: boolean): void; autoSearch(event: KeyboardEvent): void; selectResult(event: KeyboardEvent): void; showResult(result: SearchResult, query: Selection): void; addMarker(result: SearchResult, selection: Selection): void; centerMap(result: SearchResult): void; closeResults(): void; getZoom(): number; onAdd(map: Map): HTMLDivElement; onRemove(): SearchControl; } // @ts-ignore const Control: SearchControl = { options: { ...defaultOptions }, classNames: { ...defaultOptions.classNames }, initialize(options: SearchControlOptions) { if (!L) { throw new Error(UNINITIALIZED_ERR); } if (!options.provider) { throw new Error('Provider is missing from options'); } // merge given options with control defaults this.options = { ...defaultOptions, ...options }; this.classNames = { ...this.classNames, ...options.classNames }; this.markers = new L.FeatureGroup(); this.classNames.container += ` leaflet-geosearch-${this.options.style}`; this.searchElement = new SearchElement({ searchLabel: this.options.searchLabel, classNames: { container: this.classNames.container, form: this.classNames.form, input: this.classNames.input, }, handleSubmit: (result) => this.onSubmit(result), }); this.button = createElement<HTMLAnchorElement>( 'a', this.classNames.button, this.searchElement.container, { title: this.options.searchLabel, 'aria-label': this.options.searchLabel, href: '#', onClick: (e) => this.onClick(e), }, ); L.DomEvent.disableClickPropagation(this.button); this.resetButton = createElement<HTMLAnchorElement>( 'button', this.classNames.resetButton, this.searchElement.form, { text: this.options.resetButton, 'aria-label': this.options.clearSearchLabel, onClick: () => { if (this.searchElement.input.value === '') { this.close(); } else { this.clearResults(null, true); } }, }, ); L.DomEvent.disableClickPropagation(this.resetButton); if (this.options.autoComplete) { this.resultList = new ResultList({ handleClick: ({ result }): void => { this.searchElement.input.value = result.label; this.onSubmit({ query: result.label, data: result }); }, classNames: { resultlist: this.classNames.resultlist, item: this.classNames.item, notfound: this.classNames.notfound, }, notFoundMessage: this.options.notFoundMessage, }); this.searchElement.form.appendChild(this.resultList.container); this.searchElement.input.addEventListener( 'keyup', debounce( (e: KeyboardEvent) => this.autoSearch(e), this.options.autoCompleteDelay, ), true, ); this.searchElement.input.addEventListener( 'keydown', (e: KeyboardEvent) => this.selectResult(e), true, ); this.searchElement.input.addEventListener( 'keydown', (e: KeyboardEvent) => this.clearResults(e, true), true, ); } this.searchElement.form.addEventListener( 'click', (e) => { e.preventDefault(); }, false, ); }, onAdd(map: Map) { const { showMarker, style } = this.options; this.map = map; if (showMarker) { this.markers.addTo(map); } if (style === 'bar') { const root = map .getContainer() .querySelector('.leaflet-control-container'); this.container = createElement<HTMLDivElement>( 'div', 'leaflet-control-geosearch leaflet-geosearch-bar', ); this.container.appendChild(this.searchElement.form); root!.appendChild(this.container); } L.DomEvent.disableClickPropagation(this.searchElement.form); return this.searchElement.container; }, onRemove() { this.container?.remove(); return this; }, open() { const { container, input } = this.searchElement; addClassName(container, 'active'); input.focus(); }, close() { const { container } = this.searchElement; removeClassName(container, 'active'); this.clearResults(); }, onClick(event: Event) { event.preventDefault(); event.stopPropagation(); const { container } = this.searchElement; if (container.classList.contains('active')) { this.close(); } else { this.open(); } }, selectResult(event) { if ( [ENTER_KEY, ARROW_DOWN_KEY, ARROW_UP_KEY].indexOf(event.keyCode) === -1 ) { return; } event.preventDefault(); if (event.keyCode === ENTER_KEY) { const item = this.resultList.select(this.resultList.selected); this.onSubmit({ query: this.searchElement.input.value, data: item }); return; } const max = this.resultList.count() - 1; if (max < 0) { return; } const { selected } = this.resultList; const next = event.keyCode === ARROW_DOWN_KEY ? selected + 1 : selected - 1; const idx = next < 0 ? max : next > max ? 0 : next; const item = this.resultList.select(idx); this.searchElement.input.value = item.label; }, clearResults(event: KeyboardEvent | null, force = false) { if (event && event.keyCode !== ESCAPE_KEY) { return; } const { keepResult, autoComplete } = this.options; if (force || !keepResult) { this.searchElement.input.value = ''; this.markers.clearLayers(); } if (autoComplete) { this.resultList.clear(); } }, async autoSearch(event) { if (SPECIAL_KEYS.indexOf(event.keyCode) > -1) { return; } const query = (event.target as HTMLInputElement).value; const { provider } = this.options; if (query.length) { let results = await provider!.search({ query }); results = results.slice(0, this.options.maxSuggestions); this.resultList.render(results, this.options.resultFormat); } else { this.resultList.clear(); } }, async onSubmit(query) { this.resultList.clear(); const { provider } = this.options; const results = await provider!.search(query); if (results && results.length > 0) { this.showResult(results[0], query); } }, showResult(result, query) { const { autoClose, updateMap } = this.options; const markers = this.markers.getLayers(); if (markers.length >= this.options.maxMarkers) { this.markers.removeLayer(markers[0]); } const marker = this.addMarker(result, query); if (updateMap) { this.centerMap(result); } this.map.fireEvent('geosearch/showlocation', { location: result, marker, }); if (autoClose) { this.closeResults(); } }, closeResults() { const { container } = this.searchElement; if (container.classList.contains('active')) { removeClassName(container, 'active'); } this.clearResults(); }, addMarker(result, query) { const { marker: options, showPopup, popupFormat } = this.options; const marker = new L.Marker([result.y, result.x], options); let popupLabel = result.label; if (typeof popupFormat === 'function') { popupLabel = popupFormat({ query, result }); } marker.bindPopup(popupLabel); this.markers.addLayer(marker); if (showPopup) { marker.openPopup(); } if (options.draggable) { marker.on('dragend', (args: L.DragEndEvent) => { this.map.fireEvent('geosearch/marker/dragend', { location: marker.getLatLng(), event: args, }); }); } return marker; }, centerMap(result) { const { retainZoomLevel, animateZoom } = this.options; const resultBounds = result.bounds ? new L.LatLngBounds(result.bounds) : new L.LatLng(result.y, result.x).toBounds(10); const bounds = resultBounds.isValid() ? resultBounds : this.markers.getBounds(); if (!retainZoomLevel && resultBounds.isValid() && !result.bounds) { this.map.setView(bounds.getCenter(), this.getZoom(), { animate: animateZoom, }); } else if (!retainZoomLevel && resultBounds.isValid()) { this.map.fitBounds(bounds, { animate: animateZoom }); } else { this.map.setView(bounds.getCenter(), this.getZoom(), { animate: animateZoom, }); } }, getZoom(): number { const { retainZoomLevel, zoomLevel } = this.options; return retainZoomLevel ? this.map.getZoom() : zoomLevel; }, }; export default function SearchControl(...options: any[]) { if (!L) { throw new Error(UNINITIALIZED_ERR); } const LControl = L.Control.extend(Control); return new LControl(...options); }