GeolocationUtils.sys.mjs (10443B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 MerinoClient: "moz-src:///browser/components/urlbar/MerinoClient.sys.mjs", 9 UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs", 10 }); 11 12 ChromeUtils.defineLazyGetter(lazy, "logger", () => 13 lazy.UrlbarUtils.getLogger({ prefix: "GeolocationUtils" }) 14 ); 15 16 // Cache period for Merino's geolocation response. This is intentionally a small 17 // amount of time. See the `cachePeriodMs` discussion in `MerinoClient`. 18 const GEOLOCATION_CACHE_PERIOD_MS = 2 * 60 * 60 * 1000; // 2 hours. 19 20 // The mean Earth radius used in distance calculations. 21 const EARTH_RADIUS_KM = 6371.009; 22 23 // Timeout setting to fetch geolocation from Merino. 24 const MERINO_TIMEOUT_MS = 5000; 25 26 /** 27 * Utils for fetching the client's geolocation from Merino, computing distances 28 * between locations, and finding suggestions that best match the geolocation. 29 */ 30 class _GeolocationUtils { 31 /** 32 * Fetches the client's geolocation from Merino. Merino gets the geolocation 33 * by looking up the client's IP address in its MaxMind database. We cache 34 * responses for a brief period of time so that fetches during a urlbar 35 * session don't ping Merino over and over. 36 * 37 * @returns {Promise<object>} 38 * An object with the following properties (see Merino source for latest): 39 * 40 * {string} country 41 * The full country name. 42 * {string} country_code 43 * The country ISO code. 44 * {string} region 45 * The full region name, e.g., the full name of a U.S. state. 46 * {string} region_code 47 * The region ISO code, e.g., the two-letter abbreviation for U.S. states. 48 * {string} city 49 * The city name. 50 * {object} location 51 * This object has the following properties: 52 * {number} latitude 53 * Latitude in decimal degrees. 54 * {number} longitude 55 * Longitude in decimal degrees. 56 * {number} radius 57 * Accuracy radius in km. 58 */ 59 async geolocation() { 60 if (!this.#merino) { 61 this.#merino = new lazy.MerinoClient("GeolocationUtils", { 62 cachePeriodMs: GEOLOCATION_CACHE_PERIOD_MS, 63 }); 64 } 65 66 lazy.logger.debug("Fetching geolocation from Merino"); 67 let results = await this.#merino.fetch({ 68 providers: ["geolocation"], 69 query: "", 70 timeoutMs: MERINO_TIMEOUT_MS, 71 }); 72 73 lazy.logger.debug("Got geolocation from Merino", results); 74 75 return results?.[0]?.custom_details?.geolocation || null; 76 } 77 78 /** 79 * @typedef {object} GeoLocationItem 80 * @property {string|number} [latitude] 81 * The location's latitude in decimal coordinates as either a string or float. 82 * @property {string|number} [longitude] 83 * The location's longitude in decimal coordinates as either a string or float. 84 * @property {string} [country] 85 * The location's two-digit ISO country code. Case doesn't matter. 86 * @property {string} [region] 87 * The location's region, e.g., a U.S. state. This is compared to the 88 * `region_code` in the Merino geolocation response (case insensitive) so 89 * it should be the same format: the region ISO code, e.g., the two-letter 90 * abbreviation for U.S. states. 91 * @property {number} [population] 92 * The location's population. 93 */ 94 95 /** 96 * Returns the item from an array of candidate items that best matches the 97 * client's geolocation. For urlbar, typically the items are suggestions, but 98 * they can be anything. 99 * 100 * The best item is determined as follows: 101 * 102 * 1. If any item locations include geographic coordinates, then the item with 103 * the closest location to the client's geolocation will be returned. 104 * 2. If any item locations include regions and populations, then the item 105 * with the most populous location in the client's region will be returned. 106 * 3. If any item locations include regions, then the first item with a 107 * location in the client's region will be returned. 108 * 4. If any item locations include countries and populations, then the item 109 * with the most populous location in the client's country will be 110 * returned. 111 * 5. If any item locations include countries, then the first item with the 112 * same country as the client will be returned. 113 * 114 * @param {Array} items 115 * Array of items, which can be anything. 116 * @param {(item: any) => GeoLocationItem} locationFromItem 117 * A function that maps an item to its location. It will be called as 118 * `locationFromItem(item)` and it should return an object with the 119 * defined properties, all optional. 120 * @returns {Promise<object|null>} 121 * The best item as described above, or null if `items` is empty. 122 */ 123 async best(items, locationFromItem = i => i) { 124 if (items.length <= 1) { 125 return items[0] || null; 126 } 127 128 let geo = await this.geolocation(); 129 if (!geo) { 130 return items[0]; 131 } 132 return ( 133 this.#bestByDistance(geo, items, locationFromItem) || 134 this.#bestByRegion(geo, items, locationFromItem) || 135 items[0] 136 ); 137 } 138 139 /** 140 * Returns the item with the city nearest the client's geolocation based on 141 * the great-circle distance between the coordinates [1]. This isn't 142 * necessarily super accurate, but that's OK since it's stable and accurate 143 * enough to find a good matching item. 144 * 145 * [1] https://en.wikipedia.org/wiki/Great-circle_distance 146 * 147 * @param {object} geo 148 * The `geolocation` object returned by Merino's geolocation provider. It's 149 * expected to have at least the properties below, but we gracefully handle 150 * exceptions. The coordinates are expected to be in decimal and the radius 151 * is expected to be in km. 152 * 153 * ``` 154 * { location: { latitude, longitude, radius }} 155 * ``` 156 * @param {Array} items 157 * Array of items as described in the doc for `best()`. 158 * @param {(item: any) => GeoLocationItem} locationFromItem 159 * Mapping function as described in the doc for `best()`. 160 * @returns {object|null} 161 * The nearest item as described above. If there are multiple nearest items 162 * within the accuracy radius, the most populous one is returned. If the 163 * `geo` does not include a location or coordinates, null is returned. 164 */ 165 #bestByDistance(geo, items, locationFromItem) { 166 let geoLat = parseFloat(geo.location?.latitude); 167 let geoLong = parseFloat(geo.location?.longitude); 168 if (isNaN(geoLat) || isNaN(geoLong)) { 169 return null; 170 } 171 172 // All distances are in km. 173 [geoLat, geoLong] = [geoLat, geoLong].map(toRadians); 174 let geoLatSin = Math.sin(geoLat); 175 let geoLatCos = Math.cos(geoLat); 176 let geoRadius = geo.location?.radius || 5; 177 178 let bestTuple; 179 let dMin = Infinity; 180 for (let item of items) { 181 let location = locationFromItem(item); 182 if (!location) { 183 continue; 184 } 185 186 let locationLat = 187 typeof location.latitude == "number" 188 ? location.latitude 189 : parseFloat(location.latitude); 190 let locationLong = 191 typeof location.longitude == "number" 192 ? location.longitude 193 : parseFloat(location.longitude); 194 if (isNaN(locationLat) || isNaN(locationLong)) { 195 continue; 196 } 197 198 let [itemLat, itemLong] = [locationLat, locationLong].map(toRadians); 199 let d = 200 EARTH_RADIUS_KM * 201 Math.acos( 202 geoLatSin * Math.sin(itemLat) + 203 geoLatCos * 204 Math.cos(itemLat) * 205 Math.cos(Math.abs(geoLong - itemLong)) 206 ); 207 if ( 208 !bestTuple || 209 // The item's location is closer to the client than the best 210 // location. 211 d + geoRadius < dMin || 212 // The item's location is the same distance from the client as the 213 // best location, i.e., the difference between the two distances is 214 // within the accuracy radius. Choose the item if it has a larger 215 // population. 216 (Math.abs(d - dMin) <= geoRadius && 217 hasLargerPopulation(location, bestTuple.location)) 218 ) { 219 dMin = d; 220 bestTuple = { item, location }; 221 } 222 } 223 224 return bestTuple?.item || null; 225 } 226 227 /** 228 * Returns the first item with a city located in the same region and country 229 * as the client's geolocation. If there is no such item, the first item in 230 * the same country is returned. If there is no item in the same country, null 231 * is returned. Ties are broken by population. 232 * 233 * @param {object} geo 234 * The `geolocation` object returned by Merino's geolocation provider. It's 235 * expected to have at least the properties listed below, but we gracefully 236 * handle exceptions: 237 * 238 * ``` 239 * { region_code, country_code } 240 * ``` 241 * @param {Array} items 242 * Array of items as described in the doc for `best()`. 243 * @param {(item: any) => GeoLocationItem} locationFromItem 244 * Mapping function as described in the doc for `best()`. 245 * @returns {object|null} 246 * The item as described above or null. 247 */ 248 #bestByRegion(geo, items, locationFromItem) { 249 let geoCountry = geo.country_code?.toLowerCase(); 250 if (!geoCountry) { 251 return null; 252 } 253 254 let geoRegion = geo.region_code?.toLowerCase(); 255 256 let bestCountryTuple; 257 let bestRegionTuple; 258 for (let item of items) { 259 let location = locationFromItem(item); 260 if (location?.country?.toLowerCase() == geoCountry) { 261 if ( 262 !bestCountryTuple || 263 hasLargerPopulation(location, bestCountryTuple.location) 264 ) { 265 bestCountryTuple = { item, location }; 266 } 267 if ( 268 location.region?.toLowerCase() == geoRegion && 269 (!bestRegionTuple || 270 hasLargerPopulation(location, bestRegionTuple.location)) 271 ) { 272 bestRegionTuple = { item, location }; 273 } 274 } 275 } 276 277 return bestRegionTuple?.item || bestCountryTuple?.item || null; 278 } 279 280 // `MerinoClient` 281 #merino; 282 } 283 284 function toRadians(deg) { 285 return (deg * Math.PI) / 180; 286 } 287 288 function hasLargerPopulation(a, b) { 289 return ( 290 typeof a.population == "number" && 291 (typeof b.population != "number" || b.population < a.population) 292 ); 293 } 294 295 export const GeolocationUtils = new _GeolocationUtils();