@inelo/leaflet-control-geocoder
Version:
Extendable geocoder with builtin support for OpenStreetMap Nominatim, Bing, Google, Mapbox, MapQuest, What3Words, Photon, Pelias, HERE, Neutrino, Plus codes
492 lines (439 loc) • 15.7 kB
text/typescript
import * as L from 'leaflet';
import { Nominatim } from './geocoders/index';
import { IGeocoder, GeocodingResult } from './geocoders/api';
export interface GeocoderControlOptions extends L.ControlOptions {
/**
* Collapse control unless hovered/clicked
*/
collapsed: boolean;
/**
* How to expand a collapsed control: `touch` or `click` or `hover`
*/
expand: 'touch' | 'click' | 'hover';
/**
* Placeholder text for text input
*/
placeholder: string;
/**
* Message when no result found / geocoding error occurs
*/
errorMessage: string;
/**
* Accessibility label for the search icon used by screen readers
*/
iconLabel: string;
/**
* Object to perform the actual geocoding queries
*/
geocoder?: IGeocoder;
/**
* Immediately show the unique result without prompting for alternatives
*/
showUniqueResult: boolean;
/**
* Show icons for geocoding results (if available); supported by Nominatim
*/
showResultIcons: boolean;
/**
* Minimum number characters before suggest functionality is used (if available from geocoder)
*/
suggestMinLength: number;
/**
* Number of milliseconds after typing stopped before suggest functionality is used (if available from geocoder)
*/
suggestTimeout: number;
/**
* Initial query string for text input
*/
query: string;
/**
* Minimum number of characters in search text before performing a query
*/
queryMinLength: number;
/**
* Whether to mark a geocoding result on the map by default
*/
defaultMarkGeocode: boolean;
}
/**
* Event is fired when selecting a geocode result.
* By default, the control will center the map on it and place a marker at its location.
* To remove the control's default handler for marking a result, set {@link GeocoderControlOptions.defaultMarkGeocode} to `false`.
*/
export type MarkGeocodeEvent = { geocode: GeocodingResult };
export type MarkGeocodeEventHandlerFn = (event: MarkGeocodeEvent) => void;
/**
* Event is fired before invoking {@link IGeocoder.geocode} (or {@link IGeocoder.suggest}).
* The event data contains the query string as `input`.
*/
export type StartGeocodeEvent = { input: string };
export type StartGeocodeEventHandlerFn = (event: StartGeocodeEvent) => void;
/**
* Event is fired before after receiving results from {@link IGeocoder.geocode} (or {@link IGeocoder.suggest}).
* The event data contains the query string as `input` and the geocoding `results`.
*/
export type FinishGeocodeEvent = { input: string; results: GeocodingResult[] };
export type FinishGeocodeEventHandlerFn = (event: FinishGeocodeEvent) => void;
declare module 'leaflet' {
interface Evented {
on(type: 'markgeocode', fn: MarkGeocodeEventHandlerFn, context?: any): this;
on(type: 'startgeocode', fn: StartGeocodeEventHandlerFn, context?: any): this;
on(type: 'startsuggest', fn: StartGeocodeEventHandlerFn, context?: any): this;
on(type: 'finishsuggest', fn: FinishGeocodeEventHandlerFn, context?: any): this;
on(type: 'finishgeocode', fn: FinishGeocodeEventHandlerFn, context?: any): this;
}
}
/**
* Leaflet mixins https://leafletjs.com/reference-1.7.1.html#class-includes
* for TypeScript https://www.typescriptlang.org/docs/handbook/mixins.html
* @internal
*/
class EventedControl {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
constructor(...args: any[]) {
// empty
}
}
/**
* @internal
*/
interface EventedControl extends L.Control, L.Evented {}
L.Util.extend(EventedControl.prototype, L.Control.prototype);
L.Util.extend(EventedControl.prototype, L.Evented.prototype);
/**
* This is the geocoder control. It works like any other [Leaflet control](https://leafletjs.com/reference.html#control), and is added to the map.
*/
export class GeocoderControl extends EventedControl {
options: GeocoderControlOptions = {
showUniqueResult: true,
showResultIcons: false,
collapsed: true,
expand: 'touch',
position: 'topright',
placeholder: 'Search...',
errorMessage: 'Nothing found.',
iconLabel: 'Initiate a new search',
query: '',
queryMinLength: 1,
suggestMinLength: 3,
suggestTimeout: 250,
defaultMarkGeocode: true
};
private _alts: HTMLUListElement;
private _container: HTMLDivElement;
private _errorElement: HTMLDivElement;
private _form: HTMLDivElement;
private _geocodeMarker: L.Marker;
private _input: HTMLInputElement;
private _lastGeocode: string;
private _map: L.Map;
private _preventBlurCollapse: boolean;
private _requestCount = 0;
private _results: any;
private _selection: any;
private _suggestTimeout: any;
/**
* Instantiates a geocoder control (to be invoked using `new`)
* @param options the options
*/
constructor(options?: Partial<GeocoderControlOptions>) {
super(options);
L.Util.setOptions(this, options);
if (!this.options.geocoder) {
this.options.geocoder = new Nominatim();
}
}
addThrobberClass() {
L.DomUtil.addClass(this._container, 'leaflet-control-geocoder-throbber');
}
removeThrobberClass() {
L.DomUtil.removeClass(this._container, 'leaflet-control-geocoder-throbber');
}
/**
* Returns the container DOM element for the control and add listeners on relevant map events.
* @param map the map instance
* @see https://leafletjs.com/reference.html#control-onadd
*/
onAdd(map: L.Map) {
const className = 'leaflet-control-geocoder';
const container = L.DomUtil.create('div', className + ' leaflet-bar') as HTMLDivElement;
const icon = L.DomUtil.create('button', className + '-icon', container) as HTMLButtonElement;
const form = (this._form = L.DomUtil.create(
'div',
className + '-form',
container
) as HTMLDivElement);
this._map = map;
this._container = container;
icon.innerHTML = ' ';
icon.type = 'button';
icon.setAttribute('aria-label', this.options.iconLabel);
const input = (this._input = L.DomUtil.create('input', '', form) as HTMLInputElement);
input.type = 'text';
input.value = this.options.query;
input.placeholder = this.options.placeholder;
L.DomEvent.disableClickPropagation(input);
this._errorElement = L.DomUtil.create(
'div',
className + '-form-no-error',
container
) as HTMLDivElement;
this._errorElement.innerHTML = this.options.errorMessage;
this._alts = L.DomUtil.create(
'ul',
className + '-alternatives leaflet-control-geocoder-alternatives-minimized',
container
) as HTMLUListElement;
L.DomEvent.disableClickPropagation(this._alts);
L.DomEvent.addListener(input, 'keydown', this._keydown, this);
if (this.options.geocoder.suggest) {
L.DomEvent.addListener(input, 'input', this._change, this);
}
L.DomEvent.addListener(input, 'blur', () => {
if (this.options.collapsed && !this._preventBlurCollapse) {
this._collapse();
}
this._preventBlurCollapse = false;
});
if (this.options.collapsed) {
if (this.options.expand === 'click') {
L.DomEvent.addListener(container, 'click', (e: Event) => {
if ((e as MouseEvent).button === 0 && (e as MouseEvent).detail !== 2) {
this._toggle();
}
});
} else if (this.options.expand === 'touch') {
L.DomEvent.addListener(
container,
L.Browser.touch ? 'touchstart mousedown' : 'mousedown',
(e: Event) => {
this._toggle();
e.preventDefault(); // mobile: clicking focuses the icon, so UI expands and immediately collapses
e.stopPropagation();
},
this
);
} else {
L.DomEvent.addListener(container, 'mouseover', this._expand, this);
L.DomEvent.addListener(container, 'mouseout', this._collapse, this);
this._map.on('movestart', this._collapse, this);
}
} else {
this._expand();
if (L.Browser.touch) {
L.DomEvent.addListener(container, 'touchstart', () => this._geocode());
} else {
L.DomEvent.addListener(container, 'click', () => this._geocode());
}
}
if (this.options.defaultMarkGeocode) {
this.on('markgeocode', this.markGeocode, this);
}
this.on('startgeocode', this.addThrobberClass, this);
this.on('finishgeocode', this.removeThrobberClass, this);
this.on('startsuggest', this.addThrobberClass, this);
this.on('finishsuggest', this.removeThrobberClass, this);
L.DomEvent.disableClickPropagation(container);
return container;
}
/**
* Sets the query string on the text input
* @param string the query string
*/
setQuery(string: string): this {
this._input.value = string;
return this;
}
private _geocodeResult(results: GeocodingResult[], suggest: boolean) {
if (!suggest && this.options.showUniqueResult && results.length === 1) {
this._geocodeResultSelected(results[0]);
} else if (results.length > 0) {
this._alts.innerHTML = '';
this._results = results;
L.DomUtil.removeClass(this._alts, 'leaflet-control-geocoder-alternatives-minimized');
L.DomUtil.addClass(this._container, 'leaflet-control-geocoder-options-open');
for (let i = 0; i < results.length; i++) {
this._alts.appendChild(this._createAlt(results[i], i));
}
} else {
L.DomUtil.addClass(this._container, 'leaflet-control-geocoder-options-error');
L.DomUtil.addClass(this._errorElement, 'leaflet-control-geocoder-error');
}
}
/**
* Marks a geocoding result on the map
* @param result the geocoding result
*/
markGeocode(event: MarkGeocodeEvent) {
const result = event.geocode;
this._map.fitBounds(result.bbox);
if (this._geocodeMarker) {
this._map.removeLayer(this._geocodeMarker);
}
this._geocodeMarker = new L.Marker(result.center)
.bindPopup(result.html || result.name)
.addTo(this._map)
.openPopup();
return this;
}
private _geocode(suggest?: boolean) {
const value = this._input.value;
if (!suggest && value.length < this.options.queryMinLength) {
return;
}
const requestCount = ++this._requestCount;
const cb = (results: GeocodingResult[]) => {
if (requestCount === this._requestCount) {
const event: FinishGeocodeEvent = { input: value, results };
this.fire(suggest ? 'finishsuggest' : 'finishgeocode', event);
this._geocodeResult(results, suggest);
}
};
this._lastGeocode = value;
if (!suggest) {
this._clearResults();
}
const event: StartGeocodeEvent = { input: value };
this.fire(suggest ? 'startsuggest' : 'startgeocode', event);
if (suggest) {
this.options.geocoder.suggest(value, cb);
} else {
this.options.geocoder.geocode(value, cb);
}
}
private _geocodeResultSelected(geocode: GeocodingResult) {
const event: MarkGeocodeEvent = { geocode };
this.fire('markgeocode', event);
}
private _toggle() {
if (L.DomUtil.hasClass(this._container, 'leaflet-control-geocoder-expanded')) {
this._collapse();
} else {
this._expand();
}
}
private _expand() {
L.DomUtil.addClass(this._container, 'leaflet-control-geocoder-expanded');
this._input.select();
this.fire('expand');
}
private _collapse() {
L.DomUtil.removeClass(this._container, 'leaflet-control-geocoder-expanded');
L.DomUtil.addClass(this._alts, 'leaflet-control-geocoder-alternatives-minimized');
L.DomUtil.removeClass(this._errorElement, 'leaflet-control-geocoder-error');
L.DomUtil.removeClass(this._container, 'leaflet-control-geocoder-options-open');
L.DomUtil.removeClass(this._container, 'leaflet-control-geocoder-options-error');
this._input.blur(); // mobile: keyboard shouldn't stay expanded
this.fire('collapse');
}
private _clearResults() {
L.DomUtil.addClass(this._alts, 'leaflet-control-geocoder-alternatives-minimized');
this._selection = null;
L.DomUtil.removeClass(this._errorElement, 'leaflet-control-geocoder-error');
L.DomUtil.removeClass(this._container, 'leaflet-control-geocoder-options-open');
L.DomUtil.removeClass(this._container, 'leaflet-control-geocoder-options-error');
}
private _createAlt(result: GeocodingResult, index: number) {
const li = L.DomUtil.create('li', ''),
a = L.DomUtil.create('a', '', li),
icon =
this.options.showResultIcons && result.icon
? (L.DomUtil.create('img', '', a) as HTMLImageElement)
: null,
text = result.html ? undefined : document.createTextNode(result.name),
mouseDownHandler = (e: Event) => {
// In some browsers, a click will fire on the map if the control is
// collapsed directly after mousedown. To work around this, we
// wait until the click is completed, and _then_ collapse the
// control. Messy, but this is the workaround I could come up with
// for #142.
this._preventBlurCollapse = true;
L.DomEvent.stop(e);
this._geocodeResultSelected(result);
L.DomEvent.on(li, 'click touchend', () => {
if (this.options.collapsed) {
this._collapse();
} else {
this._clearResults();
}
});
};
if (icon) {
icon.src = result.icon;
}
li.setAttribute('data-result-index', String(index));
if (result.html) {
a.innerHTML = a.innerHTML + result.html;
} else if (text) {
a.appendChild(text);
}
// Use mousedown and not click, since click will fire _after_ blur,
// causing the control to have collapsed and removed the items
// before the click can fire.
L.DomEvent.addListener(li, 'mousedown touchstart', mouseDownHandler, this);
return li;
}
private _keydown(e: KeyboardEvent) {
const select = (dir: number) => {
if (this._selection) {
L.DomUtil.removeClass(this._selection, 'leaflet-control-geocoder-selected');
this._selection = this._selection[dir > 0 ? 'nextSibling' : 'previousSibling'];
}
if (!this._selection) {
this._selection = this._alts[dir > 0 ? 'firstChild' : 'lastChild'];
}
if (this._selection) {
L.DomUtil.addClass(this._selection, 'leaflet-control-geocoder-selected');
}
};
switch (e.keyCode) {
// Escape
case 27:
if (this.options.collapsed) {
this._collapse();
} else {
this._clearResults();
}
break;
// Up
case 38:
select(-1);
break;
// Up
case 40:
select(1);
break;
// Enter
case 13:
if (this._selection) {
const index = parseInt(this._selection.getAttribute('data-result-index'), 10);
this._geocodeResultSelected(this._results[index]);
this._clearResults();
} else {
this._geocode();
}
break;
default:
return;
}
L.DomEvent.preventDefault(e);
}
private _change() {
const v = this._input.value;
if (v !== this._lastGeocode) {
clearTimeout(this._suggestTimeout);
if (v.length >= this.options.suggestMinLength) {
this._suggestTimeout = setTimeout(() => this._geocode(true), this.options.suggestTimeout);
} else {
this._clearResults();
}
}
}
}
/**
* [Class factory method](https://leafletjs.com/reference.html#class-class-factories) for {@link GeocoderControl}
* @param options the options
*/
export function geocoder(options?: Partial<GeocoderControlOptions>) {
return new GeocoderControl(options);
}