Source: ext/lz-dynamic-urls.js

/**
 * Optional LocusZoom extension: must be included separately, and after LocusZoom has been loaded
 *
 * This plugin exports helper functions, but does not modify the global registry. It does not require `LocusZoom.use`.
 *
 * Demonstrates a mechanism by which the plot can be loaded to a specific initial state based on the URL query string
 *  (and, optionally, to update the URL bar when the plot state changes, with back button support)
 *
 * This makes it possible to create "direct links" to a particular plot of interest (and go back to a previous state
 *  as the user interacts with the page). Optionally, there is support for custom callbacks to connect the URL to
 *  arbitrarily complex plot behaviors.
 *
 * 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-dynamic-urls.min.js" type="application/javascript"></script>
 * ```
 *
 * To use with ES6 modules, import the helper functions and use them with your layout:
 *
 * ```
 * import LzDynamicUrls from 'locuszoom/esm/ext/lz-dynamic-urls';
 * ```
 *
 * After loading, bind the plot and URL as follows:
 * ```
 * // Declares which fields in plot.state will be mapped to and from the URL, eg `plot.state.chr` -> `example.com?chrom=X`
 * const stateUrlMapping = {chr: "chrom", start: "start", end: "end"};
 * // Fetch initial position from the URL, or use some defaults
 * let initialState = LzDynamicUrls.paramsFromUrl(stateUrlMapping);
 * if (!Object.keys(initialState).length) {
 *     initialState = {chr: 10, start: 114550452, end: 115067678};
 * }
 * layout = LocusZoom.Layouts.get("plot", "standard_association", {state: initialState});
 * const plot = LocusZoom.populate("#lz-plot", data_sources, layout);
 * // Once the plot has been created, we can bind it to the URL as follows. This will cause the URL to change whenever
 * //  the plot region changes, or, clicking the back button in your browser will reload the last region viewed
 * LzDynamicUrls.plotUpdatesUrl(plot, stateUrlMapping);
 * LzDynamicUrls.plotWatchesUrl(plot, stateUrlMapping);
 *
 * // NOTE: If you are building a page that adds/removes plots on the fly, event listeners will be cleaned up when
 * //   the destructor `plot.destroy()` is called
 * ```
 *
 *  @module
 */

function _serializeQueryParams(paramsObj) {
    // Serialize an object of parameter values into a query string
    // TODO: Improve support for array values v[]=1&v[]=2
    return `?${
        Object.keys(paramsObj).map(function(key) {
            return `${encodeURIComponent(key)}=${encodeURIComponent(paramsObj[key])}`;
        }).join('&')}`;
}

function _parseQueryParams(queryString) {
    // Parse a query string into an object of parameter values.
    //   Does not attempt any type coercion; all values are, therefore, strings.
    // TODO future: Support arrays / params that specify more than one value
    const query = {};
    if (queryString) {
        const pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&');
        for (let i = 0; i < pairs.length; i++) {
            const pair = pairs[i].split('=');
            query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '');
        }
    }
    return query;
}

// A useful helper function for serializing values from a provided object
function _extractValues(data, mapping, reverse) {
    // Use the mapping to convert between {stateField: urlParam} (or the reverse). Any fields not referenced in
    //  the "key" side of the mapping will be omitted from the return value.
    // Likewise, will omit any requested keys that the source side of the mapping has no information for
    reverse = reverse || false;

    const ret = {};
    let newMapping = mapping;
    if (reverse) {
        newMapping = {};
        Object.keys(mapping).forEach(function(k) {
            newMapping[mapping[k]] = k;
        });
    }

    Object.keys(newMapping).forEach(function(k) {
        const asName = newMapping[k];
        if (Object.prototype.hasOwnProperty.call(data, k)) {
            ret[asName] = data[k];
        }

    });
    return ret;
}

function _setStateFromUrlHandler(plot, stateData) {
    // A default way to deal with URL changes: push all the params as state into plot and rerender
    // More complex handlers are possible- example, URL parameters could be used to add or remove data layers
    plot.applyState(stateData);
}

function _setUrlFromStateHandler(plot, mapping) {
    // Serialize and return basic query params based solely on information from plot.state
    // More complex handlers are possible- the serializer can extract any information desired because it is given
    //  a direct reference to the plot object

    // This default method does not use the eventContext data, because so many things change plot.state without
    //  officially triggering an event.
    return _extractValues(plot.state, mapping);
}

/**
 * Extract plot parameters from the URL query string. Very useful for setting up the plot on initial page load.
 * @param {object} mapping How to map elements of plot state to URL param fields. Hash of
 *      {plotFieldName: urlParamName} entries (both values should be unique)
 * @param {string} [queryString='window.location.search'] The query string to parse
 * @returns {object} Plot parameter values
 */
function paramsFromUrl(mapping, queryString) {
    // Internal helper function: second argument only used for unit testing
    queryString = queryString || window.location.search;
    const queryParams = _parseQueryParams(queryString);
    return _extractValues(queryParams, mapping, true);
}

/**
 * Allows the plot to monitor changes in the URL and take action when the URL changes.
 *
 * For example, this enables using the browser back button to jump to a previous plot after user interaction.
 *
 * @param {Plot} plot A reference to the LZ plot
 * @param {object} mapping How to map elements of plot state to URL param fields. Hash of
 *      {plotFieldName: urlParamName} entries (both values should be unique)
 * @param {function} [callback] Specify how the plot acts on information read in from query params.
 *   The default behavior is to push the data into `plot.state`
 *   Signature is function(plot, plotDataFromQueryString)
 * @returns {function} The function handle for the new listener (allows cleanup if plot is removed later)
 */
function plotWatchesUrl(plot, mapping, callback) {
    callback = callback || _setStateFromUrlHandler;

    const listener = function (event) {
        const urlData = paramsFromUrl(mapping);
        // Tell the plot what to do with the params extracted from the URL
        callback(plot, urlData);
    };
    window.addEventListener('popstate', listener);
    plot.trackExternalListener(window, 'popstate', listener);
    return listener;
}

/**
 * Update the URL whenever the plot state changes
 * @param {Plot} plot A reference to the LZ plot
 * @param {object} mapping How to map elements of plot state to URL param fields. Hash of
 *      {plotFieldName: urlParamName} entries (both values should be unique)
 * @param {function} [callback] Specify how plot data will be serialized into query params
 *   The default behavior is to extract all the URL params from plot.state as the only source.
 *   Signature is function(plot, mapping, eventContext)
 * @returns {function} The function handle for the new listener (allows cleanup if plot is removed later)
 * @listens event:state_changed
 */
function plotUpdatesUrl(plot, mapping, callback) {
    callback = callback || _setUrlFromStateHandler;
    // Note: this event only fires when applyState receives *new* information that would trigger a rerender.
    // Plot state is sometimes changed without the event being fired.
    const listener = function (eventContext) {
        const oldParams = _parseQueryParams(window.location.search);
        // Apply custom serialization to convert plot data to URL params
        const serializedPlotData = callback(plot, mapping, eventContext);
        const newParams = Object.assign({}, oldParams, serializedPlotData);

        const update = Object.keys(newParams).some(function (k) {
            // Not every state change would affect the URL. Allow type coercion since query is a string.
            // eslint-disable-next-line eqeqeq
            return (oldParams[k] != newParams[k]);
        });
        if (update) {
            const queryString = _serializeQueryParams(newParams);

            if (Object.keys(oldParams).length) {
                history.pushState({}, document.title, queryString);
            } else {
                // Prevent broken back behavior on first page load: the first time query params are set,
                //  we don't generate a separate history entry
                history.replaceState({}, document.title, queryString);
            }

        }
    };
    plot.on('state_changed', listener);
    return listener;
}

// Slight build quirk: we use a single webpack file for all modules, but `libraryTarget` expects the entire
//  module to be exported as `default` in <script> tag mode.
const all = {
    paramsFromUrl,
    extractValues: _extractValues,
    plotUpdatesUrl,
    plotWatchesUrl,
};

export default all;
export { paramsFromUrl, _extractValues as extractValues, plotUpdatesUrl, plotWatchesUrl };