Source: ext/lz-tabix-source.js

/**
 * An adapter that fetches data from a remote Tabix-indexed datafile, instead of a RESTful API.
 * Requires a generic user-specified parser function.
 *
 * ### Features provided
 * * {@link module:LocusZoom_Adapters~TabixUrlSource}
 *
 * ### Loading and usage
 * The page must incorporate and load all libraries before this file can be used, including:
 * - Vendor assets
 * - LocusZoom
 *
 * To use in an environment without special JS build tooling, simply load the extension file as JS from a CDN (after any dependencies):
 * ```
 * <script src="https://cdn.jsdelivr.net/npm/locuszoom@INSERT_VERSION_HERE/dist/ext/lz-tabix-source.min.js" type="application/javascript"></script>
 * ```
 *
 * To use with ES6 modules, the plugin must be loaded and registered explicitly before use:
 * ```
 * import LocusZoom from 'locuszoom';
 * import LzTabixSource from 'locuszoom/esm/ext/lz-tabix-source';
 * LocusZoom.use(LzTabixSource);
 * ```
 *
 * Then use the Adapter made available by this extension. For example:
 *
 * ```javascript
 * data_sources.add("assoc", ["TabixUrlSource", {
 *     url_data: 'https://s3-bucket.example/tabix-indexed-bgzip-file.gz',
 *     parser_func: (line_of_text) => object_of_parsed_data_for_this_line,
 *     // Tabix performs region queries. If you are fetching interval data (one end outside the edge of the plot), then
 *     // "overfetching" can help to ensure that data partially outside the view region is retrieved
 *     // If you are fetching single-point data like association summary stats, then overfetching is unnecessary
 *     overfetch: 0.25
 * }]);
 * ```
 *
 * @module
 */
import { urlReader } from 'tabix-reader';


function install(LocusZoom) {
    const BaseLZAdapter = LocusZoom.Adapters.get('BaseLZAdapter');

    /**
     * Loads data from a remote Tabix file (if the file host has been configured with proper
     *  CORS and Range header support). For instructions on how to configure a remote file host such as S3 or
     *  Google Cloud storage to serve files in the manner required, see:
     *  https://docs.cancergenomicscloud.org/docs/enabling-cross-origin-resource-sharing-cors#CORS
     *
     * @alias module:LocusZoom_Adapters~TabixUrlSource
     * @see {@link module:ext/lz-tabix-source} for required extension and installation instructions
     * @see module:LocusZoom_Adapters~BaseLZAdapter
     * @param {function} config.parser_func A function that parses a single line of text and returns (usually) a
     *  structured object of data fields
     * @param {string} config.url_data The URL for the bgzipped and tabix-indexed file
     * @param {string} [config.url_tbi] The URL for the tabix index. Defaults to `url_data` + '.tbi'
     * @param {Promise<Reader>} [config.reader] The URL for tabix-reader instance that provides the data. Mutually exclusive with providing a URL.
     *  Most LocusZoom usages will not pass an external reader. This option exists for websites like LocalZoom that accept
     *  many file formats and want to perform input validation before creating the plot: "select parser options", etc.
     * @param {number} [config.overfetch = 0] Optionally fetch more data than is required to satisfy the
     *  region query. (specified as a fraction of the region size, 0-1).
     *  Useful for sources where interesting features might lie near the edges of the plot, eg BED track intervals.
     */
    class TabixUrlSource extends BaseLZAdapter {
        constructor(config) {
            super(config);
            if (!config.parser_func || !(config.url_data || config.reader)) {
                throw new Error('Tabix source is missing required configuration options');
            }
            this.parser = config.parser_func;
            this.url_data = config.url_data;
            this.url_tbi = config.url_tbi || `${this.url_data}.tbi`;

            // In tabix mode, sometimes we want to fetch a slightly larger region than is displayed, in case a
            //    feature is on the edge of what the tabix query would return.
            //    Specify overfetch in units of % of total region size. ("fetch 10% extra before and after")
            this._overfetch = config.overfetch || 0;

            if (this._overfetch < 0 || this._overfetch > 1) {
                throw new Error('Overfetch must be specified as a fraction (0-1) of the requested region size');
            }

            // Assuming that the `tabix-reader` library has been loaded via a CDN, this will create the reader
            // Since fetching the index is a remote operation, all reader usages will be via an async interface.
            if (this.url_data) {
                this._reader_promise = urlReader(this.url_data, this.url_tbi).catch(function () {
                    throw new Error('Failed to create a tabix reader from the provided URL');
                });
            } else {
                this._reader_promise = Promise.resolve(config.reader);
            }
        }

        _performRequest(options) {
            return new Promise((resolve, reject) => {
                // Ensure that the reader is fully created (and index available), then make a query
                const region_start = options.start;
                const region_end = options.end;
                const extra_amount = this._overfetch * (region_end - region_start);

                const start = options.start - extra_amount;
                const end = options.end + extra_amount;
                this._reader_promise.then((reader) => {
                    reader.fetch(options.chr, start, end, function (data, err) {
                        if (err) {
                            reject(new Error('Could not read requested region. This may indicate an error with the .tbi index.'));
                        }
                        resolve(data);
                    });
                });
            });
        }

        _normalizeResponse(records) {
            // Parse the data from lines of text to objects
            return records.map(this.parser);
        }
    }

    LocusZoom.Adapters.add('TabixUrlSource', TabixUrlSource);
}

if (typeof LocusZoom !== 'undefined') {
    // Auto-register the plugin when included as a script tag. ES6 module users must register via LocusZoom.use()
    // eslint-disable-next-line no-undef
    LocusZoom.use(install);
}


export default install;