diff --git a/.gitignore b/.gitignore index e943e33..632ff33 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ env.test.sh dist node_modules -.env \ No newline at end of file +.idea +.env +CLAUDE.local.md \ No newline at end of file diff --git a/API.md b/API.md index 69d258d..f30f358 100644 --- a/API.md +++ b/API.md @@ -130,6 +130,9 @@ A geocoder component using the [Mapbox Geocoding API][74] * `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.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.trimCoordinatesPunctuation` **[Boolean][80]** If `true`, leading/trailing punctuation characters (currently only `;`) are trimmed from the search input. (optional, default `false`) ### Examples diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ecb2d..728fc6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## HEAD +### 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;"`) + ### Bug fixes 🐛 - Fix reverse geocoding errors caused by leading/trailing whitespace in coordinate input (e.g. `"48.774989, 9.155557 "`) diff --git a/lib/index.js b/lib/index.js index ec48f27..506bbbe 100644 --- a/lib/index.js +++ b/lib/index.js @@ -20,6 +20,8 @@ const GEOCODE_REQUEST_TYPE = { REVERSE: 2, }; +const PUNCTUATION_CHARS = new Set([';']); + /** * Don't include this as part of the options object when creating a new MapboxGeocoder instance. */ @@ -77,6 +79,8 @@ function getFooterNode() { * @param {Boolean} [options.enableGeolocation=false] If `true` enable user geolocation feature. * @param {Boolean} [options.useBrowserFocus=false] 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. * @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. * @example * var geocoder = new MapboxGeocoder({ accessToken: mapboxgl.accessToken }); * map.addControl(geocoder); @@ -85,26 +89,7 @@ function getFooterNode() { */ function MapboxGeocoder(options) { - this._eventEmitter = new EventEmitter(); - this.options = extend({}, this.options, options); - this.inputString = ''; - this.fresh = true; - this.lastSelected = null; - this.geolocation = new Geolocation(); -} - -function escapeHtml(str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -MapboxGeocoder.prototype = { - options: { + const defaultOptions = { zoom: 16, flyTo: true, trackProximity: true, @@ -122,6 +107,9 @@ MapboxGeocoder.prototype = { enableGeolocation: false, addressAccuracy: 'street', useBrowserFocus: false, + inputTransforms: { + trimCoordinatesPunctuation: false + }, getItemValue: function(item) { return item.place_name }, @@ -129,10 +117,34 @@ MapboxGeocoder.prototype = { var placeName = escapeHtml(item.place_name).split(','); return '
' + placeName[0]+ '
' + placeName.splice(1, placeName.length).join(',') + '
'; } - }, - - _headers: {}, + }; + this._eventEmitter = new EventEmitter(); + + this.options = extend({}, defaultOptions, options); + + if (options && options.inputTransforms) { + this.options.inputTransforms = extend({}, defaultOptions.inputTransforms, options.inputTransforms); + } + + this.inputString = ''; + this.fresh = true; + this.lastSelected = null; + this.geolocation = new Geolocation(); + this._headers = {}; +} + +function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +MapboxGeocoder.prototype = { /** * Add the geocoder to a container. The container can be either a `mapboxgl.Map`, an `HTMLElement` or a CSS selector string. * @@ -797,8 +809,34 @@ MapboxGeocoder.prototype = { return config; }, + /** + * Recursively applies transformations to the search input until a pass leaves it unchanged. + * @param {String} searchInput + * @returns {String} the transformed search input + * @private + */ + _transformInput: function(searchInput) { + let transformed = searchInput.trim(); + + if (this.options.inputTransforms.trimCoordinatesPunctuation && utils.RELAXED_COORD_RGX.test(transformed)) { + if (PUNCTUATION_CHARS.has(transformed.charAt(0))) { + transformed = transformed.slice(1); + } + + if (PUNCTUATION_CHARS.has(transformed.charAt(transformed.length - 1))) { + transformed = transformed.slice(0, -1); + } + } + + if (transformed !== searchInput) { + return this._transformInput(transformed); + } + + return transformed; + }, + _geocode: function(searchInput) { - searchInput = searchInput.trim(); + searchInput = this._transformInput(searchInput); this.inputString = searchInput; this._showLoadingIcon(); this._eventEmitter.emit('loading', { query: searchInput }); diff --git a/lib/utils.js b/lib/utils.js index 6db016e..2356503 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -62,8 +62,13 @@ function getAddressInfo(feature) { const REVERSE_GEOCODE_COORD_RGX = /^(-?\d{1,3}(\.\d{0,256})?)[, ]+(-?\d{1,3}(\.\d{0,256})?)$/; +// Unanchored version of REVERSE_GEOCODE_COORD_RGX: checks that the string contains +// coordinates somewhere in it, regardless of surrounding punctuation/whitespace. +const RELAXED_COORD_RGX = /(-?\d{1,3}(\.\d{0,256})?)[, ]+(-?\d{1,3}(\.\d{0,256})?)/; + module.exports = { transformFeatureToGeolocationText: transformFeatureToGeolocationText, getAddressInfo: getAddressInfo, REVERSE_GEOCODE_COORD_RGX: REVERSE_GEOCODE_COORD_RGX, + RELAXED_COORD_RGX: RELAXED_COORD_RGX, } \ No newline at end of file diff --git a/test/test.geocoder.js b/test/test.geocoder.js index ed65e29..79e1dc3 100644 --- a/test/test.geocoder.js +++ b/test/test.geocoder.js @@ -156,6 +156,102 @@ test('geocoder', function(tt) { ); }); + tt.test('options.inputTransforms.trimCoordinatesPunctuation - false by default', function(t) { + setup(); + t.equals(geocoder.options.inputTransforms.trimCoordinatesPunctuation, false, 'trimCoordinatesPunctuation defaults to false'); + t.end(); + }); + + tt.test('options.inputTransforms.trimCoordinatesPunctuation - false does not trim punctuation from coordinate-like input', function(t) { + t.plan(1); + setup({}); + geocoder.query(';48.774989, 9.155557;'); + geocoder.on( + 'results', + once(function() { + t.equals(geocoder.inputString, ';48.774989, 9.155557;', 'inputString keeps punctuation when trimCoordinatesPunctuation is disabled'); + }) + ); + }); + + tt.test('options.inputTransforms.trimCoordinatesPunctuation - true trims leading punctuation from coordinate-like input', function(t) { + t.plan(1); + setup({ + inputTransforms: { trimCoordinatesPunctuation: true } + }); + geocoder.query(';48.774989, 9.155557'); + geocoder.on( + 'results', + once(function() { + t.equals(geocoder.inputString, '48.774989, 9.155557', 'leading punctuation is trimmed from coordinate-like input'); + }) + ); + }); + + tt.test('options.inputTransforms.trimCoordinatesPunctuation - true trims trailing punctuation from coordinate-like input', function(t) { + t.plan(1); + setup({ + inputTransforms: { trimCoordinatesPunctuation: true } + }); + geocoder.query('48.774989, 9.155557;'); + geocoder.on( + 'results', + once(function() { + t.equals(geocoder.inputString, '48.774989, 9.155557', 'trailing punctuation is trimmed from coordinate-like input'); + }) + ); + }); + + tt.test('options.inputTransforms.trimCoordinatesPunctuation - true recursively trims repeated punctuation and whitespace', function(t) { + t.plan(1); + setup({ + inputTransforms: { trimCoordinatesPunctuation: true } + }); + geocoder.query(' ;;48.774989, 9.155557;; '); + geocoder.on( + 'results', + once(function() { + t.equals(geocoder.inputString, '48.774989, 9.155557', 'repeated punctuation and whitespace are trimmed'); + }) + ); + }); + + tt.test('options.inputTransforms.trimCoordinatesPunctuation - true does not trim punctuation from non-coordinate input', function(t) { + t.plan(1); + setup({ + inputTransforms: { trimCoordinatesPunctuation: true } + }); + geocoder.query('Paris;'); + geocoder.on( + 'results', + once(function() { + t.equals(geocoder.inputString, 'Paris;', 'punctuation is left untouched for non-coordinate input'); + }) + ); + }); + + tt.test('options.inputTransforms.trimCoordinatesPunctuation - true does not trim punctuation in the middle of coordinate-like input', function(t) { + t.plan(1); + setup({ + inputTransforms: { trimCoordinatesPunctuation: true } + }); + geocoder.query('note;48.774989, 9.155557;end'); + geocoder.on( + 'results', + once(function() { + t.equals(geocoder.inputString, 'note;48.774989, 9.155557;end', 'punctuation in the middle of the string is left untouched'); + }) + ); + }); + + tt.test('options.inputTransforms - partial object merges with defaults', function(t) { + setup({ + inputTransforms: {} + }); + t.equals(geocoder.options.inputTransforms.trimCoordinatesPunctuation, false, 'trimCoordinatesPunctuation still defaults to false when a partial inputTransforms object is provided'); + t.end(); + }); + tt.test('custom endpoint', function(t) { t.plan(1); setup({ origin: 'localhost:2999' });