Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 177 additions & 112 deletions API.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

- Fix reverse geocoding errors caused by leading/trailing whitespace in coordinate input (e.g. `"48.774989, 9.155557 "`)
- Fix an error message not being shown when pasting an invalid value directly (e.g. via keyboard shortcut), instead of typing it character by character
- Fix the suggestion list not updating after cutting (Cmd/Ctrl+X) or undoing (Cmd/Ctrl+Z) a change to the search input

## 5.1.2

Expand Down
10 changes: 9 additions & 1 deletion debug/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ insertCss(
);

var MapboxGeocoder = require('../');
var spatialFormats = require('../lib/spatial-formats');

var mapDiv = document.body.appendChild(document.createElement('div'));
mapDiv.style.position = 'absolute';
Expand Down Expand Up @@ -76,7 +77,14 @@ var geocoder = new MapboxGeocoder({
useBrowserFocus: true,
enableGeolocation: true,
localGeocoder: function(query) {
return coordinatesGeocoder(query);
var spatialFeatures = [
spatialFormats.parseSlashSeparatedZoomLatLng(query),
spatialFormats.parseCommaSeparatedLngLatZoom(query),
spatialFormats.parseTile(query),
spatialFormats.parseQuadkey(query)
].filter(Boolean);

return spatialFeatures.concat(coordinatesGeocoder(query) || []);
},
externalGeocoder: function(query, features) {
// peak at the query and features before calling the external api
Expand Down
10 changes: 9 additions & 1 deletion lib/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,12 +306,20 @@ MapboxEventManager.prototype = {
* @private
* @param {Object} selected the geojson feature selected by the user
* @param {Object} geocoder a Mapbox-GL-Geocoder instance
* @returns {Number} the index of the selected result
* @returns {Number | undefined} the index of the selected result
*/
getSelectedIndex: function(selected, geocoder){
if (!geocoder._typeahead) return;
var results = geocoder._typeahead.data;
var selectedID = selected.id;

// Features without an `id` are synthetic (e.g. parsed from an extended
// spatial format) and shouldn't be counted in selection stats, so report
// no index for them
if (selectedID === undefined) {
return;
}

var resultIDs = results.map(function (feature) {
return feature.id;
});
Expand Down
33 changes: 27 additions & 6 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ function MapboxGeocoder(options) {
trimCoordinatesPunctuation: false
},
getItemValue: function(item) {
if (item._searchQuery) {
return item._searchQuery;
}

return item.place_name
},
render: function(item) {
Expand Down Expand Up @@ -590,7 +594,9 @@ MapboxGeocoder.prototype = {

_onKeyDown: function(e) {
var ESC_KEY_CODE = 27,
TAB_KEY_CODE = 9;
TAB_KEY_CODE = 9,
X_KEY_CODE = 88,
Z_KEY_CODE = 90;

if (e.keyCode === ESC_KEY_CODE && this.options.clearAndBlurOnEsc) {
this._clear(e);
Expand All @@ -614,7 +620,9 @@ MapboxGeocoder.prototype = {
this._hideGeolocateButton();

// TAB, ESC, LEFT, RIGHT, ENTER, UP, DOWN
if ((e.metaKey || [TAB_KEY_CODE, ESC_KEY_CODE, 37, 39, 13, 38, 40].indexOf(e.keyCode) !== -1))
// metaKey/ctrlKey combos are ignored except Cmd/Ctrl+X (cut) and Cmd/Ctrl+Z (undo),
// which mutate the input value
if (((e.metaKey || e.ctrlKey) && e.keyCode !== X_KEY_CODE && e.keyCode !== Z_KEY_CODE) || [TAB_KEY_CODE, ESC_KEY_CODE, 37, 39, 13, 38, 40].indexOf(e.keyCode) !== -1)
return;

if (target.value.length >= this.options.minLength) {
Expand Down Expand Up @@ -720,6 +728,15 @@ MapboxGeocoder.prototype = {
zoom: this.options.zoom
}
flyOptions = extend({}, defaultFlyOptions, this.options.flyTo);

// A zoom carried on the feature itself is the most specific intent
// available - for features parsed from the search input it is a zoom the
// user typed - so it wins over options.flyTo.zoom. Other flyTo options
// (speed, curve, essential, ...) are left untouched.
if (typeof selected._zoom === 'number') {
flyOptions.zoom = selected._zoom;
}

// ensure that center is not overriden by custom options
if (selected.center) {
flyOptions.center = selected.center;
Expand Down Expand Up @@ -968,18 +985,22 @@ MapboxGeocoder.prototype = {
this._hideLoadingIcon();
this._hideAttribution();

// in the event of an error in the Mapbox Geocoding API still display results from the localGeocoder
if ((localGeocoderRes.length && this.options.localGeocoder) || (externalGeocoderRes.length && this.options.externalGeocoder) ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't that break the externalGeocoder?

// in the event of an error in the Mapbox Geocoding API still display
// results from the localGeocoder, since those are computed locally and
// stay valid when the request fails
var fallbackFeatures = localGeocoderRes;

if (fallbackFeatures.length) {
this._showClearButton();
this._hideGeolocateButton();
this._typeahead.update(localGeocoderRes);
this._typeahead.update(fallbackFeatures);
} else {
this._hideClearButton();
this._typeahead.selected = null;
this._renderError();
}

this._eventEmitter.emit('results', { features: localGeocoderRes });
this._eventEmitter.emit('results', { features: fallbackFeatures });
this._eventEmitter.emit('error', { error: err });
}.bind(this)
);
Expand Down
192 changes: 192 additions & 0 deletions lib/spatial-formats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
'use strict';

var utils = require('./utils');

// An integer or a decimal with at least one fractional digit, optionally signed.
const NUMBER = '-?\\d+(?:\\.\\d+)?';

const COMMA_SEPARATED_LNG_LAT_ZOOM_RGX = new RegExp('^(' + NUMBER + '),(' + NUMBER + '),(' + NUMBER + ')$');
const SLASH_SEPARATED_ZOOM_LAT_LNG_RGX = new RegExp('^(' + NUMBER + ')\\/(' + NUMBER + ')\\/(' + NUMBER + ')$');
const TILE_RGX = /^(\d+)\/(\d+)\/(\d+)$/;

function isValidLng(value) {
return value >= -180 && value <= 180;
}

function isValidLat(value) {
return value >= -90 && value <= 90;
}

function isValidZoom(zoom) {
return zoom >= 0 && zoom <= utils.MAX_SPATIAL_ZOOM;
}

/**
* Builds the synthetic feature for a recognized spatial format. `center` and
* `geometry.coordinates` are both set so the feature needs no special handling in
* `_fly`, `_handleMarker` or `options.getItemValue`.
* @private
* @param {Object} params
* @param {number} params.lng
* @param {number} params.lat
* @param {number} params.zoom
* @param {string} params.placeName
* @param {string} params.searchQuery
* @param {Object} params.properties
* @returns {Object} a GeoJSON Feature
*/
function createFeature(params) {
return {
type: 'Feature',
place_name: params.placeName,
place_type: ['coordinate'],
center: [params.lng, params.lat],
geometry: {
type: 'Point',
coordinates: [params.lng, params.lat]
},
properties: params.properties,
_zoom: params.zoom,
_searchQuery: params.searchQuery
};
}


/**
* Recognizes input of the form `lng,lat,zoom` with no spaces, e.g. `6.925882,51.110352,11.31`.
* @param {String} searchInput search input
* @returns {Object|null} a GeoJSON Feature, or `null` if the input doesn't match
*/
function parseCommaSeparatedLngLatZoom(searchInput) {
const match = searchInput.match(COMMA_SEPARATED_LNG_LAT_ZOOM_RGX);
if (!match) {
return null;
}

const lngStr = match[1];
const latStr = match[2];
const zoomStr = match[3];

const lng = Number(lngStr);
const lat = Number(latStr);
const zoom = Number(zoomStr);

if (!isValidLng(lng) || !isValidLat(lat) || !isValidZoom(zoom)) {
return null;
}

return createFeature({
lng: lng,
lat: lat,
zoom: zoom,
placeName: 'Point,lng=' + lngStr + ' lat=' + latStr + ' zoom=' + zoomStr,
searchQuery: searchInput,
properties: {
spatialFormat: 'commaSeparatedLngLatZoom'
}
});
}

/**
* Recognizes input of the form `zoom/lat/lng` with no spaces, e.g. `11.31/51.110352/6.925882`.
* @param {String} searchInput search input
* @returns {Object|null} a GeoJSON Feature, or `null` if the input doesn't match
*/
function parseSlashSeparatedZoomLatLng(searchInput) {
const match = searchInput.match(SLASH_SEPARATED_ZOOM_LAT_LNG_RGX);
if (!match) {
return null;
}

const zoomStr = match[1];
const latStr = match[2];
const lngStr = match[3];

const zoom = Number(zoomStr);
const lat = Number(latStr);
const lng = Number(lngStr);

if (!isValidZoom(zoom) || !isValidLat(lat) || !isValidLng(lng)) {
return null;
}

return createFeature({
lng: lng,
lat: lat,
zoom: zoom,
placeName: 'Point,lng=' + lngStr + ' lat=' + latStr + ' zoom=' + zoomStr,
searchQuery: searchInput,
properties: {
spatialFormat: 'slashSeparatedZoomLatLng'
}
});
}

/**
* Recognizes XYZ tile coordinates of the form `z/x/y`, e.g. `14/8507/5477`, and resolves them
* to the center of the tile.
* @param {String} searchInput search input
* @returns {Object|null} a GeoJSON Feature, or `null` if the input doesn't match
*/
function parseTile(searchInput) {
const match = searchInput.match(TILE_RGX);
if (!match) {
return null;
}

const z = Number(match[1]);
const x = Number(match[2]);
const y = Number(match[3]);

if (!utils.isValidTile(z, x, y)) {
return null;
}

const center = utils.tileToLngLat(z, x, y);

return createFeature({
lng: center[0],
lat: center[1],
zoom: z,
placeName: 'Tile,x=' + x + ' y=' + y + ' z=' + z,
searchQuery: searchInput,
properties: {
spatialFormat: 'tile',
tile: { z: z, x: x, y: y }
}
});
}

/**
* Recognizes a quadkey, e.g. `12020332200123`, and resolves it to the center of the quadkey.
* @param {String} searchInput search input
* @returns {Object|null} a GeoJSON Feature, or `null` if the input doesn't match
*/
function parseQuadkey(searchInput) {
if (!utils.isValidQuadkey(searchInput)) {
return null;
}

const qk = searchInput;
const parsedTile = utils.quadkeyToTile(qk);
const center = utils.tileToLngLat(parsedTile.z, parsedTile.x, parsedTile.y);

return createFeature({
lng: center[0],
lat: center[1],
zoom: parsedTile.z,
placeName: 'Quadkey,' + qk,
searchQuery: searchInput,
properties: {
spatialFormat: 'quadkey',
quadkey: qk
}
});
}

module.exports = {
parseCommaSeparatedLngLatZoom: parseCommaSeparatedLngLatZoom,
parseSlashSeparatedZoomLatLng: parseSlashSeparatedZoomLatLng,
parseTile: parseTile,
parseQuadkey: parseQuadkey,
};
Loading
Loading