/* 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 GRID_URI = "resource://newtab/data/data/content/assets/location-interest-grid/"; export class LocationInterestGrid { #fetch; #metadataPromise = null; #bandPromises = new Map(); /** Creates a grid loader with the given resource fetcher. */ constructor({ fetch: fetchResource = fetch } = {}) { this.#fetch = fetchResource; } /** Fetches a resource from the packaged location interest grid. */ async #fetchResource(file) { const response = await this.#fetch(GRID_URI + file); if (!response.ok) { throw new Error(`Unable to load location interest grid: ${file}`); } return response; } /** Loads and validates the packaged grid metadata. */ async #loadMetadata() { const response = await this.#fetchResource("topic_grid_bands.json"); const metadata = /** @type {any} */ (await response.json()); if ( metadata.format_version !== 1 || metadata.encoding !== "packed16" || metadata.dtype !== "uint16" || metadata.byte_order !== "little" || metadata.layout !== "row-major" || metadata.bits_per_topic !== 2 || metadata.topic_order !== "most-significant-first" || metadata.levels_max !== 3 || !metadata.topics?.length || metadata.topics.length > 7 || !metadata.bands?.length ) { throw new Error("Unsupported location interest grid format"); } return metadata; } /** Loads and validates a binary grid band. */ async #loadBand(band) { const response = await this.#fetchResource(band.file); const buffer = await response.arrayBuffer(); if ( buffer.byteLength !== band.byte_length || buffer.byteLength !== 2 * band.n_lat * band.n_lon ) { throw new Error(`Invalid location interest grid size: ${band.file}`); } return new DataView(buffer); } /** * Returns discrete topic levels or null when no location prior is available. * Radius is the location accuracy radius in kilometers; omitted values use 25 km. * Resource loading errors reject the promise so callers can fall back. * * @param {object} location * @returns {Promise} */ async lookup({ latitude, longitude, radius } = {}) { // check for valid input if ( !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180 || (radius !== null && radius !== undefined && (!Number.isFinite(radius) || radius <= 0)) ) { return null; } // load grid metadata this.#metadataPromise ??= this.#loadMetadata().catch(error => { this.#metadataPromise = null; throw error; }); const metadata = await this.#metadataPromise; const radiusKm = radius ?? metadata.default_radius_km; // if location radius is above our grid, bounce if (radiusKm > metadata.no_prior_above_km) { return null; } // find closest radius const logRadius = Math.log(radiusKm); const band = metadata.bands.reduce((closest, candidate) => Math.abs(logRadius - Math.log(candidate.radius_km)) < Math.abs(logRadius - Math.log(closest.radius_km)) ? candidate : closest ); // compute index into grid based on (lat,lon) const row = Math.floor((latitude - band.lat0) / band.step_deg); const col = Math.floor((longitude - band.lon0) / band.step_deg); // make sure we are in the grid if (row < 0 || row >= band.n_lat || col < 0 || col >= band.n_lon) { return null; } // cache each grid/band as a promise if (!this.#bandPromises.has(band.file)) { this.#bandPromises.set( band.file, this.#loadBand(band).catch(error => { this.#bandPromises.delete(band.file); throw error; }) ); } // load the grid we need const raster = await this.#bandPromises.get(band.file); // index into the grid const code = raster.getUint16(2 * (row * band.n_lon + col), true); if (code === metadata.no_data) { return null; } if (code >= 4 ** metadata.topics.length) { throw new Error(`Invalid location interest grid value: ${band.file}`); } // decode and return the value at the grid location return Object.fromEntries( metadata.topics.map((topic, index) => [ topic, (code >>> (2 * (metadata.topics.length - 1 - index))) & 3, ]) ); } }