From 2917f4b68275818d7307e69296c760722214fa3a Mon Sep 17 00:00:00 2001 From: pahuta Date: Tue, 4 Aug 2026 20:22:56 +0200 Subject: [PATCH 1/2] RTFDB-4842: Improve user input handling in "mapbox-gl-geocoder" - Add optional `parseExtendedSpatialFormats` option with four independently toggled formats, all disabled by default: `commaSeparatedLngLatZoom` (`lng,lat,zoom`), `slashSeparatedZoomLatLng` (`zoom/lat/lng`), `tile` (`z/x/y`) and `quadkey` - Add `lib/spatial-formats.js`, which turns a matching search input into a synthetic GeoJSON feature and prepends it to the suggestion list on both the successful and the failed request path - Add `isValidTile`, `isValidQuadkey`, `tileToLngLat` and `quadkeyToTile` helpers to `lib/utils.js` - Use the parsed zoom when flying to a selected feature that has no `bbox` - Fix `getSelectedIndex` in `lib/events.js` reporting index 0 for every result feature without an `id` https://mapbox.atlassian.net/browse/RTFDB-4842 --- API.md | 6 + CHANGELOG.md | 2 + debug/index.js | 6 + lib/events.js | 10 +- lib/index.js | 62 ++++++++-- lib/spatial-formats.js | 219 +++++++++++++++++++++++++++++++++++ lib/utils.js | 99 ++++++++++++++++ package.json | 2 +- test/events.test.js | 21 ++++ test/spatial-formats.test.js | 171 +++++++++++++++++++++++++++ test/test.geocoder.js | 202 ++++++++++++++++++++++++++++++++ test/test.ui.js | 66 +++++++++++ test/utils.test.js | 60 +++++++++- 13 files changed, 914 insertions(+), 12 deletions(-) create mode 100644 lib/spatial-formats.js create mode 100644 test/spatial-formats.test.js diff --git a/API.md b/API.md index f30f3582..d5425995 100644 --- a/API.md +++ b/API.md @@ -133,6 +133,12 @@ A geocoder component using the [Mapbox Geocoding API][74] * `options.inputTransforms` **[Object][75]?** Options controlling how the search input is transformed before being processed. * `options.inputTransforms.trimCoordinatesPunctuation` **[Boolean][80]** If `true`, leading/trailing punctuation characters (currently only `;`) are trimmed from the search input. (optional, default `false`) + * `options.parseExtendedSpatialFormats` **[Object][75]?** Options controlling which extended spatial input formats are recognized. This must be an object of the sub-options below. When the search input matches an enabled format, a feature for the parsed location is added as the first suggestion, counting against `options.limit` in the suggestion list alongside any geocoding results. All formats are disabled by default. Longitude must be within -180..180, latitude within -90..90, and zoom within 0..24. + + * `options.parseExtendedSpatialFormats.commaSeparatedLngLatZoom` **[Boolean][80]** If `true`, recognize input of the form `lng,lat,zoom` with no spaces, e.g. `6.925882,51.110352,11.31`. (optional, default `false`) + * `options.parseExtendedSpatialFormats.slashSeparatedZoomLatLng` **[Boolean][80]** If `true`, recognize input of the form `zoom/lat/lng` with no spaces, e.g. `11.31/51.110352/6.925882`. (optional, default `false`) + * `options.parseExtendedSpatialFormats.tile` **[Boolean][80]** If `true`, recognize XYZ tile coordinates of the form `z/x/y`, e.g. `14/8507/5477`, and resolve them to the center of the tile. Note that an all-integer `z/a/b` input within latitude/longitude range, e.g. `12/45/30`, is valid under both this format and `slashSeparatedZoomLatLng`; when both are enabled such input produces two suggestions, the tile one first. (optional, default `false`) + * `options.parseExtendedSpatialFormats.quadkey` **[Boolean][80]** If `true`, recognize a quadkey, e.g. `12020332200123`, and resolve it to the center of the quadkey. Be aware that a quadkey is any string of the digits `0`-`3`, so enabling this makes purely numeric searches ambiguous — searching for the postal code `20331`, for example, also produces a quadkey suggestion. Only enable it where numeric-only queries are not expected. (optional, default `false`) ### Examples diff --git a/CHANGELOG.md b/CHANGELOG.md index 57722196..29503863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,14 @@ ### Features / Improvements 🚀 - Add `inputTransforms.trimCoordinatesPunctuation` option (defaults to `false`). When enabled, leading/trailing punctuation (e.g. `;`) is trimmed from search input that looks like coordinates (e.g. `"48.774989, 9.155557;"`) +- Add `parseExtendedSpatialFormats` option for recognizing extended spatial input formats: `commaSeparatedLngLatZoom` (`6.925882,51.110352,11.31`), `slashSeparatedZoomLatLng` (`11.31/51.110352/6.925882`), `tile` (`14/8507/5477`) and `quadkey` (`12020332200123`). Each defaults to `false`. When enabled and the search input matches, a feature for the parsed location is added as the first suggestion alongside the geocoding results, and selecting it moves the map to those coordinates at the parsed zoom. For `tile` and `quadkey` the coordinates are the center of the tile. Note that `12/45/30`-style input is valid as both a tile and a `zoom/lat/lng` triple, so with both formats enabled it yields two suggestions. - Reject search input longer than 256 characters (matching the Geocoding v5 API's own limit) with a dedicated "search is too long" error message, instead of sending it to the API ### Bug fixes 🐛 - 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 diff --git a/debug/index.js b/debug/index.js index 28ab1cd2..c206bc02 100644 --- a/debug/index.js +++ b/debug/index.js @@ -75,6 +75,12 @@ var geocoder = new MapboxGeocoder({ trackProximity: true, useBrowserFocus: true, enableGeolocation: true, + parseExtendedSpatialFormats: { + commaSeparatedLngLatZoom: true, + slashSeparatedZoomLatLng: true, + tile: true, + quadkey: true + }, localGeocoder: function(query) { return coordinatesGeocoder(query); }, diff --git a/lib/events.js b/lib/events.js index 4519ea98..aecc7359 100644 --- a/lib/events.js +++ b/lib/events.js @@ -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; }); diff --git a/lib/index.js b/lib/index.js index c7d65c3c..7293a06b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -12,6 +12,7 @@ var localization = require('./localization'); var subtag = require('subtag'); var Geolocation = require('./geolocation'); var utils = require('./utils'); +var spatialFormats = require('./spatial-formats'); const GEOCODE_REQUEST_TYPE = { @@ -27,6 +28,10 @@ const PUNCTUATION_CHARS = new Set([';']); // short enough that regex-based input transforms can't be a ReDoS vector. const MAX_INPUT_LENGTH = 256; +// Options whose value is an object of sub-options. A caller passing a partial +// object keeps the defaults for the keys they left out, instead of wiping them. +const NESTED_OPTION_KEYS = ['inputTransforms', 'parseExtendedSpatialFormats']; + /** * Don't include this as part of the options object when creating a new MapboxGeocoder instance. */ @@ -86,6 +91,11 @@ function getFooterNode() { * @param {('address'|'street'|'place'|'country')} [options.addressAccuracy="street"] The accuracy for the geolocation feature with which we define the address line to fill. The browser API returns the user's position with accuracy, and sometimes we can get the neighbor's address. To prevent receiving an incorrect address, you can reduce the accuracy of the definition. * @param {Object} [options.inputTransforms] Options controlling how the search input is transformed before being processed. * @param {Boolean} [options.inputTransforms.trimCoordinatesPunctuation=false] If `true`, leading/trailing punctuation characters (currently only `;`) are trimmed from the search input. + * @param {Object} [options.parseExtendedSpatialFormats] Options controlling which extended spatial input formats are recognized. This must be an object of the sub-options below. When the search input matches an enabled format, a feature for the parsed location is added as the first suggestion, counting against `options.limit` in the suggestion list alongside any geocoding results. All formats are disabled by default. Longitude must be within -180..180, latitude within -90..90, and zoom within 0..24. + * @param {Boolean} [options.parseExtendedSpatialFormats.commaSeparatedLngLatZoom=false] If `true`, recognize input of the form `lng,lat,zoom` with no spaces, e.g. `6.925882,51.110352,11.31`. + * @param {Boolean} [options.parseExtendedSpatialFormats.slashSeparatedZoomLatLng=false] If `true`, recognize input of the form `zoom/lat/lng` with no spaces, e.g. `11.31/51.110352/6.925882`. + * @param {Boolean} [options.parseExtendedSpatialFormats.tile=false] If `true`, recognize XYZ tile coordinates of the form `z/x/y`, e.g. `14/8507/5477`, and resolve them to the center of the tile. Note that an all-integer `z/a/b` input within latitude/longitude range, e.g. `12/45/30`, is valid under both this format and `slashSeparatedZoomLatLng`; when both are enabled such input produces two suggestions, the tile one first. + * @param {Boolean} [options.parseExtendedSpatialFormats.quadkey=false] If `true`, recognize a quadkey, e.g. `12020332200123`, and resolve it to the center of the quadkey. Be aware that a quadkey is any string of the digits `0`-`3`, so enabling this makes purely numeric searches ambiguous — searching for the postal code `20331`, for example, also produces a quadkey suggestion. Only enable it where numeric-only queries are not expected. * @example * var geocoder = new MapboxGeocoder({ accessToken: mapboxgl.accessToken }); * map.addControl(geocoder); @@ -115,7 +125,17 @@ function MapboxGeocoder(options) { inputTransforms: { trimCoordinatesPunctuation: false }, + parseExtendedSpatialFormats: { + commaSeparatedLngLatZoom: false, + slashSeparatedZoomLatLng: false, + tile: false, + quadkey: false + }, getItemValue: function(item) { + if (item._source === spatialFormats.SOURCE) { + return item._searchQuery; + } + return item.place_name }, render: function(item) { @@ -128,9 +148,11 @@ function MapboxGeocoder(options) { this.options = extend({}, defaultOptions, options); - if (options && options.inputTransforms) { - this.options.inputTransforms = extend({}, defaultOptions.inputTransforms, options.inputTransforms); - } + NESTED_OPTION_KEYS.forEach(function(key) { + if (options && options[key]) { + this.options[key] = extend({}, defaultOptions[key], options[key]); + } + }, this); this.inputString = ''; this.fresh = true; @@ -590,7 +612,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); @@ -614,7 +638,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) { @@ -720,6 +746,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; @@ -865,6 +900,8 @@ MapboxGeocoder.prototype = { this._showLoadingIcon(); this._eventEmitter.emit('loading', { query: searchInput }); + const spatialFormatRes = spatialFormats.parse(searchInput, this.options.parseExtendedSpatialFormats); + const requestType = this._requestType(this.options, searchInput); const config = this._setupConfig(requestType, searchInput); @@ -949,6 +986,9 @@ MapboxGeocoder.prototype = { res.features = res.features.filter(this.options.filter); } + // put features found by spatialFormat parsing at the start of the list + res.features = spatialFormatRes.concat(res.features); + if (res.features.length) { this._showClearButton(); this._hideGeolocateButton(); @@ -968,18 +1008,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) ) { + // in the event of an error in the Mapbox Geocoding API still display + // results from the localGeocoder and from the extended spatial formats - + // the latter are parsed locally, so they stay valid when the request fails + var fallbackFeatures = spatialFormatRes.concat(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) ); diff --git a/lib/spatial-formats.js b/lib/spatial-formats.js new file mode 100644 index 00000000..685f5164 --- /dev/null +++ b/lib/spatial-formats.js @@ -0,0 +1,219 @@ +'use strict'; + +var utils = require('./utils'); + +// Marks a feature synthesized from an extended spatial format, so it can be told +// apart from Geocoding API results (which carry `_source: 'mapbox'`). +const SOURCE = 'extended-spatial-format'; + +// 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, + _source: SOURCE, + _searchQuery: params.searchQuery + }; +} + + +// Parsers for each known format. +// `tile` deliberately precedes `slashSeparatedZoomLatLng` +// because both accept `z/a/b`: an all-integer input that is also within +// latitude/longitude range (e.g. `12/45/30`) matches both, and the tile +// interpretation is listed first. +const FORMATS = [ + { + name: 'commaSeparatedLngLatZoom', + parse: function(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' + } + }); + } + }, + { + name: 'tile', + parse: function(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 } + } + }); + } + }, + { + name: 'slashSeparatedZoomLatLng', + parse: function(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' + } + }); + } + }, + { + name: 'quadkey', + parse: function(searchInput) { + if (!utils.isValidQuadkey(searchInput)) { + return null; + } + + const tile = utils.quadkeyToTile(searchInput); + const center = utils.tileToLngLat(tile.z, tile.x, tile.y); + + return createFeature({ + lng: center[0], + lat: center[1], + zoom: tile.z, + placeName: 'Quadkey,' + searchInput, + searchQuery: searchInput, + properties: { + spatialFormat: 'quadkey', + quadkey: searchInput + } + }); + } + } +]; + +/** + * Parses the search input against the enabled extended spatial formats. + * @private + * @param {String} searchInput search input + * @param {Object} formatOptions + * @param {Boolean} [formatOptions.commaSeparatedLngLatZoom] If `true`, recognize input of the form `lng,lat,zoom` + * @param {Boolean} [formatOptions.slashSeparatedZoomLatLng] If `true`, recognize input of the form `zoom/lat/lng` + * @param {Boolean} [formatOptions.tile] If `true`, recognize XYZ tile coordinates of the form `z/x/y` + * @param {Boolean} [formatOptions.quadkey] If `true`, recognize a quadkey + * @returns {Array} one feature per enabled format that matched, in the order the formats are declared; `[]` when nothing matched + */ +function parse(searchInput, formatOptions) { + if (!formatOptions) { + return []; + } + + return FORMATS.reduce(function(features, format) { + if (!formatOptions[format.name]) { + return features; + } + + const feature = format.parse(searchInput); + if (feature) { + features.push(feature); + } + + return features; + }, []); +} + +module.exports = { + parse: parse, + SOURCE: SOURCE +}; diff --git a/lib/utils.js b/lib/utils.js index ca1b0845..98543dd5 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -71,9 +71,108 @@ const REVERSE_GEOCODE_COORD_RGX = /^(-?\d+(\.\d{0,256})?)\s*,\s*(-?\d+(\.\d{0,25 // coordinates somewhere in it, regardless of surrounding punctuation/whitespace. const RELAXED_COORD_RGX = /(-?\d+(\.\d{0,256})?)\s*,\s*(-?\d+(\.\d{0,256})?)/; +// Maximum zoom level accepted by the extended spatial input formats. It also +// caps quadkey length, since a quadkey holds one base-4 digit per zoom level. +const MAX_SPATIAL_ZOOM = 24; + +/** + * Checks whether a tile coordinate triple is valid: `z` must be a supported zoom + * level and `x`/`y` must fall inside the 2^z x 2^z tile grid of that level. + * @private + * @param {Number} z zoom level + * @param {Number} x tile column + * @param {Number} y tile row + * @returns {Boolean} + */ +function isValidTile(z, x, y) { + if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) { + return false; + } + + if (z < 0 || z > MAX_SPATIAL_ZOOM) { + return false; + } + + const maxIndex = Math.pow(2, z) - 1; + + return x >= 0 && x <= maxIndex && y >= 0 && y <= maxIndex; +} + +/** + * Checks whether a string is a valid quadkey. A quadkey holds one base-4 digit + * per zoom level, so only the characters 0-3 are allowed and its length is the + * zoom level it describes. + * @private + * @param {String} quadkey + * @returns {Boolean} + */ +function isValidQuadkey(quadkey) { + if (typeof quadkey !== 'string') { + return false; + } + + if (quadkey.length === 0 || quadkey.length > MAX_SPATIAL_ZOOM) { + return false; + } + + return /^[0-3]+$/.test(quadkey); +} + +/** + * Converts a tile coordinate to the longitude/latitude of the tile's center. + * @private + * @param {Number} z zoom level + * @param {Number} x tile column + * @param {Number} y tile row + * @returns {Array} `[lng, lat]` + */ +function tileToLngLat(z, x, y) { + const tilesPerAxis = Math.pow(2, z); + const lng = ((x + 0.5) / tilesPerAxis) * 360 - 180; + const latRadians = Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 0.5)) / tilesPerAxis))); + + return [lng, (latRadians * 180) / Math.PI]; +} + +/** + * Converts a quadkey to its tile coordinate. + * @private + * @param {String} quadkey + * @returns {Object} `{ z, x, y }` + */ +function quadkeyToTile(quadkey) { + const z = quadkey.length; + var x = 0; + var y = 0; + + for (var i = z; i > 0; i--) { + const mask = 1 << (i - 1); + + switch (quadkey.charAt(z - i)) { + case '1': + x |= mask; + break; + case '2': + y |= mask; + break; + case '3': + x |= mask; + y |= mask; + break; + } + } + + return { z: z, x: x, y: y }; +} + module.exports = { transformFeatureToGeolocationText: transformFeatureToGeolocationText, getAddressInfo: getAddressInfo, REVERSE_GEOCODE_COORD_RGX: REVERSE_GEOCODE_COORD_RGX, RELAXED_COORD_RGX: RELAXED_COORD_RGX, + MAX_SPATIAL_ZOOM: MAX_SPATIAL_ZOOM, + isValidTile: isValidTile, + isValidQuadkey: isValidQuadkey, + tileToLngLat: tileToLngLat, + quadkeyToTile: quadkeyToTile, } \ No newline at end of file diff --git a/package.json b/package.json index e4b07205..43266ea0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "start": "budo debug/index.js --dir debug --live -- -t brfs ", "prepublish": "NODE_ENV=production && mkdir -p dist && browserify lib/index.js --transform [ babelify --global ] --standalone MapboxGeocoder | uglifyjs -c -m > dist/mapbox-gl-geocoder.min.js && cp lib/mapbox-gl-geocoder.css dist/", - "test": "browserify -t envify test/index.js test/events.test.js test/utils.test.js | smokestack -b firefox | tap-status | tap-color", + "test": "browserify -t envify test/index.js test/spatial-formats.test.js test/events.test.js test/utils.test.js | smokestack -b firefox | tap-status | tap-color", "docs": "documentation build lib/index.js --format=md > API.md", "pretest": "npm run lint", "lint": "eslint lib test", diff --git a/test/events.test.js b/test/events.test.js index f0c30abf..776de05c 100644 --- a/test/events.test.js +++ b/test/events.test.js @@ -215,6 +215,27 @@ test('search selects event', function(assert){ assert.end(); }); +test('search selects event with id-less (synthetic) features is not logged', function(assert){ + var eventsManager = new MapboxEventsManager({ + accessToken: 'abc123' + }) + var pushMethod = sinon.spy(eventsManager, "push"); + var geocoder = new MapboxGeocoder({accessToken: 'abc123'}); + // Neither feature has an `id`, e.g. two features synthesized from extended + // spatial formats (parseExtendedSpatialFormats: { tile: true, slashSeparatedZoomLatLng: true }). + var firstFeature = {place_name: 'Tile,x=45 y=30 z=12', place_type: ['coordinate'], properties: {}, _source: 'extended-spatial-format'}; + var secondFeature = {place_name: 'Point,lng=30 lat=45 zoom=12', place_type: ['coordinate'], properties: {}, _source: 'extended-spatial-format'}; + geocoder._typeahead = { + data: [firstFeature, secondFeature] + }; + geocoder.inputString = '12/45/30'; + assert.equals(eventsManager.getSelectedIndex(secondFeature, geocoder), undefined, 'id-less features report no index'); + eventsManager.select(secondFeature, geocoder); + assert.notOk(pushMethod.called, 'synthetic id-less features are not logged to the events service'); + pushMethod.restore(); + assert.end(); +}); + test('generate session id', function(assert){ var eventsManager = new MapboxEventsManager({ accessToken: 'abc123' diff --git a/test/spatial-formats.test.js b/test/spatial-formats.test.js new file mode 100644 index 00000000..c7970c8a --- /dev/null +++ b/test/spatial-formats.test.js @@ -0,0 +1,171 @@ +'use strict'; + +var test = require('tape'); +var spatialFormats = require('../lib/spatial-formats'); + +var ALL_ENABLED = { + commaSeparatedLngLatZoom: true, + slashSeparatedZoomLatLng: true, + tile: true, + quadkey: true +}; + +function placeNames(features) { + return features.map(function (feature) { + return feature.place_name; + }); +} + +function assertLngLat(t, actual, expected, msg) { + t.ok( + Math.abs(actual[0] - expected[0]) < 1e-9 && Math.abs(actual[1] - expected[1]) < 1e-9, + msg + ' (got [' + actual[0] + ', ' + actual[1] + '])' + ); +} + +test('spatial-formats: commaSeparatedLngLatZoom', function (t) { + var formatOptions = { commaSeparatedLngLatZoom: true }; + var features = spatialFormats.parse('6.925882,51.110352,11.31', formatOptions); + + t.equal(features.length, 1, 'one feature'); + t.equal(features[0].place_name, 'Point,lng=6.925882 lat=51.110352 zoom=11.31', 'place_name echoes the input'); + t.deepEqual(features[0].center, [6.925882, 51.110352], 'center is [lng, lat]'); + t.equal(features[0]._zoom, 11.31, 'a fractional zoom is preserved'); + t.equal(features[0].properties.spatialFormat, 'commaSeparatedLngLatZoom', 'the format is recorded'); + + t.equal(spatialFormats.parse('-6,-51,0', formatOptions).length, 1, 'negative values and zoom 0 are accepted'); + t.equal(spatialFormats.parse('180,90,24', formatOptions).length, 1, 'the range boundaries are inclusive'); + t.deepEqual(spatialFormats.parse('181,51,11', formatOptions), [], 'lng above 180'); + t.deepEqual(spatialFormats.parse('6,91,11', formatOptions), [], 'lat above 90'); + t.deepEqual(spatialFormats.parse('6,51,25', formatOptions), [], 'zoom above 24'); + t.deepEqual(spatialFormats.parse('6,51,-1', formatOptions), [], 'negative zoom'); + t.deepEqual(spatialFormats.parse('6, 51, 11', formatOptions), [], 'spaces around commas are not accepted'); + t.deepEqual(spatialFormats.parse('6,51', formatOptions), [], 'two numbers are not enough'); + t.deepEqual(spatialFormats.parse('6,51,11,2', formatOptions), [], 'four numbers are too many'); + t.end(); +}); + +test('spatial-formats: slashSeparatedZoomLatLng', function (t) { + var formatOptions = { slashSeparatedZoomLatLng: true }; + var features = spatialFormats.parse('11.31/51.110352/6.925882', formatOptions); + + t.equal(features.length, 1, 'one feature'); + t.equal(features[0].place_name, 'Point,lng=6.925882 lat=51.110352 zoom=11.31', 'place_name is reordered to lng, lat, zoom'); + t.deepEqual(features[0].center, [6.925882, 51.110352], 'center is [lng, lat]'); + t.equal(features[0]._zoom, 11.31, 'zoom comes from the first component'); + t.equal(features[0].properties.spatialFormat, 'slashSeparatedZoomLatLng', 'the format is recorded'); + + t.deepEqual(spatialFormats.parse('25/51/6', formatOptions), [], 'zoom above 24'); + t.deepEqual(spatialFormats.parse('11/91/6', formatOptions), [], 'lat above 90'); + t.deepEqual(spatialFormats.parse('11/51/181', formatOptions), [], 'lng above 180'); + t.deepEqual(spatialFormats.parse('14/8507/5477', formatOptions), [], 'tile coordinates are out of lat/lng range'); + t.deepEqual(spatialFormats.parse('11 / 51 / 6', formatOptions), [], 'spaces around slashes are not accepted'); + t.end(); +}); + +test('spatial-formats: tile', function (t) { + var formatOptions = { tile: true }; + var features = spatialFormats.parse('14/8507/5477', formatOptions); + + t.equal(features.length, 1, 'one feature'); + t.equal(features[0].place_name, 'Tile,x=8507 y=5477 z=14', 'place_name lists x, y, z'); + t.equal(features[0]._zoom, 14, 'zoom is the tile zoom'); + t.deepEqual(features[0].properties.tile, { z: 14, x: 8507, y: 5477 }, 'the tile components are exposed'); + t.equal(features[0].properties.spatialFormat, 'tile', 'the format is recorded'); + assertLngLat(t, features[0].center, [6.932373046875, 51.10352194240417], 'center is the tile center'); + + t.equal(spatialFormats.parse('0/0/0', formatOptions).length, 1, 'the z=0 world tile is valid'); + t.deepEqual(spatialFormats.parse('14/16384/5477', formatOptions), [], 'x is outside the z=14 grid'); + t.deepEqual(spatialFormats.parse('25/0/0', formatOptions), [], 'zoom above 24'); + t.deepEqual(spatialFormats.parse('14/8507.5/5477', formatOptions), [], 'components must be integers'); + t.deepEqual(spatialFormats.parse('14/-1/5477', formatOptions), [], 'negative components are rejected'); + t.equal(spatialFormats.parse('014/8507/5477', formatOptions).length, 1, 'leading zeros are accepted'); + t.equal( + spatialFormats.parse('014/8507/5477', formatOptions)[0].place_name, + 'Tile,x=8507 y=5477 z=14', + 'leading zeros are normalized away in place_name' + ); + t.end(); +}); + +test('spatial-formats: quadkey', function (t) { + var formatOptions = { quadkey: true }; + var features = spatialFormats.parse('12020332200123',formatOptions); + + t.equal(features.length, 1, 'one feature'); + t.equal(features[0].place_name, 'Quadkey,12020332200123', 'place_name echoes the quadkey'); + t.equal(features[0]._zoom, 14, 'zoom is the quadkey length'); + t.equal(features[0].properties.quadkey, '12020332200123', 'the quadkey is exposed'); + t.equal(features[0].properties.spatialFormat, 'quadkey', 'the format is recorded'); + assertLngLat(t, features[0].center, [8.558349609375, 49.33228198473772], 'center is the tile center'); + + t.equal(spatialFormats.parse('0123',formatOptions).length, 1, 'a leading zero is a valid quadkey'); + t.equal(spatialFormats.parse('0123',formatOptions)[0]._zoom, 4, 'the leading zero counts towards the zoom'); + t.deepEqual(spatialFormats.parse('12345',formatOptions), [], 'digits above 3 are not a quadkey'); + t.deepEqual(spatialFormats.parse('12 0203',formatOptions), [], 'whitespace is not accepted'); + t.deepEqual(spatialFormats.parse('abc',formatOptions), [], 'letters are not accepted'); + t.end(); +}); + +test('spatial-formats: feature shape', function (t) { + var feature = spatialFormats.parse('6.925882,51.110352,11.31', { commaSeparatedLngLatZoom: true })[0]; + t.equal(feature.type, 'Feature', 'is a GeoJSON Feature'); + t.deepEqual(feature.place_type, ['coordinate'], 'place_type is coordinate'); + t.equal(feature.geometry.type, 'Point', 'has a point geometry'); + t.deepEqual(feature.geometry.coordinates, feature.center, 'geometry coordinates match center'); + t.equal(feature._source, 'extended-spatial-format', 'is tagged with the extended spatial format source'); + t.equal(feature.bbox, undefined, 'has no bbox, so _fly uses center and _zoom'); + t.end(); +}); + +test('spatial-formats: ambiguous z/a/b input yields both interpretations', function (t) { + t.deepEqual(placeNames(spatialFormats.parse('12/45/30', ALL_ENABLED)), [ + 'Tile,x=45 y=30 z=12', + 'Point,lng=30 lat=45 zoom=12' + ], 'the tile interpretation comes first, then lat/lng'); + + t.deepEqual( + placeNames(spatialFormats.parse('12/45/30', { tile: true })), + ['Tile,x=45 y=30 z=12'], + 'only an enabled format contributes' + ); + t.deepEqual( + placeNames(spatialFormats.parse('12/45.5/30', ALL_ENABLED)), + ['Point,lng=30 lat=45.5 zoom=12'], + 'a decimal component rules out the tile interpretation' + ); + t.deepEqual( + placeNames(spatialFormats.parse('14/8507/5477', ALL_ENABLED)), + ['Tile,x=8507 y=5477 z=14'], + 'an out-of-range latitude rules out the lat/lng interpretation' + ); + t.equal(spatialFormats.parse('6.925882,51.110352,11.31', ALL_ENABLED).length, 1, 'a comma-separated triple only ever matches one format'); + t.equal(spatialFormats.parse('12020332200123', ALL_ENABLED).length, 1, 'a quadkey only ever matches one format'); + t.end(); +}); + +test('spatial-formats: every format is opt-in', function (t) { + var inputs = [ + '6.925882,51.110352,11.31', + '11.31/51.110352/6.925882', + '14/8507/5477', + '12020332200123' + ]; + + var formatOptions = { + commaSeparatedLngLatZoom: false, + slashSeparatedZoomLatLng: false, + tile: false, + quadkey: false + } + + inputs.forEach(function (input) { + t.deepEqual(spatialFormats.parse(input, formatOptions), [], 'no feature for "' + input + '" when all formats are disabled'); + t.deepEqual(spatialFormats.parse(input, undefined), [], 'no feature for "' + input + '" without format options'); + }); + + t.deepEqual(spatialFormats.parse('Berlin', ALL_ENABLED), [], 'ordinary text never matches'); + t.deepEqual(spatialFormats.parse('', ALL_ENABLED), [], 'empty input never matches'); + t.deepEqual(spatialFormats.parse('48.774989, 9.155557', ALL_ENABLED), [], 'plain reverse-geocode coordinates never match'); + t.end(); +}); diff --git a/test/test.geocoder.js b/test/test.geocoder.js index f2414aa4..a28f4add 100644 --- a/test/test.geocoder.js +++ b/test/test.geocoder.js @@ -8,6 +8,7 @@ var mapboxEvents = require('./../lib/events'); var sinon = require('sinon'); var localization = require('./../lib/localization'); var exceptions = require('./../lib/exceptions'); +var spatialFormats = require('./../lib/spatial-formats'); mapboxgl.accessToken = process.env.MapboxAccessToken; @@ -921,6 +922,20 @@ test('geocoder', function(tt) { t.end() }); + tt.test('options.getItemValue for spatial format results', function(t){ + setup({}); + + var fixture = { + id: 'abc123', + place_name: 'Point,lng=6.925882 lat=51.110352 zoom=11', + _source: spatialFormats.SOURCE, + _searchQuery: '6.925882,51.110352,11' + } + + t.equals(geocoder._typeahead.getItemValue(fixture), '6.925882,51.110352,11', 'the getItemValue uses the original search query for spatial format results'); + t.end() + }); + tt.test('options.flyTo [false]', function(t){ t.plan(1) setup({ @@ -1702,5 +1717,192 @@ test('geocoder', function(tt) { t.end(); }); + tt.test('options.parseExtendedSpatialFormats - every format is disabled by default', function(t) { + setup(); + t.deepEqual(geocoder.options.parseExtendedSpatialFormats, { + commaSeparatedLngLatZoom: false, + slashSeparatedZoomLatLng: false, + tile: false, + quadkey: false + }, 'every format defaults to false'); + t.end(); + }); + + tt.test('options.parseExtendedSpatialFormats - a partial option keeps the remaining defaults', function(t) { + setup({ parseExtendedSpatialFormats: { tile: true } }); + t.deepEqual(geocoder.options.parseExtendedSpatialFormats, { + commaSeparatedLngLatZoom: false, + slashSeparatedZoomLatLng: false, + tile: true, + quadkey: false + }, 'only the key that was passed is overridden'); + t.end(); + }); + + tt.test('options.inputTransforms - a partial option still keeps the remaining defaults', function(t) { + var passedInputTransforms = { trimCoordinatesPunctuation: true }; + setup({ inputTransforms: passedInputTransforms }); + t.deepEqual(geocoder.options.inputTransforms, { + trimCoordinatesPunctuation: true + }, 'the existing nested option still merges over its defaults'); + t.notEqual(geocoder.options.inputTransforms, passedInputTransforms, 'the nested option was merged into a fresh object rather than adopted by reference, so a caller mutating their own object afterwards cannot reach into the instance'); + t.end(); + }); + + // Stubs the Geocoding API with a fixed set of features, so the tests below do + // not depend on live API responses. + function stubForwardGeocode(features) { + return sinon.stub(geocoder.geocoderService, 'forwardGeocode').returns({ + send: function() { + return Promise.resolve({ + statusCode: '200', + body: { + type: 'FeatureCollection', + features: features + }, + request: {}, + headers: {} + }); + } + }); + } + + var apiFeatureFixture = { + id: 'place.1', + type: 'Feature', + place_type: ['place'], + text: 'Somewhere', + place_name: 'Somewhere, Germany', + center: [7, 51], + geometry: { + type: 'Point', + coordinates: [7, 51] + }, + properties: {} + }; + + tt.test('options.parseExtendedSpatialFormats - the parsed feature comes first in the results', function(t) { + t.plan(4); + setup({ parseExtendedSpatialFormats: { tile: true } }); + stubForwardGeocode([apiFeatureFixture]); + + geocoder.query('14/8507/5477'); + geocoder.on( + 'results', + once(function(e) { + t.equals(e.features.length, 2, 'the parsed feature and the API result are both present'); + t.equals(e.features[0].place_name, 'Tile,x=8507 y=5477 z=14', 'the parsed feature is first'); + t.equals(e.features[1].place_name, 'Somewhere, Germany', 'the API result follows it'); + t.equals(e.features[0]._source, 'extended-spatial-format', 'the parsed feature keeps its own _source instead of being relabelled "mapbox"'); + }) + ); + }); + + tt.test('options.parseExtendedSpatialFormats - no "no results" message when the API returns nothing', function(t) { + t.plan(4); + setup({ parseExtendedSpatialFormats: { quadkey: true } }); + stubForwardGeocode([]); + var noResultsSpy = sinon.spy(geocoder, '_renderNoResults'); + + geocoder.query('12020332200123'); + geocoder.on( + 'results', + once(function(e) { + t.equals(e.features.length, 1, 'only the parsed feature is present'); + t.equals(e.features[0].place_name, 'Quadkey,12020332200123', 'the parsed feature is shown'); + t.ok(noResultsSpy.notCalled, 'the "No results found" message is not rendered'); + // _geocode emits 'results' before it calls _typeahead.update(), so the + // suggestion list is only populated on the next tick. + setTimeout(function() { + t.equals(geocoder._typeahead.data.length, 1, 'the suggestion list holds the parsed feature'); + }); + }) + ); + }); + + tt.test('options.parseExtendedSpatialFormats - options.filter does not drop the parsed feature', function(t) { + t.plan(2); + setup({ + parseExtendedSpatialFormats: { tile: true }, + filter: function() { return false; } + }); + stubForwardGeocode([apiFeatureFixture]); + + geocoder.query('14/8507/5477'); + geocoder.on( + 'results', + once(function(e) { + t.equals(e.features.length, 1, 'the API result was filtered out'); + t.equals(e.features[0].place_name, 'Tile,x=8507 y=5477 z=14', 'the parsed feature survived the filter'); + }) + ); + }); + + tt.test('options.parseExtendedSpatialFormats - the parsed feature survives an API error', function(t) { + t.plan(4); + setup({ parseExtendedSpatialFormats: { tile: true } }); + sinon.stub(geocoder.geocoderService, 'forwardGeocode').returns({ + send: function() { + return Promise.reject(new Error('network is down')); + } + }); + var renderErrorSpy = sinon.spy(geocoder, '_renderError'); + + geocoder.on( + 'results', + once(function(e) { + t.equals(e.features.length, 1, 'the parsed feature is still delivered'); + t.equals(e.features[0].place_name, 'Tile,x=8507 y=5477 z=14', 'the parsed feature is shown'); + t.ok(renderErrorSpy.notCalled, 'the error message does not replace the result'); + }) + ); + geocoder.on( + 'error', + once(function() { + t.pass('the error event is still emitted'); + }) + ); + + // _geocode resolves to the underlying request promise, which is rejected + // here; catching it keeps the rejection from surfacing as an unhandled one. + geocoder._geocode('14/8507/5477').catch(function() {}); + }); + + tt.test('options.parseExtendedSpatialFormats - localGeocoderOnly still delivers the parsed feature', function(t) { + t.plan(2); + // The parser is purely local and makes no request, and both options are + // separate explicit opt-ins, so localGeocoderOnly must not suppress it. + setup({ + localGeocoderOnly: true, + localGeocoder: function() { return []; }, + parseExtendedSpatialFormats: { tile: true } + }); + + geocoder.query('14/8507/5477'); + geocoder.on( + 'results', + once(function(e) { + t.equals(e.features.length, 1, 'the parsed feature is delivered even though localGeocoderOnly is set'); + t.equals(e.features[0].place_name, 'Tile,x=8507 y=5477 z=14', 'the parsed feature is shown'); + }) + ); + }); + + tt.test('options.parseExtendedSpatialFormats - the ambiguous z/a/b input yields two suggestions through _geocode', function(t) { + t.plan(3); + setup({ parseExtendedSpatialFormats: { tile: true, slashSeparatedZoomLatLng: true } }); + stubForwardGeocode([]); + + geocoder.query('12/45/30'); + geocoder.on( + 'results', + once(function(e) { + t.equals(e.features.length, 2, 'both interpretations are present'); + t.equals(e.features[0].place_name, 'Tile,x=45 y=30 z=12', 'the tile interpretation comes first'); + t.equals(e.features[1].place_name, 'Point,lng=30 lat=45 zoom=12', 'the lat/lng interpretation comes second'); + }) + ); + }); + tt.end(); }); diff --git a/test/test.ui.js b/test/test.ui.js index 115a1f7b..a9d32426 100644 --- a/test/test.ui.js +++ b/test/test.ui.js @@ -168,6 +168,72 @@ test('Geocoder#inputControl', function(tt) { t.end(); }); + tt.test('_onKeyDown triggers _geocode on Cmd+X (cut)', function(t){ + t.plan(1); + setup({}); + + var geocodeSpy = sinon.spy(geocoder, '_geocode'); + geocoder._onKeyDown({ target: { value: '6.945423,51.102197' }, keyCode: 88, metaKey: true }); + t.equal(geocodeSpy.called, true, '_geocode is called for Cmd+X since it mutates the input value'); + + t.end(); + }); + + tt.test('_onKeyDown triggers _geocode on Ctrl+X (cut)', function(t){ + t.plan(1); + setup({}); + + var geocodeSpy = sinon.spy(geocoder, '_geocode'); + geocoder._onKeyDown({ target: { value: '6.945423,51.102197' }, keyCode: 88, ctrlKey: true }); + t.equal(geocodeSpy.called, true, '_geocode is called for Ctrl+X since it mutates the input value'); + + t.end(); + }); + + tt.test('_onKeyDown does not trigger _geocode on Cmd+A', function(t){ + t.plan(1); + setup({}); + + var geocodeSpy = sinon.spy(geocoder, '_geocode'); + geocoder._onKeyDown({ target: { value: '6.945423,51.102197' }, keyCode: 65, metaKey: true }); + t.equal(geocodeSpy.called, false, '_geocode is not called for other Cmd combos'); + + t.end(); + }); + + tt.test('_onKeyDown does not trigger _geocode on Ctrl+A', function(t){ + t.plan(1); + setup({}); + + var geocodeSpy = sinon.spy(geocoder, '_geocode'); + geocoder._onKeyDown({ target: { value: '6.945423,51.102197' }, keyCode: 65, ctrlKey: true }); + t.equal(geocodeSpy.called, false, '_geocode is not called for other Ctrl combos'); + + t.end(); + }); + + tt.test('_onKeyDown triggers _geocode on Cmd+Z (undo)', function(t){ + t.plan(1); + setup({}); + + var geocodeSpy = sinon.spy(geocoder, '_geocode'); + geocoder._onKeyDown({ target: { value: '6.945423,51.102197,16.98' }, keyCode: 90, metaKey: true }); + t.equal(geocodeSpy.called, true, '_geocode is called for Cmd+Z since it mutates the input value'); + + t.end(); + }); + + tt.test('_onKeyDown triggers _geocode on Ctrl+Z (undo)', function(t){ + t.plan(1); + setup({}); + + var geocodeSpy = sinon.spy(geocoder, '_geocode'); + geocoder._onKeyDown({ target: { value: '6.945423,51.102197,16.98' }, keyCode: 90, ctrlKey: true }); + t.equal(geocodeSpy.called, true, '_geocode is called for Ctrl+Z since it mutates the input value'); + + t.end(); + }); + tt.test('options.clearAndBlurOnEsc=true clears and blurs on escape', function(t) { t.plan(4); setup({ diff --git a/test/utils.test.js b/test/utils.test.js index e573ecc5..0912ad05 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -15,4 +15,62 @@ test('REVERSE_GEOCODE_COORD_RGX', function (t) { t.ok(utils.REVERSE_GEOCODE_COORD_RGX.test('12.55 , 34.87'), 'Reverse: "12.55 , 34.87" (space before and after comma)'); t.notOk(utils.REVERSE_GEOCODE_COORD_RGX.test('12.55 34.87'), 'Forward: "12.55 34.87" (no comma, only whitespace-separated)'); t.notOk(utils.REVERSE_GEOCODE_COORD_RGX.test('123 Main'), 'Forward: "123 Main"'); -}) \ No newline at end of file + t.end(); +}); + +// Math.sinh/Math.atan precision is implementation-defined, so compare coordinates +// with a tolerance instead of asserting exact float equality. +function assertLngLat(t, actual, expected, msg) { + t.ok( + Math.abs(actual[0] - expected[0]) < 1e-9 && Math.abs(actual[1] - expected[1]) < 1e-9, + msg + ' (got [' + actual[0] + ', ' + actual[1] + '])' + ); +} + +test('isValidTile', function (t) { + t.ok(utils.isValidTile(0, 0, 0), 'z=0: 0/0/0 is the only valid tile'); + t.notOk(utils.isValidTile(0, 1, 0), 'z=0: x=1 is outside the 1x1 grid'); + t.ok(utils.isValidTile(1, 1, 1), 'z=1: 1/1 is the last tile of the 2x2 grid'); + t.notOk(utils.isValidTile(1, 2, 1), 'z=1: x=2 is outside the 2x2 grid'); + t.notOk(utils.isValidTile(1, 1, 2), 'z=1: y=2 is outside the 2x2 grid'); + t.ok(utils.isValidTile(14, 8507, 5477), 'z=14: 8507/5477 is inside the grid'); + t.notOk(utils.isValidTile(14, 16384, 5477), 'z=14: x=2^14 is outside the grid'); + t.ok(utils.isValidTile(24, Math.pow(2, 24) - 1, Math.pow(2, 24) - 1), 'z=24: last tile of the grid'); + t.notOk(utils.isValidTile(25, 0, 0), 'z=25 is above the supported maximum'); + t.notOk(utils.isValidTile(-1, 0, 0), 'z=-1 is below zero'); + t.notOk(utils.isValidTile(14, -1, 0), 'x=-1 is below zero'); + t.notOk(utils.isValidTile(14, 0, -1), 'y=-1 is below zero'); + t.notOk(utils.isValidTile(14.5, 10, 10), 'z must be an integer'); + t.notOk(utils.isValidTile(14, 10.5, 10), 'x must be an integer'); + t.notOk(utils.isValidTile(14, 10, 10.5), 'y must be an integer'); + t.end(); +}); + +test('isValidQuadkey', function (t) { + t.ok(utils.isValidQuadkey('12020332200123'), 'valid 14-character quadkey'); + t.ok(utils.isValidQuadkey('0'), 'single-digit quadkey'); + t.ok(utils.isValidQuadkey('0123'), 'a leading zero is significant, not invalid'); + t.ok(utils.isValidQuadkey('000000000000000000000000'), '24 characters is the supported maximum'); + t.notOk(utils.isValidQuadkey('0000000000000000000000000'), '25 characters is above the supported maximum'); + t.notOk(utils.isValidQuadkey(''), 'empty string is not a quadkey'); + t.notOk(utils.isValidQuadkey('1204'), 'digit 4 is outside the base-4 alphabet'); + t.notOk(utils.isValidQuadkey('120a'), 'non-digit characters are rejected'); + t.notOk(utils.isValidQuadkey('12 03'), 'whitespace is rejected'); + t.notOk(utils.isValidQuadkey(1203), 'a number is not a quadkey'); + t.end(); +}); + +test('tileToLngLat', function (t) { + t.deepEqual(utils.tileToLngLat(0, 0, 0), [0, 0], 'z=0 covers the world, its center is null island'); + assertLngLat(t, utils.tileToLngLat(14, 8507, 5477), [6.932373046875, 51.10352194240417], 'z=14 tile center'); + assertLngLat(t, utils.tileToLngLat(1, 0, 0), [-90, 66.51326044311186], 'z=1 north-west tile center'); + t.end(); +}); + +test('quadkeyToTile', function (t) { + t.deepEqual(utils.quadkeyToTile('12020332200123'), { z: 14, x: 8581, y: 5603 }, '14-character quadkey'); + t.deepEqual(utils.quadkeyToTile('213'), { z: 3, x: 3, y: 5 }, '3-character quadkey'); + t.deepEqual(utils.quadkeyToTile('0'), { z: 1, x: 0, y: 0 }, 'single-digit quadkey'); + t.deepEqual(utils.quadkeyToTile('3'), { z: 1, x: 1, y: 1 }, 'single-digit quadkey, south-east quadrant'); + t.end(); +}); From 8ab0a3313636dc09b7a56acd6f96644bf0a5f0a5 Mon Sep 17 00:00:00 2001 From: pahuta Date: Mon, 10 Aug 2026 16:28:38 +0200 Subject: [PATCH 2/2] RTFDB-4842: Improve user input handling in "mapbox-gl-geocoder" - remove a `parseExtendedSpatialFormats` contractor parameter in favor of using a `localGeocoder` https://mapbox.atlassian.net/browse/RTFDB-4842 --- API.md | 295 +++++++++++++++++++++-------------- CHANGELOG.md | 1 - debug/index.js | 16 +- lib/index.js | 37 +---- lib/spatial-formats.js | 273 +++++++++++++++----------------- test/events.test.js | 6 +- test/spatial-formats.test.js | 203 ++++++++++-------------- test/test.geocoder.js | 179 --------------------- 8 files changed, 398 insertions(+), 612 deletions(-) diff --git a/API.md b/API.md index d5425995..01d00c65 100644 --- a/API.md +++ b/API.md @@ -75,6 +75,14 @@ * [Parameters][71] * [getAddressInfo][72] * [Parameters][73] +* [commaSeparatedLngLatZoom][74] + * [Parameters][75] +* [slashSeparatedZoomLatLng][76] + * [Parameters][77] +* [tile][78] + * [Parameters][79] +* [quadkey][80] + * [Parameters][81] ## getFooterNode @@ -82,63 +90,57 @@ Don't include this as part of the options object when creating a new MapboxGeoco ## MapboxGeocoder -A geocoder component using the [Mapbox Geocoding API][74] +A geocoder component using the [Mapbox Geocoding API][82] ### Parameters -* `options` **[Object][75]** - - * `options.accessToken` **[String][76]** Required. - * `options.origin` **[String][76]** Use to set a custom API origin. (optional, default `https://api.mapbox.com`) - * `options.mapboxgl` **[Object][75]?** A [mapbox-gl][77] instance to use when creating [Markers][78]. Required if `options.marker` is `true`. - * `options.zoom` **[Number][79]** On geocoded result what zoom level should the map animate to when a `bbox` isn't found in the response. If a `bbox` is found the map will fit to the `bbox`. (optional, default `16`) - * `options.flyTo` **([Boolean][80] | [Object][75])** If `false`, animating the map to a selected result is disabled. If `true`, animating the map will use the default animation parameters. If an object, it will be passed as `options` to the map [`flyTo`][81] or [`fitBounds`][82] method providing control over the animation of the transition. (optional, default `true`) - * `options.placeholder` **[String][76]** Override the default placeholder attribute value. (optional, default `Search`) - * `options.proximity` **([Object][75] | `"ip"`)?** a geographical point given as an object with `latitude` and `longitude` properties, or the string 'ip' to use a user's IP address location. Search results closer to this point will be given higher priority. - * `options.trackProximity` **[Boolean][80]** If `true`, the geocoder proximity will dynamically update based on the current map view or user's IP location, depending on zoom level. (optional, default `true`) - * `options.collapsed` **[Boolean][80]** If `true`, the geocoder control will collapse until hovered or in focus. (optional, default `false`) - * `options.clearAndBlurOnEsc` **[Boolean][80]** If `true`, the geocoder control will clear it's contents and blur when user presses the escape key. (optional, default `false`) - * `options.clearOnBlur` **[Boolean][80]** If `true`, the geocoder control will clear its value when the input blurs. (optional, default `false`) - * `options.bbox` **[Array][83]?** a bounding box argument: this is +* `options` **[Object][83]** + + * `options.accessToken` **[String][84]** Required. + * `options.origin` **[String][84]** Use to set a custom API origin. (optional, default `https://api.mapbox.com`) + * `options.mapboxgl` **[Object][83]?** A [mapbox-gl][85] instance to use when creating [Markers][86]. Required if `options.marker` is `true`. + * `options.zoom` **[Number][87]** On geocoded result what zoom level should the map animate to when a `bbox` isn't found in the response. If a `bbox` is found the map will fit to the `bbox`. (optional, default `16`) + * `options.flyTo` **([Boolean][88] | [Object][83])** If `false`, animating the map to a selected result is disabled. If `true`, animating the map will use the default animation parameters. If an object, it will be passed as `options` to the map [`flyTo`][89] or [`fitBounds`][90] method providing control over the animation of the transition. (optional, default `true`) + * `options.placeholder` **[String][84]** Override the default placeholder attribute value. (optional, default `Search`) + * `options.proximity` **([Object][83] | `"ip"`)?** a geographical point given as an object with `latitude` and `longitude` properties, or the string 'ip' to use a user's IP address location. Search results closer to this point will be given higher priority. + * `options.trackProximity` **[Boolean][88]** If `true`, the geocoder proximity will dynamically update based on the current map view or user's IP location, depending on zoom level. (optional, default `true`) + * `options.collapsed` **[Boolean][88]** If `true`, the geocoder control will collapse until hovered or in focus. (optional, default `false`) + * `options.clearAndBlurOnEsc` **[Boolean][88]** If `true`, the geocoder control will clear it's contents and blur when user presses the escape key. (optional, default `false`) + * `options.clearOnBlur` **[Boolean][88]** If `true`, the geocoder control will clear its value when the input blurs. (optional, default `false`) + * `options.bbox` **[Array][91]?** a bounding box argument: this is a bounding box given as an array in the format `[minX, minY, maxX, maxY]`. Search results will be limited to the bounding box. - * `options.countries` **[string][76]?** a comma separated list of country codes to + * `options.countries` **[string][84]?** a comma separated list of country codes to limit results to specified country or countries. - * `options.types` **[string][76]?** a comma seperated list of types that filter - results to match those specified. See [https://docs.mapbox.com/api/search/#data-types][84] + * `options.types` **[string][84]?** a comma seperated list of types that filter + results to match those specified. See [https://docs.mapbox.com/api/search/#data-types][92] for available types. If reverseGeocode is enabled and no type is specified, the type defaults to POIs. Otherwise, if you configure more than one type, the first type will be used. - * `options.minLength` **[Number][79]** Minimum number of characters to enter before results are shown. (optional, default `2`) - * `options.limit` **[Number][79]** Maximum number of results to show. (optional, default `5`) - * `options.language` **[string][76]?** Specify the language to use for response text and query result weighting. Options are IETF language tags comprised of a mandatory ISO 639-1 language code and optionally one or more IETF subtags for country or script. More than one value can also be specified, separated by commas. Defaults to the browser's language settings. - * `options.filter` **[Function][85]?** A function which accepts a Feature in the [extended GeoJSON][86] format to filter out results from the Geocoding API response before they are included in the suggestions list. Return `true` to keep the item, `false` otherwise. - * `options.localGeocoder` **[Function][85]?** A function accepting the query string which performs local geocoding to supplement results from the Mapbox Geocoding API. Expected to return an Array of GeoJSON Features in the [extended GeoJSON][86] format. - * `options.externalGeocoder` **[Function][85]?** A function accepting the query string and current features list which performs geocoding to supplement results from the Mapbox Geocoding API. Expected to return a Promise which resolves to an Array of GeoJSON Features in the [extended GeoJSON][86] format. + * `options.minLength` **[Number][87]** Minimum number of characters to enter before results are shown. (optional, default `2`) + * `options.limit` **[Number][87]** Maximum number of results to show. (optional, default `5`) + * `options.language` **[string][84]?** Specify the language to use for response text and query result weighting. Options are IETF language tags comprised of a mandatory ISO 639-1 language code and optionally one or more IETF subtags for country or script. More than one value can also be specified, separated by commas. Defaults to the browser's language settings. + * `options.filter` **[Function][93]?** A function which accepts a Feature in the [extended GeoJSON][94] format to filter out results from the Geocoding API response before they are included in the suggestions list. Return `true` to keep the item, `false` otherwise. + * `options.localGeocoder` **[Function][93]?** A function accepting the query string which performs local geocoding to supplement results from the Mapbox Geocoding API. Expected to return an Array of GeoJSON Features in the [extended GeoJSON][94] format. + * `options.externalGeocoder` **[Function][93]?** A function accepting the query string and current features list which performs geocoding to supplement results from the Mapbox Geocoding API. Expected to return a Promise which resolves to an Array of GeoJSON Features in the [extended GeoJSON][94] format. * `options.reverseMode` **(distance | score)** Set the factors that are used to sort nearby results. (optional, default `distance`) - * `options.reverseGeocode` **[boolean][80]** If `true`, enable reverse geocoding mode. In reverse geocoding, search input is expected to be coordinates in the form `lat, lon`, with suggestions being the reverse geocodes. (optional, default `false`) - * `options.flipCoordinates` **[boolean][80]** If `true`, search input coordinates for reverse geocoding is expected to be in the form `lon, lat` instead of the default `lat, lon`. (optional, default `false`) - * `options.enableEventLogging` **[Boolean][80]** Allow Mapbox to collect anonymous usage statistics from the plugin. (optional, default `true`) - * `options.marker` **([Boolean][80] | [Object][75])** If `true`, a [Marker][78] will be added to the map at the location of the user-selected result using a default set of Marker options. If the value is an object, the marker will be constructed using these options. If `false`, no marker will be added to the map. Requires that `options.mapboxgl` also be set. (optional, default `true`) - * `options.render` **[Function][85]?** A function that specifies how the results should be rendered in the dropdown menu. This function should accepts a single [extended GeoJSON][86] object as input and return a string. Any HTML in the returned string will be rendered. - * `options.getItemValue` **[Function][85]?** A function that specifies how the selected result should be rendered in the search bar. This function should accept a single [extended GeoJSON][86] object as input and return a string. HTML tags in the output string will not be rendered. Defaults to `(item) => item.place_name`. - * `options.mode` **[String][76]** A string specifying the geocoding [endpoint][87] to query. Options are `mapbox.places` and `mapbox.places-permanent`. The `mapbox.places-permanent` mode requires an enterprise license for permanent geocodes. (optional, default `mapbox.places`) - * `options.localGeocoderOnly` **[Boolean][80]** If `true`, indicates that the `localGeocoder` results should be the only ones returned to the user. If `false`, indicates that the `localGeocoder` results should be combined with those from the Mapbox API with the `localGeocoder` results ranked higher. (optional, default `false`) - * `options.autocomplete` **[Boolean][80]** Specify whether to return autocomplete results or not. When autocomplete is enabled, results will be included that start with the requested string, rather than just responses that match it exactly. (optional, default `true`) - * `options.fuzzyMatch` **[Boolean][80]** Specify whether the Geocoding API should attempt approximate, as well as exact, matching when performing searches, or whether it should opt out of this behavior and only attempt exact matching. (optional, default `true`) - * `options.routing` **[Boolean][80]** Specify whether to request additional metadata about the recommended navigation destination corresponding to the feature or not. Only applicable for address features. (optional, default `false`) - * `options.worldview` **[String][76]** Filter results to geographic features whose characteristics are defined differently by audiences belonging to various regional, cultural, or political groups. (optional, default `"us"`) - * `options.enableGeolocation` **[Boolean][80]** If `true` enable user geolocation feature. (optional, default `false`) - * `options.useBrowserFocus` **[Boolean][80]** If `true`, the geocoder will use the browser's focus event to show suggestions. If `false`, it will only highlight active suggestions and Tab will not propagate to the suggestions list. (optional, default `false`) + * `options.reverseGeocode` **[boolean][88]** If `true`, enable reverse geocoding mode. In reverse geocoding, search input is expected to be coordinates in the form `lat, lon`, with suggestions being the reverse geocodes. (optional, default `false`) + * `options.flipCoordinates` **[boolean][88]** If `true`, search input coordinates for reverse geocoding is expected to be in the form `lon, lat` instead of the default `lat, lon`. (optional, default `false`) + * `options.enableEventLogging` **[Boolean][88]** Allow Mapbox to collect anonymous usage statistics from the plugin. (optional, default `true`) + * `options.marker` **([Boolean][88] | [Object][83])** If `true`, a [Marker][86] will be added to the map at the location of the user-selected result using a default set of Marker options. If the value is an object, the marker will be constructed using these options. If `false`, no marker will be added to the map. Requires that `options.mapboxgl` also be set. (optional, default `true`) + * `options.render` **[Function][93]?** A function that specifies how the results should be rendered in the dropdown menu. This function should accepts a single [extended GeoJSON][94] object as input and return a string. Any HTML in the returned string will be rendered. + * `options.getItemValue` **[Function][93]?** A function that specifies how the selected result should be rendered in the search bar. This function should accept a single [extended GeoJSON][94] object as input and return a string. HTML tags in the output string will not be rendered. Defaults to `(item) => item.place_name`. + * `options.mode` **[String][84]** A string specifying the geocoding [endpoint][95] to query. Options are `mapbox.places` and `mapbox.places-permanent`. The `mapbox.places-permanent` mode requires an enterprise license for permanent geocodes. (optional, default `mapbox.places`) + * `options.localGeocoderOnly` **[Boolean][88]** If `true`, indicates that the `localGeocoder` results should be the only ones returned to the user. If `false`, indicates that the `localGeocoder` results should be combined with those from the Mapbox API with the `localGeocoder` results ranked higher. (optional, default `false`) + * `options.autocomplete` **[Boolean][88]** Specify whether to return autocomplete results or not. When autocomplete is enabled, results will be included that start with the requested string, rather than just responses that match it exactly. (optional, default `true`) + * `options.fuzzyMatch` **[Boolean][88]** Specify whether the Geocoding API should attempt approximate, as well as exact, matching when performing searches, or whether it should opt out of this behavior and only attempt exact matching. (optional, default `true`) + * `options.routing` **[Boolean][88]** Specify whether to request additional metadata about the recommended navigation destination corresponding to the feature or not. Only applicable for address features. (optional, default `false`) + * `options.worldview` **[String][84]** Filter results to geographic features whose characteristics are defined differently by audiences belonging to various regional, cultural, or political groups. (optional, default `"us"`) + * `options.enableGeolocation` **[Boolean][88]** If `true` enable user geolocation feature. (optional, default `false`) + * `options.useBrowserFocus` **[Boolean][88]** If `true`, the geocoder will use the browser's focus event to show suggestions. If `false`, it will only highlight active suggestions and Tab will not propagate to the suggestions list. (optional, default `false`) * `options.addressAccuracy` **(`"address"` | `"street"` | `"place"` | `"country"`)** The accuracy for the geolocation feature with which we define the address line to fill. The browser API returns the user's position with accuracy, and sometimes we can get the neighbor's address. To prevent receiving an incorrect address, you can reduce the accuracy of the definition. (optional, default `"street"`) - * `options.inputTransforms` **[Object][75]?** Options controlling how the search input is transformed before being processed. + * `options.inputTransforms` **[Object][83]?** Options controlling how the search input is transformed before being processed. - * `options.inputTransforms.trimCoordinatesPunctuation` **[Boolean][80]** If `true`, leading/trailing punctuation characters (currently only `;`) are trimmed from the search input. (optional, default `false`) - * `options.parseExtendedSpatialFormats` **[Object][75]?** Options controlling which extended spatial input formats are recognized. This must be an object of the sub-options below. When the search input matches an enabled format, a feature for the parsed location is added as the first suggestion, counting against `options.limit` in the suggestion list alongside any geocoding results. All formats are disabled by default. Longitude must be within -180..180, latitude within -90..90, and zoom within 0..24. - - * `options.parseExtendedSpatialFormats.commaSeparatedLngLatZoom` **[Boolean][80]** If `true`, recognize input of the form `lng,lat,zoom` with no spaces, e.g. `6.925882,51.110352,11.31`. (optional, default `false`) - * `options.parseExtendedSpatialFormats.slashSeparatedZoomLatLng` **[Boolean][80]** If `true`, recognize input of the form `zoom/lat/lng` with no spaces, e.g. `11.31/51.110352/6.925882`. (optional, default `false`) - * `options.parseExtendedSpatialFormats.tile` **[Boolean][80]** If `true`, recognize XYZ tile coordinates of the form `z/x/y`, e.g. `14/8507/5477`, and resolve them to the center of the tile. Note that an all-integer `z/a/b` input within latitude/longitude range, e.g. `12/45/30`, is valid under both this format and `slashSeparatedZoomLatLng`; when both are enabled such input produces two suggestions, the tile one first. (optional, default `false`) - * `options.parseExtendedSpatialFormats.quadkey` **[Boolean][80]** If `true`, recognize a quadkey, e.g. `12020332200123`, and resolve it to the center of the quadkey. Be aware that a quadkey is any string of the digits `0`-`3`, so enabling this makes purely numeric searches ambiguous — searching for the postal code `20331`, for example, also produces a quadkey suggestion. Only enable it where numeric-only queries are not expected. (optional, default `false`) + * `options.inputTransforms.trimCoordinatesPunctuation` **[Boolean][88]** If `true`, leading/trailing punctuation characters (currently only `;`) are trimmed from the search input. (optional, default `false`) ### Examples @@ -153,9 +155,9 @@ Returns **[MapboxGeocoder][2]** `this` Add the geocoder to a container. The container can be either a `mapboxgl.Map`, an `HTMLElement` or a CSS selector string. -If the container is a [`mapboxgl.Map`][88], this function will behave identically to [`Map.addControl(geocoder)`][89]. -If the container is an instance of [`HTMLElement`][90], then the geocoder will be appended as a child of that [`HTMLElement`][90]. -If the container is a [CSS selector string][91], the geocoder will be appended to the element returned from the query. +If the container is a [`mapboxgl.Map`][96], this function will behave identically to [`Map.addControl(geocoder)`][97]. +If the container is an instance of [`HTMLElement`][98], then the geocoder will be appended as a child of that [`HTMLElement`][98]. +If the container is a [CSS selector string][99], the geocoder will be appended to the element returned from the query. This function will throw an error if the container is none of the above. It will also throw an error if the referenced HTML element cannot be found in the `document.body`. @@ -169,7 +171,7 @@ geocoder.addTo('#geocoder-container'); #### Parameters -* `container` **([String][76] | [HTMLElement][92] | mapboxgl.Map)** A reference to the container to which to add the geocoder +* `container` **([String][84] | [HTMLElement][100] | mapboxgl.Map)** A reference to the container to which to add the geocoder ### clear @@ -177,7 +179,7 @@ Clear and then focus the input. #### Parameters -* `ev` **[Event][93]?** the event that triggered the clear, if available +* `ev` **[Event][101]?** the event that triggered the clear, if available ### query @@ -185,7 +187,7 @@ Set & query the input #### Parameters -* `searchInput` **[string][76]** location name or other search input +* `searchInput` **[string][84]** location name or other search input Returns **[MapboxGeocoder][2]** this @@ -195,8 +197,8 @@ Set input #### Parameters -* `searchInput` **[string][76]** location name or other search input -* `showSuggestions` **[boolean][80]** display suggestion on setInput call (optional, default `false`) +* `searchInput` **[string][84]** location name or other search input +* `showSuggestions` **[boolean][88]** display suggestion on setInput call (optional, default `false`) Returns **[MapboxGeocoder][2]** this @@ -206,8 +208,8 @@ Set proximity #### Parameters -* `proximity` **([Object][75] | `"ip"`)** The new `options.proximity` value. This is a geographical point given as an object with `latitude` and `longitude` properties or the string 'ip'. -* `disableTrackProximity` **[Boolean][80]** If true, sets `trackProximity` to false. True by default to prevent `trackProximity` from unintentionally overriding an explicitly set proximity value. (optional, default `true`) +* `proximity` **([Object][83] | `"ip"`)** The new `options.proximity` value. This is a geographical point given as an object with `latitude` and `longitude` properties or the string 'ip'. +* `disableTrackProximity` **[Boolean][88]** If true, sets `trackProximity` to false. True by default to prevent `trackProximity` from unintentionally overriding an explicitly set proximity value. (optional, default `true`) Returns **[MapboxGeocoder][2]** this @@ -215,7 +217,7 @@ Returns **[MapboxGeocoder][2]** this Get proximity -Returns **[Object][75]** The geocoder proximity +Returns **[Object][83]** The geocoder proximity ### setRenderFunction @@ -223,7 +225,7 @@ Set the render function used in the results dropdown #### Parameters -* `fn` **[Function][85]** The function to use as a render function. This function accepts a single [extended GeoJSON][86] object as input and returns a string. +* `fn` **[Function][93]** The function to use as a render function. This function accepts a single [extended GeoJSON][94] object as input and returns a string. Returns **[MapboxGeocoder][2]** this @@ -231,7 +233,7 @@ Returns **[MapboxGeocoder][2]** this Get the function used to render the results dropdown -Returns **[Function][85]** the render function +Returns **[Function][93]** the render function ### setLanguage @@ -241,7 +243,7 @@ Look first at the explicitly set options otherwise use the browser's language se #### Parameters -* `language` **[String][76]** Specify the language to use for response text and query result weighting. Options are IETF language tags comprised of a mandatory ISO 639-1 language code and optionally one or more IETF subtags for country or script. More than one value can also be specified, separated by commas. +* `language` **[String][84]** Specify the language to use for response text and query result weighting. Options are IETF language tags comprised of a mandatory ISO 639-1 language code and optionally one or more IETF subtags for country or script. More than one value can also be specified, separated by commas. Returns **[MapboxGeocoder][2]** this @@ -249,13 +251,13 @@ Returns **[MapboxGeocoder][2]** this Get the language to use in UI elements and when making search requests -Returns **[String][76]** The language(s) used by the plugin, if any +Returns **[String][84]** The language(s) used by the plugin, if any ### getZoom Get the zoom level the map will move to when there is no bounding box on the selected result -Returns **[Number][79]** the map zoom +Returns **[Number][87]** the map zoom ### setZoom @@ -263,7 +265,7 @@ Set the zoom level #### Parameters -* `zoom` **[Number][79]** The zoom level that the map should animate to when a `bbox` isn't found in the response. If a `bbox` is found the map will fit to the `bbox`. +* `zoom` **[Number][87]** The zoom level that the map should animate to when a `bbox` isn't found in the response. If a `bbox` is found the map will fit to the `bbox`. Returns **[MapboxGeocoder][2]** this @@ -271,7 +273,7 @@ Returns **[MapboxGeocoder][2]** this Get the parameters used to fly to the selected response, if any -Returns **([Boolean][80] | [Object][75])** The `flyTo` option +Returns **([Boolean][88] | [Object][83])** The `flyTo` option ### setFlyTo @@ -279,13 +281,13 @@ Set the flyTo options #### Parameters -* `flyTo` **([Boolean][80] | [Object][75])** If false, animating the map to a selected result is disabled. If true, animating the map will use the default animation parameters. If an object, it will be passed as `options` to the map [`flyTo`][81] or [`fitBounds`][82] method providing control over the animation of the transition. +* `flyTo` **([Boolean][88] | [Object][83])** If false, animating the map to a selected result is disabled. If true, animating the map will use the default animation parameters. If an object, it will be passed as `options` to the map [`flyTo`][89] or [`fitBounds`][90] method providing control over the animation of the transition. ### getPlaceholder Get the value of the placeholder string -Returns **[String][76]** The input element's placeholder value +Returns **[String][84]** The input element's placeholder value ### setPlaceholder @@ -293,7 +295,7 @@ Set the value of the input element's placeholder #### Parameters -* `placeholder` **[String][76]** the text to use as the input element's placeholder +* `placeholder` **[String][84]** the text to use as the input element's placeholder Returns **[MapboxGeocoder][2]** this @@ -301,7 +303,7 @@ Returns **[MapboxGeocoder][2]** this Get the bounding box used by the plugin -Returns **[Array][83]<[Number][79]>** the bounding box, if any +Returns **[Array][91]<[Number][87]>** the bounding box, if any ### setBbox @@ -309,7 +311,7 @@ Set the bounding box to limit search results to #### Parameters -* `bbox` **[Array][83]<[Number][79]>** a bounding box given as an array in the format \[minX, minY, maxX, maxY]. +* `bbox` **[Array][91]<[Number][87]>** a bounding box given as an array in the format \[minX, minY, maxX, maxY]. Returns **[MapboxGeocoder][2]** this @@ -317,7 +319,7 @@ Returns **[MapboxGeocoder][2]** this Get a list of the countries to limit search results to -Returns **[String][76]** a comma separated list of countries to limit to, if any +Returns **[String][84]** a comma separated list of countries to limit to, if any ### setCountries @@ -325,7 +327,7 @@ Set the countries to limit search results to #### Parameters -* `countries` **[String][76]** a comma separated list of countries to limit to +* `countries` **[String][84]** a comma separated list of countries to limit to Returns **[MapboxGeocoder][2]** this @@ -333,7 +335,7 @@ Returns **[MapboxGeocoder][2]** this Get a list of the types to limit search results to -Returns **[String][76]** a comma separated list of types to limit to +Returns **[String][84]** a comma separated list of types to limit to ### setTypes @@ -342,7 +344,7 @@ Set the types to limit search results to #### Parameters * `types` -* `countries` **[String][76]** a comma separated list of types to limit to +* `countries` **[String][84]** a comma separated list of types to limit to Returns **[MapboxGeocoder][2]** this @@ -350,7 +352,7 @@ Returns **[MapboxGeocoder][2]** this Get the minimum number of characters typed to trigger results used in the plugin -Returns **[Number][79]** The minimum length in characters before a search is triggered +Returns **[Number][87]** The minimum length in characters before a search is triggered ### setMinLength @@ -358,7 +360,7 @@ Set the minimum number of characters typed to trigger results used by the plugin #### Parameters -* `minLength` **[Number][79]** the minimum length in characters +* `minLength` **[Number][87]** the minimum length in characters Returns **[MapboxGeocoder][2]** this @@ -366,7 +368,7 @@ Returns **[MapboxGeocoder][2]** this Get the limit value for the number of results to display used by the plugin -Returns **[Number][79]** The limit value for the number of results to display used by the plugin +Returns **[Number][87]** The limit value for the number of results to display used by the plugin ### setLimit @@ -374,7 +376,7 @@ Set the limit value for the number of results to display used by the plugin #### Parameters -* `limit` **[Number][79]** the number of search results to return +* `limit` **[Number][87]** the number of search results to return Returns **[MapboxGeocoder][2]** @@ -382,7 +384,7 @@ Returns **[MapboxGeocoder][2]** Get the filter function used by the plugin -Returns **[Function][85]** the filter function +Returns **[Function][93]** the filter function ### setFilter @@ -390,7 +392,7 @@ Set the filter function used by the plugin. #### Parameters -* `filter` **[Function][85]** A function which accepts a Feature in the [extended GeoJSON][86] format to filter out results from the Geocoding API response before they are included in the suggestions list. Return `true` to keep the item, `false` otherwise. +* `filter` **[Function][93]** A function which accepts a Feature in the [extended GeoJSON][94] format to filter out results from the Geocoding API response before they are included in the suggestions list. Return `true` to keep the item, `false` otherwise. Returns **[MapboxGeocoder][2]** this @@ -400,7 +402,7 @@ Set the geocoding endpoint used by the plugin. #### Parameters -* `origin` **[Function][85]** A function which accepts an HTTPS URL to specify the endpoint to query results from. +* `origin` **[Function][93]** A function which accepts an HTTPS URL to specify the endpoint to query results from. Returns **[MapboxGeocoder][2]** this @@ -408,7 +410,7 @@ Returns **[MapboxGeocoder][2]** this Get the geocoding endpoint the plugin is currently set to -Returns **[Function][85]** the endpoint URL +Returns **[Function][93]** the endpoint URL ### setAccessToken @@ -416,7 +418,7 @@ Set the accessToken option used for the geocoding request endpoint. #### Parameters -* `accessToken` **[String][76]** value +* `accessToken` **[String][84]** value Returns **[MapboxGeocoder][2]** this @@ -426,13 +428,13 @@ Set the autocomplete option used for geocoding requests #### Parameters -* `value` **[Boolean][80]** The boolean value to set autocomplete to +* `value` **[Boolean][88]** The boolean value to set autocomplete to ### getAutocomplete Get the current autocomplete parameter value used for requests -Returns **[Boolean][80]** The autocomplete parameter value +Returns **[Boolean][88]** The autocomplete parameter value ### setFuzzyMatch @@ -440,13 +442,13 @@ Set the fuzzyMatch option used for approximate matching in geocoding requests #### Parameters -* `value` **[Boolean][80]** The boolean value to set fuzzyMatch to +* `value` **[Boolean][88]** The boolean value to set fuzzyMatch to ### getFuzzyMatch Get the current fuzzyMatch parameter value used for requests -Returns **[Boolean][80]** The fuzzyMatch parameter value +Returns **[Boolean][88]** The fuzzyMatch parameter value ### setRouting @@ -454,13 +456,13 @@ Set the routing parameter used to ask for routable point metadata in geocoding r #### Parameters -* `value` **[Boolean][80]** The boolean value to set routing to +* `value` **[Boolean][88]** The boolean value to set routing to ### getRouting Get the current routing parameter value used for requests -Returns **[Boolean][80]** The routing parameter value +Returns **[Boolean][88]** The routing parameter value ### setWorldview @@ -468,13 +470,13 @@ Set the worldview parameter #### Parameters -* `code` **[String][76]** The country code representing the worldview (e.g. "us" | "cn" | "jp", "in") +* `code` **[String][84]** The country code representing the worldview (e.g. "us" | "cn" | "jp", "in") ### getWorldview Get the current worldview parameter value used for requests -Returns **[String][76]** The worldview parameter value +Returns **[String][84]** The worldview parameter value ### on @@ -482,12 +484,12 @@ Subscribe to events that happen within the plugin. #### Parameters -* `type` **[String][76]** name of event. Available events and the data passed into their respective event objects are:* **clear** `Emitted when the input is cleared` +* `type` **[String][84]** name of event. Available events and the data passed into their respective event objects are:* **clear** `Emitted when the input is cleared` * **loading** `{ query } Emitted when the geocoder is looking up a query` * **results** `{ results } Fired when the geocoder returns a response` * **result** `{ result } Fired when input is set` * **error** `{ error } Error as string` -* `fn` **[Function][85]** function that's called when the event is emitted. +* `fn` **[Function][93]** function that's called when the event is emitted. Returns **[MapboxGeocoder][2]** this; @@ -497,8 +499,8 @@ Remove an event #### Parameters -* `type` **[String][76]** Event name. -* `fn` **[Function][85]** Function that should unsubscribe to the event emitted. +* `type` **[String][84]** Event name. +* `fn` **[Function][93]** Function that should unsubscribe to the event emitted. Returns **[MapboxGeocoder][2]** this @@ -508,8 +510,8 @@ This function transforms the feature from reverse geocoding to plain text with s ### Parameters -* `feature` **[object][75]** -* `accuracy` **[string][76]** +* `feature` **[object][83]** +* `accuracy` **[string][84]** ## getAddressInfo @@ -517,9 +519,50 @@ This function transforms the feature from reverse geocoding to AddressInfo objec ### Parameters -* `feature` **[object][75]** +* `feature` **[object][83]** + +Returns **[object][83]** + +## commaSeparatedLngLatZoom + +Recognizes input of the form `lng,lat,zoom` with no spaces, e.g. `6.925882,51.110352,11.31`. + +### Parameters + +* `searchInput` **[String][84]** search input + +Returns **([Object][83] | null)** a GeoJSON Feature, or `null` if the input doesn't match + +## slashSeparatedZoomLatLng -Returns **[object][75]** +Recognizes input of the form `zoom/lat/lng` with no spaces, e.g. `11.31/51.110352/6.925882`. + +### Parameters + +* `searchInput` **[String][84]** search input + +Returns **([Object][83] | null)** a GeoJSON Feature, or `null` if the input doesn't match + +## tile + +Recognizes XYZ tile coordinates of the form `z/x/y`, e.g. `14/8507/5477`, and resolves them +to the center of the tile. + +### Parameters + +* `searchInput` **[String][84]** search input + +Returns **([Object][83] | null)** a GeoJSON Feature, or `null` if the input doesn't match + +## quadkey + +Recognizes a quadkey, e.g. `12020332200123`, and resolves it to the center of the quadkey. + +### Parameters + +* `searchInput` **[String][84]** search input + +Returns **([Object][83] | null)** a GeoJSON Feature, or `null` if the input doesn't match [1]: #getfooternode @@ -667,42 +710,58 @@ Returns **[object][75]** [73]: #parameters-26 -[74]: https://docs.mapbox.com/api/search/#geocoding +[74]: #commaseparatedlnglatzoom + +[75]: #parameters-27 + +[76]: #slashseparatedzoomlatlng + +[77]: #parameters-28 + +[78]: #tile + +[79]: #parameters-29 + +[80]: #quadkey + +[81]: #parameters-30 + +[82]: https://docs.mapbox.com/api/search/#geocoding -[75]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object +[83]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object -[76]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String +[84]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String -[77]: https://github.com/mapbox/mapbox-gl-js +[85]: https://github.com/mapbox/mapbox-gl-js -[78]: https://docs.mapbox.com/mapbox-gl-js/api/#marker +[86]: https://docs.mapbox.com/mapbox-gl-js/api/#marker -[79]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number +[87]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number -[80]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean +[88]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean -[81]: https://docs.mapbox.com/mapbox-gl-js/api/#map#flyto +[89]: https://docs.mapbox.com/mapbox-gl-js/api/#map#flyto -[82]: https://docs.mapbox.com/mapbox-gl-js/api/#map#fitbounds +[90]: https://docs.mapbox.com/mapbox-gl-js/api/#map#fitbounds -[83]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array +[91]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array -[84]: https://docs.mapbox.com/api/search/#data-types +[92]: https://docs.mapbox.com/api/search/#data-types -[85]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function +[93]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function -[86]: https://docs.mapbox.com/api/search/geocoding-v5/#geocoding-response-object +[94]: https://docs.mapbox.com/api/search/geocoding-v5/#geocoding-response-object -[87]: https://docs.mapbox.com/api/search/#endpoints +[95]: https://docs.mapbox.com/api/search/#endpoints -[88]: https://docs.mapbox.com/mapbox-gl-js/api/map/ +[96]: https://docs.mapbox.com/mapbox-gl-js/api/map/ -[89]: https://docs.mapbox.com/mapbox-gl-js/api/map/#map#addcontrol +[97]: https://docs.mapbox.com/mapbox-gl-js/api/map/#map#addcontrol -[90]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement +[98]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement -[91]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors +[99]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors -[92]: https://developer.mozilla.org/docs/Web/HTML/Element +[100]: https://developer.mozilla.org/docs/Web/HTML/Element -[93]: https://developer.mozilla.org/docs/Web/API/Event +[101]: https://developer.mozilla.org/docs/Web/API/Event diff --git a/CHANGELOG.md b/CHANGELOG.md index 29503863..0f8ff2f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,6 @@ ### Features / Improvements 🚀 - Add `inputTransforms.trimCoordinatesPunctuation` option (defaults to `false`). When enabled, leading/trailing punctuation (e.g. `;`) is trimmed from search input that looks like coordinates (e.g. `"48.774989, 9.155557;"`) -- Add `parseExtendedSpatialFormats` option for recognizing extended spatial input formats: `commaSeparatedLngLatZoom` (`6.925882,51.110352,11.31`), `slashSeparatedZoomLatLng` (`11.31/51.110352/6.925882`), `tile` (`14/8507/5477`) and `quadkey` (`12020332200123`). Each defaults to `false`. When enabled and the search input matches, a feature for the parsed location is added as the first suggestion alongside the geocoding results, and selecting it moves the map to those coordinates at the parsed zoom. For `tile` and `quadkey` the coordinates are the center of the tile. Note that `12/45/30`-style input is valid as both a tile and a `zoom/lat/lng` triple, so with both formats enabled it yields two suggestions. - Reject search input longer than 256 characters (matching the Geocoding v5 API's own limit) with a dedicated "search is too long" error message, instead of sending it to the API ### Bug fixes 🐛 diff --git a/debug/index.js b/debug/index.js index c206bc02..7f236bd5 100644 --- a/debug/index.js +++ b/debug/index.js @@ -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'; @@ -75,14 +76,15 @@ var geocoder = new MapboxGeocoder({ trackProximity: true, useBrowserFocus: true, enableGeolocation: true, - parseExtendedSpatialFormats: { - commaSeparatedLngLatZoom: true, - slashSeparatedZoomLatLng: true, - tile: true, - quadkey: 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 diff --git a/lib/index.js b/lib/index.js index 7293a06b..d3bcacf7 100644 --- a/lib/index.js +++ b/lib/index.js @@ -12,7 +12,6 @@ var localization = require('./localization'); var subtag = require('subtag'); var Geolocation = require('./geolocation'); var utils = require('./utils'); -var spatialFormats = require('./spatial-formats'); const GEOCODE_REQUEST_TYPE = { @@ -28,10 +27,6 @@ const PUNCTUATION_CHARS = new Set([';']); // short enough that regex-based input transforms can't be a ReDoS vector. const MAX_INPUT_LENGTH = 256; -// Options whose value is an object of sub-options. A caller passing a partial -// object keeps the defaults for the keys they left out, instead of wiping them. -const NESTED_OPTION_KEYS = ['inputTransforms', 'parseExtendedSpatialFormats']; - /** * Don't include this as part of the options object when creating a new MapboxGeocoder instance. */ @@ -91,11 +86,6 @@ function getFooterNode() { * @param {('address'|'street'|'place'|'country')} [options.addressAccuracy="street"] The accuracy for the geolocation feature with which we define the address line to fill. The browser API returns the user's position with accuracy, and sometimes we can get the neighbor's address. To prevent receiving an incorrect address, you can reduce the accuracy of the definition. * @param {Object} [options.inputTransforms] Options controlling how the search input is transformed before being processed. * @param {Boolean} [options.inputTransforms.trimCoordinatesPunctuation=false] If `true`, leading/trailing punctuation characters (currently only `;`) are trimmed from the search input. - * @param {Object} [options.parseExtendedSpatialFormats] Options controlling which extended spatial input formats are recognized. This must be an object of the sub-options below. When the search input matches an enabled format, a feature for the parsed location is added as the first suggestion, counting against `options.limit` in the suggestion list alongside any geocoding results. All formats are disabled by default. Longitude must be within -180..180, latitude within -90..90, and zoom within 0..24. - * @param {Boolean} [options.parseExtendedSpatialFormats.commaSeparatedLngLatZoom=false] If `true`, recognize input of the form `lng,lat,zoom` with no spaces, e.g. `6.925882,51.110352,11.31`. - * @param {Boolean} [options.parseExtendedSpatialFormats.slashSeparatedZoomLatLng=false] If `true`, recognize input of the form `zoom/lat/lng` with no spaces, e.g. `11.31/51.110352/6.925882`. - * @param {Boolean} [options.parseExtendedSpatialFormats.tile=false] If `true`, recognize XYZ tile coordinates of the form `z/x/y`, e.g. `14/8507/5477`, and resolve them to the center of the tile. Note that an all-integer `z/a/b` input within latitude/longitude range, e.g. `12/45/30`, is valid under both this format and `slashSeparatedZoomLatLng`; when both are enabled such input produces two suggestions, the tile one first. - * @param {Boolean} [options.parseExtendedSpatialFormats.quadkey=false] If `true`, recognize a quadkey, e.g. `12020332200123`, and resolve it to the center of the quadkey. Be aware that a quadkey is any string of the digits `0`-`3`, so enabling this makes purely numeric searches ambiguous — searching for the postal code `20331`, for example, also produces a quadkey suggestion. Only enable it where numeric-only queries are not expected. * @example * var geocoder = new MapboxGeocoder({ accessToken: mapboxgl.accessToken }); * map.addControl(geocoder); @@ -125,14 +115,8 @@ function MapboxGeocoder(options) { inputTransforms: { trimCoordinatesPunctuation: false }, - parseExtendedSpatialFormats: { - commaSeparatedLngLatZoom: false, - slashSeparatedZoomLatLng: false, - tile: false, - quadkey: false - }, getItemValue: function(item) { - if (item._source === spatialFormats.SOURCE) { + if (item._searchQuery) { return item._searchQuery; } @@ -148,11 +132,9 @@ function MapboxGeocoder(options) { this.options = extend({}, defaultOptions, options); - NESTED_OPTION_KEYS.forEach(function(key) { - if (options && options[key]) { - this.options[key] = extend({}, defaultOptions[key], options[key]); - } - }, this); + if (options && options.inputTransforms) { + this.options.inputTransforms = extend({}, defaultOptions.inputTransforms, options.inputTransforms); + } this.inputString = ''; this.fresh = true; @@ -900,8 +882,6 @@ MapboxGeocoder.prototype = { this._showLoadingIcon(); this._eventEmitter.emit('loading', { query: searchInput }); - const spatialFormatRes = spatialFormats.parse(searchInput, this.options.parseExtendedSpatialFormats); - const requestType = this._requestType(this.options, searchInput); const config = this._setupConfig(requestType, searchInput); @@ -986,9 +966,6 @@ MapboxGeocoder.prototype = { res.features = res.features.filter(this.options.filter); } - // put features found by spatialFormat parsing at the start of the list - res.features = spatialFormatRes.concat(res.features); - if (res.features.length) { this._showClearButton(); this._hideGeolocateButton(); @@ -1009,9 +986,9 @@ MapboxGeocoder.prototype = { this._hideAttribution(); // in the event of an error in the Mapbox Geocoding API still display - // results from the localGeocoder and from the extended spatial formats - - // the latter are parsed locally, so they stay valid when the request fails - var fallbackFeatures = spatialFormatRes.concat(localGeocoderRes); + // results from the localGeocoder, since those are computed locally and + // stay valid when the request fails + var fallbackFeatures = localGeocoderRes; if (fallbackFeatures.length) { this._showClearButton(); diff --git a/lib/spatial-formats.js b/lib/spatial-formats.js index 685f5164..357d10df 100644 --- a/lib/spatial-formats.js +++ b/lib/spatial-formats.js @@ -2,10 +2,6 @@ var utils = require('./utils'); -// Marks a feature synthesized from an extended spatial format, so it can be told -// apart from Geocoding API results (which carry `_source: 'mapbox'`). -const SOURCE = 'extended-spatial-format'; - // An integer or a decimal with at least one fractional digit, optionally signed. const NUMBER = '-?\\d+(?:\\.\\d+)?'; @@ -51,169 +47,146 @@ function createFeature(params) { }, properties: params.properties, _zoom: params.zoom, - _source: SOURCE, _searchQuery: params.searchQuery }; } -// Parsers for each known format. -// `tile` deliberately precedes `slashSeparatedZoomLatLng` -// because both accept `z/a/b`: an all-integer input that is also within -// latitude/longitude range (e.g. `12/45/30`) matches both, and the tile -// interpretation is listed first. -const FORMATS = [ - { - name: 'commaSeparatedLngLatZoom', - parse: function(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' - } - }); - } - }, - { - name: 'tile', - parse: function(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 } - } - }); - } - }, - { - name: 'slashSeparatedZoomLatLng', - parse: function(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' - } - }); - } - }, - { - name: 'quadkey', - parse: function(searchInput) { - if (!utils.isValidQuadkey(searchInput)) { - return null; - } - - const tile = utils.quadkeyToTile(searchInput); - const center = utils.tileToLngLat(tile.z, tile.x, tile.y); - - return createFeature({ - lng: center[0], - lat: center[1], - zoom: tile.z, - placeName: 'Quadkey,' + searchInput, - searchQuery: searchInput, - properties: { - spatialFormat: 'quadkey', - quadkey: searchInput - } - }); - } +/** + * 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' + } + }); +} /** - * Parses the search input against the enabled extended spatial formats. - * @private + * Recognizes input of the form `zoom/lat/lng` with no spaces, e.g. `11.31/51.110352/6.925882`. * @param {String} searchInput search input - * @param {Object} formatOptions - * @param {Boolean} [formatOptions.commaSeparatedLngLatZoom] If `true`, recognize input of the form `lng,lat,zoom` - * @param {Boolean} [formatOptions.slashSeparatedZoomLatLng] If `true`, recognize input of the form `zoom/lat/lng` - * @param {Boolean} [formatOptions.tile] If `true`, recognize XYZ tile coordinates of the form `z/x/y` - * @param {Boolean} [formatOptions.quadkey] If `true`, recognize a quadkey - * @returns {Array} one feature per enabled format that matched, in the order the formats are declared; `[]` when nothing matched + * @returns {Object|null} a GeoJSON Feature, or `null` if the input doesn't match */ -function parse(searchInput, formatOptions) { - if (!formatOptions) { - return []; +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 FORMATS.reduce(function(features, format) { - if (!formatOptions[format.name]) { - return features; + 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]); - const feature = format.parse(searchInput); - if (feature) { - features.push(feature); + 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 } } + }); +} - return features; - }, []); +/** + * 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 = { - parse: parse, - SOURCE: SOURCE + parseCommaSeparatedLngLatZoom: parseCommaSeparatedLngLatZoom, + parseSlashSeparatedZoomLatLng: parseSlashSeparatedZoomLatLng, + parseTile: parseTile, + parseQuadkey: parseQuadkey, }; diff --git a/test/events.test.js b/test/events.test.js index 776de05c..3ce95d15 100644 --- a/test/events.test.js +++ b/test/events.test.js @@ -222,9 +222,9 @@ test('search selects event with id-less (synthetic) features is not logged', fun var pushMethod = sinon.spy(eventsManager, "push"); var geocoder = new MapboxGeocoder({accessToken: 'abc123'}); // Neither feature has an `id`, e.g. two features synthesized from extended - // spatial formats (parseExtendedSpatialFormats: { tile: true, slashSeparatedZoomLatLng: true }). - var firstFeature = {place_name: 'Tile,x=45 y=30 z=12', place_type: ['coordinate'], properties: {}, _source: 'extended-spatial-format'}; - var secondFeature = {place_name: 'Point,lng=30 lat=45 zoom=12', place_type: ['coordinate'], properties: {}, _source: 'extended-spatial-format'}; + // spatial formats (see lib/spatial-formats.js's `tile` and `slashSeparatedZoomLatLng`). + var firstFeature = {place_name: 'Tile,x=45 y=30 z=12', place_type: ['coordinate'], properties: {}, _searchQuery: '12/45/30'}; + var secondFeature = {place_name: 'Point,lng=30 lat=45 zoom=12', place_type: ['coordinate'], properties: {}, _searchQuery: '12/45/30'}; geocoder._typeahead = { data: [firstFeature, secondFeature] }; diff --git a/test/spatial-formats.test.js b/test/spatial-formats.test.js index c7970c8a..f9fd0b54 100644 --- a/test/spatial-formats.test.js +++ b/test/spatial-formats.test.js @@ -3,19 +3,6 @@ var test = require('tape'); var spatialFormats = require('../lib/spatial-formats'); -var ALL_ENABLED = { - commaSeparatedLngLatZoom: true, - slashSeparatedZoomLatLng: true, - tile: true, - quadkey: true -}; - -function placeNames(features) { - return features.map(function (feature) { - return feature.place_name; - }); -} - function assertLngLat(t, actual, expected, msg) { t.ok( Math.abs(actual[0] - expected[0]) < 1e-9 && Math.abs(actual[1] - expected[1]) < 1e-9, @@ -24,64 +11,58 @@ function assertLngLat(t, actual, expected, msg) { } test('spatial-formats: commaSeparatedLngLatZoom', function (t) { - var formatOptions = { commaSeparatedLngLatZoom: true }; - var features = spatialFormats.parse('6.925882,51.110352,11.31', formatOptions); - - t.equal(features.length, 1, 'one feature'); - t.equal(features[0].place_name, 'Point,lng=6.925882 lat=51.110352 zoom=11.31', 'place_name echoes the input'); - t.deepEqual(features[0].center, [6.925882, 51.110352], 'center is [lng, lat]'); - t.equal(features[0]._zoom, 11.31, 'a fractional zoom is preserved'); - t.equal(features[0].properties.spatialFormat, 'commaSeparatedLngLatZoom', 'the format is recorded'); - - t.equal(spatialFormats.parse('-6,-51,0', formatOptions).length, 1, 'negative values and zoom 0 are accepted'); - t.equal(spatialFormats.parse('180,90,24', formatOptions).length, 1, 'the range boundaries are inclusive'); - t.deepEqual(spatialFormats.parse('181,51,11', formatOptions), [], 'lng above 180'); - t.deepEqual(spatialFormats.parse('6,91,11', formatOptions), [], 'lat above 90'); - t.deepEqual(spatialFormats.parse('6,51,25', formatOptions), [], 'zoom above 24'); - t.deepEqual(spatialFormats.parse('6,51,-1', formatOptions), [], 'negative zoom'); - t.deepEqual(spatialFormats.parse('6, 51, 11', formatOptions), [], 'spaces around commas are not accepted'); - t.deepEqual(spatialFormats.parse('6,51', formatOptions), [], 'two numbers are not enough'); - t.deepEqual(spatialFormats.parse('6,51,11,2', formatOptions), [], 'four numbers are too many'); + var feature = spatialFormats.parseCommaSeparatedLngLatZoom('6.925882,51.110352,11.31'); + + t.equal(feature.place_name, 'Point,lng=6.925882 lat=51.110352 zoom=11.31', 'place_name echoes the input'); + t.deepEqual(feature.center, [6.925882, 51.110352], 'center is [lng, lat]'); + t.equal(feature._zoom, 11.31, 'a fractional zoom is preserved'); + t.equal(feature.properties.spatialFormat, 'commaSeparatedLngLatZoom', 'the format is recorded'); + + t.ok(spatialFormats.parseCommaSeparatedLngLatZoom('-6,-51,0'), 'negative values and zoom 0 are accepted'); + t.ok(spatialFormats.parseCommaSeparatedLngLatZoom('180,90,24'), 'the range boundaries are inclusive'); + t.equal(spatialFormats.parseCommaSeparatedLngLatZoom('181,51,11'), null, 'lng above 180'); + t.equal(spatialFormats.parseCommaSeparatedLngLatZoom('6,91,11'), null, 'lat above 90'); + t.equal(spatialFormats.parseCommaSeparatedLngLatZoom('6,51,25'), null, 'zoom above 24'); + t.equal(spatialFormats.parseCommaSeparatedLngLatZoom('6,51,-1'), null, 'negative zoom'); + t.equal(spatialFormats.parseCommaSeparatedLngLatZoom('6, 51, 11'), null, 'spaces around commas are not accepted'); + t.equal(spatialFormats.parseCommaSeparatedLngLatZoom('6,51'), null, 'two numbers are not enough'); + t.equal(spatialFormats.parseCommaSeparatedLngLatZoom('6,51,11,2'), null, 'four numbers are too many'); t.end(); }); test('spatial-formats: slashSeparatedZoomLatLng', function (t) { - var formatOptions = { slashSeparatedZoomLatLng: true }; - var features = spatialFormats.parse('11.31/51.110352/6.925882', formatOptions); - - t.equal(features.length, 1, 'one feature'); - t.equal(features[0].place_name, 'Point,lng=6.925882 lat=51.110352 zoom=11.31', 'place_name is reordered to lng, lat, zoom'); - t.deepEqual(features[0].center, [6.925882, 51.110352], 'center is [lng, lat]'); - t.equal(features[0]._zoom, 11.31, 'zoom comes from the first component'); - t.equal(features[0].properties.spatialFormat, 'slashSeparatedZoomLatLng', 'the format is recorded'); - - t.deepEqual(spatialFormats.parse('25/51/6', formatOptions), [], 'zoom above 24'); - t.deepEqual(spatialFormats.parse('11/91/6', formatOptions), [], 'lat above 90'); - t.deepEqual(spatialFormats.parse('11/51/181', formatOptions), [], 'lng above 180'); - t.deepEqual(spatialFormats.parse('14/8507/5477', formatOptions), [], 'tile coordinates are out of lat/lng range'); - t.deepEqual(spatialFormats.parse('11 / 51 / 6', formatOptions), [], 'spaces around slashes are not accepted'); + var feature = spatialFormats.parseSlashSeparatedZoomLatLng('11.31/51.110352/6.925882'); + + t.equal(feature.place_name, 'Point,lng=6.925882 lat=51.110352 zoom=11.31', 'place_name is reordered to lng, lat, zoom'); + t.deepEqual(feature.center, [6.925882, 51.110352], 'center is [lng, lat]'); + t.equal(feature._zoom, 11.31, 'zoom comes from the first component'); + t.equal(feature.properties.spatialFormat, 'slashSeparatedZoomLatLng', 'the format is recorded'); + + t.equal(spatialFormats.parseSlashSeparatedZoomLatLng('25/51/6'), null, 'zoom above 24'); + t.equal(spatialFormats.parseSlashSeparatedZoomLatLng('11/91/6'), null, 'lat above 90'); + t.equal(spatialFormats.parseSlashSeparatedZoomLatLng('11/51/181'), null, 'lng above 180'); + t.equal(spatialFormats.parseSlashSeparatedZoomLatLng('14/8507/5477'), null, 'tile coordinates are out of lat/lng range'); + t.equal(spatialFormats.parseSlashSeparatedZoomLatLng('11 / 51 / 6'), null, 'spaces around slashes are not accepted'); t.end(); }); test('spatial-formats: tile', function (t) { - var formatOptions = { tile: true }; - var features = spatialFormats.parse('14/8507/5477', formatOptions); - - t.equal(features.length, 1, 'one feature'); - t.equal(features[0].place_name, 'Tile,x=8507 y=5477 z=14', 'place_name lists x, y, z'); - t.equal(features[0]._zoom, 14, 'zoom is the tile zoom'); - t.deepEqual(features[0].properties.tile, { z: 14, x: 8507, y: 5477 }, 'the tile components are exposed'); - t.equal(features[0].properties.spatialFormat, 'tile', 'the format is recorded'); - assertLngLat(t, features[0].center, [6.932373046875, 51.10352194240417], 'center is the tile center'); - - t.equal(spatialFormats.parse('0/0/0', formatOptions).length, 1, 'the z=0 world tile is valid'); - t.deepEqual(spatialFormats.parse('14/16384/5477', formatOptions), [], 'x is outside the z=14 grid'); - t.deepEqual(spatialFormats.parse('25/0/0', formatOptions), [], 'zoom above 24'); - t.deepEqual(spatialFormats.parse('14/8507.5/5477', formatOptions), [], 'components must be integers'); - t.deepEqual(spatialFormats.parse('14/-1/5477', formatOptions), [], 'negative components are rejected'); - t.equal(spatialFormats.parse('014/8507/5477', formatOptions).length, 1, 'leading zeros are accepted'); + var feature = spatialFormats.parseTile('14/8507/5477'); + + t.equal(feature.place_name, 'Tile,x=8507 y=5477 z=14', 'place_name lists x, y, z'); + t.equal(feature._zoom, 14, 'zoom is the tile zoom'); + t.deepEqual(feature.properties.tile, { z: 14, x: 8507, y: 5477 }, 'the tile components are exposed'); + t.equal(feature.properties.spatialFormat, 'tile', 'the format is recorded'); + assertLngLat(t, feature.center, [6.932373046875, 51.10352194240417], 'center is the tile center'); + + t.ok(spatialFormats.parseTile('0/0/0'), 'the z=0 world tile is valid'); + t.equal(spatialFormats.parseTile('14/16384/5477'), null, 'x is outside the z=14 grid'); + t.equal(spatialFormats.parseTile('25/0/0'), null, 'zoom above 24'); + t.equal(spatialFormats.parseTile('14/8507.5/5477'), null, 'components must be integers'); + t.equal(spatialFormats.parseTile('14/-1/5477'), null, 'negative components are rejected'); + t.ok(spatialFormats.parseTile('014/8507/5477'), 'leading zeros are accepted'); t.equal( - spatialFormats.parse('014/8507/5477', formatOptions)[0].place_name, + spatialFormats.parseTile('014/8507/5477').place_name, 'Tile,x=8507 y=5477 z=14', 'leading zeros are normalized away in place_name' ); @@ -89,83 +70,57 @@ test('spatial-formats: tile', function (t) { }); test('spatial-formats: quadkey', function (t) { - var formatOptions = { quadkey: true }; - var features = spatialFormats.parse('12020332200123',formatOptions); - - t.equal(features.length, 1, 'one feature'); - t.equal(features[0].place_name, 'Quadkey,12020332200123', 'place_name echoes the quadkey'); - t.equal(features[0]._zoom, 14, 'zoom is the quadkey length'); - t.equal(features[0].properties.quadkey, '12020332200123', 'the quadkey is exposed'); - t.equal(features[0].properties.spatialFormat, 'quadkey', 'the format is recorded'); - assertLngLat(t, features[0].center, [8.558349609375, 49.33228198473772], 'center is the tile center'); - - t.equal(spatialFormats.parse('0123',formatOptions).length, 1, 'a leading zero is a valid quadkey'); - t.equal(spatialFormats.parse('0123',formatOptions)[0]._zoom, 4, 'the leading zero counts towards the zoom'); - t.deepEqual(spatialFormats.parse('12345',formatOptions), [], 'digits above 3 are not a quadkey'); - t.deepEqual(spatialFormats.parse('12 0203',formatOptions), [], 'whitespace is not accepted'); - t.deepEqual(spatialFormats.parse('abc',formatOptions), [], 'letters are not accepted'); + var feature = spatialFormats.parseQuadkey('12020332200123'); + + t.equal(feature.place_name, 'Quadkey,12020332200123', 'place_name echoes the quadkey'); + t.equal(feature._zoom, 14, 'zoom is the quadkey length'); + t.equal(feature.properties.quadkey, '12020332200123', 'the quadkey is exposed'); + t.equal(feature.properties.spatialFormat, 'quadkey', 'the format is recorded'); + assertLngLat(t, feature.center, [8.558349609375, 49.33228198473772], 'center is the tile center'); + + t.ok(spatialFormats.parseQuadkey('0123'), 'a leading zero is a valid quadkey'); + t.equal(spatialFormats.parseQuadkey('0123')._zoom, 4, 'the leading zero counts towards the zoom'); + t.equal(spatialFormats.parseQuadkey('12345'), null, 'digits above 3 are not a quadkey'); + t.equal(spatialFormats.parseQuadkey('12 0203'), null, 'whitespace is not accepted'); + t.equal(spatialFormats.parseQuadkey('abc'), null, 'letters are not accepted'); t.end(); }); test('spatial-formats: feature shape', function (t) { - var feature = spatialFormats.parse('6.925882,51.110352,11.31', { commaSeparatedLngLatZoom: true })[0]; + var feature = spatialFormats.parseCommaSeparatedLngLatZoom('6.925882,51.110352,11.31'); t.equal(feature.type, 'Feature', 'is a GeoJSON Feature'); t.deepEqual(feature.place_type, ['coordinate'], 'place_type is coordinate'); t.equal(feature.geometry.type, 'Point', 'has a point geometry'); t.deepEqual(feature.geometry.coordinates, feature.center, 'geometry coordinates match center'); - t.equal(feature._source, 'extended-spatial-format', 'is tagged with the extended spatial format source'); + t.equal(feature._searchQuery, '6.925882,51.110352,11.31', 'has initial searchInput value'); t.equal(feature.bbox, undefined, 'has no bbox, so _fly uses center and _zoom'); t.end(); }); -test('spatial-formats: ambiguous z/a/b input yields both interpretations', function (t) { - t.deepEqual(placeNames(spatialFormats.parse('12/45/30', ALL_ENABLED)), [ - 'Tile,x=45 y=30 z=12', - 'Point,lng=30 lat=45 zoom=12' - ], 'the tile interpretation comes first, then lat/lng'); +test('spatial-formats: ambiguous z/a/b input matches both tile and slashSeparatedZoomLatLng', function (t) { + t.equal(spatialFormats.parseTile('12/45/30').place_name, 'Tile,x=45 y=30 z=12', 'tile interpretation'); + t.equal(spatialFormats.parseSlashSeparatedZoomLatLng('12/45/30').place_name, 'Point,lng=30 lat=45 zoom=12', 'lat/lng interpretation'); + + t.equal(spatialFormats.parseTile('12/45.5/30'), null, 'a decimal component rules out the tile interpretation'); + t.ok(spatialFormats.parseSlashSeparatedZoomLatLng('12/45.5/30'), 'the lat/lng interpretation still matches'); + + t.ok(spatialFormats.parseTile('14/8507/5477'), 'the tile interpretation matches'); + t.equal(spatialFormats.parseSlashSeparatedZoomLatLng('14/8507/5477'), null, 'an out-of-range latitude rules out the lat/lng interpretation'); - t.deepEqual( - placeNames(spatialFormats.parse('12/45/30', { tile: true })), - ['Tile,x=45 y=30 z=12'], - 'only an enabled format contributes' - ); - t.deepEqual( - placeNames(spatialFormats.parse('12/45.5/30', ALL_ENABLED)), - ['Point,lng=30 lat=45.5 zoom=12'], - 'a decimal component rules out the tile interpretation' - ); - t.deepEqual( - placeNames(spatialFormats.parse('14/8507/5477', ALL_ENABLED)), - ['Tile,x=8507 y=5477 z=14'], - 'an out-of-range latitude rules out the lat/lng interpretation' - ); - t.equal(spatialFormats.parse('6.925882,51.110352,11.31', ALL_ENABLED).length, 1, 'a comma-separated triple only ever matches one format'); - t.equal(spatialFormats.parse('12020332200123', ALL_ENABLED).length, 1, 'a quadkey only ever matches one format'); t.end(); }); -test('spatial-formats: every format is opt-in', function (t) { - var inputs = [ - '6.925882,51.110352,11.31', - '11.31/51.110352/6.925882', - '14/8507/5477', - '12020332200123' - ]; - - var formatOptions = { - commaSeparatedLngLatZoom: false, - slashSeparatedZoomLatLng: false, - tile: false, - quadkey: false - } - - inputs.forEach(function (input) { - t.deepEqual(spatialFormats.parse(input, formatOptions), [], 'no feature for "' + input + '" when all formats are disabled'); - t.deepEqual(spatialFormats.parse(input, undefined), [], 'no feature for "' + input + '" without format options'); - }); - - t.deepEqual(spatialFormats.parse('Berlin', ALL_ENABLED), [], 'ordinary text never matches'); - t.deepEqual(spatialFormats.parse('', ALL_ENABLED), [], 'empty input never matches'); - t.deepEqual(spatialFormats.parse('48.774989, 9.155557', ALL_ENABLED), [], 'plain reverse-geocode coordinates never match'); +test('spatial-formats: ordinary text never matches', function (t) { + t.equal(spatialFormats.parseCommaSeparatedLngLatZoom('Berlin'), null, 'commaSeparatedLngLatZoom'); + t.equal(spatialFormats.parseSlashSeparatedZoomLatLng('Berlin'), null, 'slashSeparatedZoomLatLng'); + t.equal(spatialFormats.parseTile('Berlin'), null, 'tile'); + t.equal(spatialFormats.parseQuadkey('Berlin'), null, 'quadkey'); + + t.equal(spatialFormats.parseCommaSeparatedLngLatZoom(''), null, 'commaSeparatedLngLatZoom on empty input'); + t.equal(spatialFormats.parseSlashSeparatedZoomLatLng(''), null, 'slashSeparatedZoomLatLng on empty input'); + t.equal(spatialFormats.parseTile(''), null, 'tile on empty input'); + t.equal(spatialFormats.parseQuadkey(''), null, 'quadkey on empty input'); + + t.equal(spatialFormats.parseCommaSeparatedLngLatZoom('48.774989, 9.155557'), null, 'plain reverse-geocode coordinates never match commaSeparatedLngLatZoom'); t.end(); }); diff --git a/test/test.geocoder.js b/test/test.geocoder.js index a28f4add..7b7458c8 100644 --- a/test/test.geocoder.js +++ b/test/test.geocoder.js @@ -8,7 +8,6 @@ var mapboxEvents = require('./../lib/events'); var sinon = require('sinon'); var localization = require('./../lib/localization'); var exceptions = require('./../lib/exceptions'); -var spatialFormats = require('./../lib/spatial-formats'); mapboxgl.accessToken = process.env.MapboxAccessToken; @@ -928,7 +927,6 @@ test('geocoder', function(tt) { var fixture = { id: 'abc123', place_name: 'Point,lng=6.925882 lat=51.110352 zoom=11', - _source: spatialFormats.SOURCE, _searchQuery: '6.925882,51.110352,11' } @@ -1717,28 +1715,6 @@ test('geocoder', function(tt) { t.end(); }); - tt.test('options.parseExtendedSpatialFormats - every format is disabled by default', function(t) { - setup(); - t.deepEqual(geocoder.options.parseExtendedSpatialFormats, { - commaSeparatedLngLatZoom: false, - slashSeparatedZoomLatLng: false, - tile: false, - quadkey: false - }, 'every format defaults to false'); - t.end(); - }); - - tt.test('options.parseExtendedSpatialFormats - a partial option keeps the remaining defaults', function(t) { - setup({ parseExtendedSpatialFormats: { tile: true } }); - t.deepEqual(geocoder.options.parseExtendedSpatialFormats, { - commaSeparatedLngLatZoom: false, - slashSeparatedZoomLatLng: false, - tile: true, - quadkey: false - }, 'only the key that was passed is overridden'); - t.end(); - }); - tt.test('options.inputTransforms - a partial option still keeps the remaining defaults', function(t) { var passedInputTransforms = { trimCoordinatesPunctuation: true }; setup({ inputTransforms: passedInputTransforms }); @@ -1749,160 +1725,5 @@ test('geocoder', function(tt) { t.end(); }); - // Stubs the Geocoding API with a fixed set of features, so the tests below do - // not depend on live API responses. - function stubForwardGeocode(features) { - return sinon.stub(geocoder.geocoderService, 'forwardGeocode').returns({ - send: function() { - return Promise.resolve({ - statusCode: '200', - body: { - type: 'FeatureCollection', - features: features - }, - request: {}, - headers: {} - }); - } - }); - } - - var apiFeatureFixture = { - id: 'place.1', - type: 'Feature', - place_type: ['place'], - text: 'Somewhere', - place_name: 'Somewhere, Germany', - center: [7, 51], - geometry: { - type: 'Point', - coordinates: [7, 51] - }, - properties: {} - }; - - tt.test('options.parseExtendedSpatialFormats - the parsed feature comes first in the results', function(t) { - t.plan(4); - setup({ parseExtendedSpatialFormats: { tile: true } }); - stubForwardGeocode([apiFeatureFixture]); - - geocoder.query('14/8507/5477'); - geocoder.on( - 'results', - once(function(e) { - t.equals(e.features.length, 2, 'the parsed feature and the API result are both present'); - t.equals(e.features[0].place_name, 'Tile,x=8507 y=5477 z=14', 'the parsed feature is first'); - t.equals(e.features[1].place_name, 'Somewhere, Germany', 'the API result follows it'); - t.equals(e.features[0]._source, 'extended-spatial-format', 'the parsed feature keeps its own _source instead of being relabelled "mapbox"'); - }) - ); - }); - - tt.test('options.parseExtendedSpatialFormats - no "no results" message when the API returns nothing', function(t) { - t.plan(4); - setup({ parseExtendedSpatialFormats: { quadkey: true } }); - stubForwardGeocode([]); - var noResultsSpy = sinon.spy(geocoder, '_renderNoResults'); - - geocoder.query('12020332200123'); - geocoder.on( - 'results', - once(function(e) { - t.equals(e.features.length, 1, 'only the parsed feature is present'); - t.equals(e.features[0].place_name, 'Quadkey,12020332200123', 'the parsed feature is shown'); - t.ok(noResultsSpy.notCalled, 'the "No results found" message is not rendered'); - // _geocode emits 'results' before it calls _typeahead.update(), so the - // suggestion list is only populated on the next tick. - setTimeout(function() { - t.equals(geocoder._typeahead.data.length, 1, 'the suggestion list holds the parsed feature'); - }); - }) - ); - }); - - tt.test('options.parseExtendedSpatialFormats - options.filter does not drop the parsed feature', function(t) { - t.plan(2); - setup({ - parseExtendedSpatialFormats: { tile: true }, - filter: function() { return false; } - }); - stubForwardGeocode([apiFeatureFixture]); - - geocoder.query('14/8507/5477'); - geocoder.on( - 'results', - once(function(e) { - t.equals(e.features.length, 1, 'the API result was filtered out'); - t.equals(e.features[0].place_name, 'Tile,x=8507 y=5477 z=14', 'the parsed feature survived the filter'); - }) - ); - }); - - tt.test('options.parseExtendedSpatialFormats - the parsed feature survives an API error', function(t) { - t.plan(4); - setup({ parseExtendedSpatialFormats: { tile: true } }); - sinon.stub(geocoder.geocoderService, 'forwardGeocode').returns({ - send: function() { - return Promise.reject(new Error('network is down')); - } - }); - var renderErrorSpy = sinon.spy(geocoder, '_renderError'); - - geocoder.on( - 'results', - once(function(e) { - t.equals(e.features.length, 1, 'the parsed feature is still delivered'); - t.equals(e.features[0].place_name, 'Tile,x=8507 y=5477 z=14', 'the parsed feature is shown'); - t.ok(renderErrorSpy.notCalled, 'the error message does not replace the result'); - }) - ); - geocoder.on( - 'error', - once(function() { - t.pass('the error event is still emitted'); - }) - ); - - // _geocode resolves to the underlying request promise, which is rejected - // here; catching it keeps the rejection from surfacing as an unhandled one. - geocoder._geocode('14/8507/5477').catch(function() {}); - }); - - tt.test('options.parseExtendedSpatialFormats - localGeocoderOnly still delivers the parsed feature', function(t) { - t.plan(2); - // The parser is purely local and makes no request, and both options are - // separate explicit opt-ins, so localGeocoderOnly must not suppress it. - setup({ - localGeocoderOnly: true, - localGeocoder: function() { return []; }, - parseExtendedSpatialFormats: { tile: true } - }); - - geocoder.query('14/8507/5477'); - geocoder.on( - 'results', - once(function(e) { - t.equals(e.features.length, 1, 'the parsed feature is delivered even though localGeocoderOnly is set'); - t.equals(e.features[0].place_name, 'Tile,x=8507 y=5477 z=14', 'the parsed feature is shown'); - }) - ); - }); - - tt.test('options.parseExtendedSpatialFormats - the ambiguous z/a/b input yields two suggestions through _geocode', function(t) { - t.plan(3); - setup({ parseExtendedSpatialFormats: { tile: true, slashSeparatedZoomLatLng: true } }); - stubForwardGeocode([]); - - geocoder.query('12/45/30'); - geocoder.on( - 'results', - once(function(e) { - t.equals(e.features.length, 2, 'both interpretations are present'); - t.equals(e.features[0].place_name, 'Tile,x=45 y=30 z=12', 'the tile interpretation comes first'); - t.equals(e.features[1].place_name, 'Point,lng=30 lat=45 zoom=12', 'the lat/lng interpretation comes second'); - }) - ); - }); - tt.end(); });