/**
* 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 };