/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { MerinoClient: "resource:///modules/MerinoClient.sys.mjs", UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", }); // Cache period for Merino's geolocation response. This is intentionally a small // amount of time. See the `cachePeriodMs` discussion in `MerinoClient`. const GEOLOCATION_CACHE_PERIOD_MS = 120000; // 2 minutes // The mean Earth radius used in distance calculations. const EARTH_RADIUS_KM = 6371.009; /** * Utils for fetching the client's geolocation from Merino, computing distances * between locations, and finding suggestions that best match the geolocation. */ class _GeolocationUtils { constructor() { ChromeUtils.defineLazyGetter(this, "logger", () => lazy.UrlbarUtils.getLogger({ prefix: "GeolocationUtils" }) ); } /** * Fetches the client's geolocation from Merino. Merino gets the geolocation * by looking up the client's IP address in its MaxMind database. We cache * responses for a brief period of time so that fetches during a urlbar * session don't ping Merino over and over. * * @returns {object} * An object with the following properties (see Merino source for latest): * * {string} country * The full country name. * {string} country_code * The country ISO code. * {string} region * The full region name, e.g., the full name of a U.S. state. * {string} region_code * The region ISO code, e.g., the two-letter abbreviation for U.S. states. * {string} city * The city name. * {object} location * This object has the following properties: * {number} latitude * Latitude in decimal degrees. * {number} longitude * Longitude in decimal degrees. * {number} radius * Accuracy radius in km. */ async geolocation() { if (!this.#merino) { this.#merino = new lazy.MerinoClient("GeolocationUtils", { cachePeriodMs: GEOLOCATION_CACHE_PERIOD_MS, }); } this.logger.debug("Fetching geolocation from Merino"); let results = await this.#merino.fetch({ providers: ["geolocation"], query: "", }); this.logger.debug("Got geolocation from Merino", results); return results?.[0]?.custom_details?.geolocation || null; } /** * Returns the item from an array of candidate items that best matches the * client's geolocation. For urlbar, typically the items are suggestions, but * they can be anything. * * The best item is determined as follows: * * 1. If any item locations include geographic coordinates, then the item with * the closest location to the client's geolocation will be returned. * 2. If any item locations include regions and populations, then the item * with the most populous location in the client's region will be returned. * 3. If any item locations include regions, then the first item with a * location in the client's region will be returned. * 4. If any item locations include countries and populations, then the item * with the most populous location in the client's country will be * returned. * 5. If any item locations include countries, then the first item with the * same country as the client will be returned. * * @param {Array} items * Array of items, which can be anything. * @param {Function} locationFromItem * A function that maps an item to its location. It will be called as * `locationFromItem(item)` and it should return an object with the * following properties, all optional: * * {String|number} latitude * The location's latitude in decimal coordinates as either a string or * float. * {String|number} longitude * The location's longitude in decimal coordinates as either a string or * float. * {string} country * The location's two-digit ISO country code. Case doesn't matter. * {string} region * The location's region, e.g., a U.S. state. This is compared to the * `region_code` in the Merino geolocation response (case insensitive) so * it should be the same format: the region ISO code, e.g., the two-letter * abbreviation for U.S. states. * {number} population * The location's population. * @returns {object|null} * The best item as described above, or null if `items` is empty. */ async best(items, locationFromItem = i => i) { if (items.length <= 1) { return items[0] || null; } let geo = await this.geolocation(); if (!geo) { return items[0]; } return ( this.#bestByDistance(geo, items, locationFromItem) || this.#bestByRegion(geo, items, locationFromItem) || items[0] ); } /** * Returns the item with the city nearest the client's geolocation based on * the great-circle distance between the coordinates [1]. This isn't * necessarily super accurate, but that's OK since it's stable and accurate * enough to find a good matching item. * * [1] https://en.wikipedia.org/wiki/Great-circle_distance * * @param {object} geo * The `geolocation` object returned by Merino's geolocation provider. It's * expected to have at least the properties below, but we gracefully handle * exceptions. The coordinates are expected to be in decimal and the radius * is expected to be in km. * * ``` * { location: { latitude, longitude, radius }} * ``` * @param {Array} items * Array of items as described in the doc for `best()`. * @param {Function} locationFromItem * Mapping function as described in the doc for `best()`. * @returns {object|null} * The nearest item as described above. If there are multiple nearest items * within the accuracy radius, the most populous one is returned. If the * `geo` does not include a location or coordinates, null is returned. */ #bestByDistance(geo, items, locationFromItem) { let geoLat = parseFloat(geo.location?.latitude); let geoLong = parseFloat(geo.location?.longitude); if (isNaN(geoLat) || isNaN(geoLong)) { return null; } // All distances are in km. [geoLat, geoLong] = [geoLat, geoLong].map(toRadians); let geoLatSin = Math.sin(geoLat); let geoLatCos = Math.cos(geoLat); let geoRadius = geo.location?.radius || 5; let bestTuple; let dMin = Infinity; for (let item of items) { let location = locationFromItem(item); if (!location) { continue; } let locationLat = parseFloat(location.latitude); let locationLong = parseFloat(location.longitude); if (isNaN(locationLat) || isNaN(locationLong)) { continue; } let [itemLat, itemLong] = [locationLat, locationLong].map(toRadians); let d = EARTH_RADIUS_KM * Math.acos( geoLatSin * Math.sin(itemLat) + geoLatCos * Math.cos(itemLat) * Math.cos(Math.abs(geoLong - itemLong)) ); if ( !bestTuple || // The item's location is closer to the client than the best // location. d + geoRadius < dMin || // The item's location is the same distance from the client as the // best location, i.e., the difference between the two distances is // within the accuracy radius. Choose the item if it has a larger // population. (Math.abs(d - dMin) <= geoRadius && hasLargerPopulation(location, bestTuple.location)) ) { dMin = d; bestTuple = { item, location }; } } return bestTuple?.item || null; } /** * Returns the first item with a city located in the same region and country * as the client's geolocation. If there is no such item, the first item in * the same country is returned. If there is no item in the same country, null * is returned. Ties are broken by population. * * @param {object} geo * The `geolocation` object returned by Merino's geolocation provider. It's * expected to have at least the properties listed below, but we gracefully * handle exceptions: * * ``` * { region_code, country_code } * ``` * @param {Array} items * Array of items as described in the doc for `best()`. * @param {Function} locationFromItem * Mapping function as described in the doc for `best()`. * @returns {object|null} * The item as described above or null. */ #bestByRegion(geo, items, locationFromItem) { let geoCountry = geo.country_code?.toLowerCase(); if (!geoCountry) { return null; } let geoRegion = geo.region_code?.toLowerCase(); let bestCountryTuple; let bestRegionTuple; for (let item of items) { let location = locationFromItem(item); if (location?.country?.toLowerCase() == geoCountry) { if ( !bestCountryTuple || hasLargerPopulation(location, bestCountryTuple.location) ) { bestCountryTuple = { item, location }; } if ( location.region?.toLowerCase() == geoRegion && (!bestRegionTuple || hasLargerPopulation(location, bestRegionTuple.location)) ) { bestRegionTuple = { item, location }; } } } return bestRegionTuple?.item || bestCountryTuple?.item || null; } // `MerinoClient` #merino; } function toRadians(deg) { return (deg * Math.PI) / 180; } function hasLargerPopulation(a, b) { return ( typeof a.population == "number" && (typeof b.population != "number" || b.population < a.population) ); } export const GeolocationUtils = new _GeolocationUtils();