Repository: vgteam/sequenceTubeMap
Pull request #160 (2022-05-17 18:13:25)

Add link to the current view
### Changes:
- **Adds a 'Copy Link' button that allows the user to link to the current view**
- Refactor the fetchParams and similar data types into a viewTarget 
- Minor fixes in errors and button styling

![CopyLink](https://user-images.githubusercontent.com/11857485/168881076-5f89b1d4-ecc8-4f2a-8e70-416f30a36ee4.gif)

### Testing:
- E2E tests for copy button before and after hitting 'Go'
- Manually tested on examples (small and cactus), mounted files


Patch:
diff --git a/package-lock.json b/package-lock.json
index 0029074..e583b77 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3143,9 +3143,9 @@
       "integrity": "sha1-9wtzXGvKGlycItmCw+Oef+ujva0="
     },
     "async": {
-      "version": "2.6.3",
-      "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
-      "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
+      "version": "2.6.4",
+      "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
+      "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
       "requires": {
         "lodash": "^4.17.14"
       }
@@ -4910,11 +4910,11 @@
       "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
     },
     "ejs": {
-      "version": "3.1.6",
-      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.6.tgz",
-      "integrity": "sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==",
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.7.tgz",
+      "integrity": "sha512-BIar7R6abbUxDA3bfXrO4DSgwo8I+fB5/1zgujl3HLLjwd6+9iOnrT+t3grn2qbk9vOgBubXOFwX2m9axoFaGw==",
       "requires": {
-        "jake": "^10.6.1"
+        "jake": "^10.8.5"
       }
     },
     "electron-to-chromium": {
@@ -5714,11 +5714,29 @@
       }
     },
     "filelist": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.2.tgz",
-      "integrity": "sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.3.tgz",
+      "integrity": "sha512-LwjCsruLWQULGYKy7TX0OPtrL9kLpojOFKc5VCTxdFTV7w5zbsgqVKfnkKG7Qgjtq50gKfO56hJv88OfcGb70Q==",
       "requires": {
-        "minimatch": "^3.0.4"
+        "minimatch": "^5.0.1"
+      },
+      "dependencies": {
+        "brace-expansion": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+          "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+          "requires": {
+            "balanced-match": "^1.0.0"
+          }
+        },
+        "minimatch": {
+          "version": "5.0.1",
+          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz",
+          "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==",
+          "requires": {
+            "brace-expansion": "^2.0.1"
+          }
+        }
       }
     },
     "filename-reserved-regex": {
@@ -6780,30 +6798,20 @@
       }
     },
     "jake": {
-      "version": "10.8.2",
-      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.2.tgz",
-      "integrity": "sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==",
+      "version": "10.8.5",
+      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
+      "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==",
       "requires": {
-        "async": "0.9.x",
-        "chalk": "^2.4.2",
+        "async": "^3.2.3",
+        "chalk": "^4.0.2",
         "filelist": "^1.0.1",
         "minimatch": "^3.0.4"
       },
       "dependencies": {
         "async": {
-          "version": "0.9.2",
-          "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz",
-          "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0="
-        },
-        "chalk": {
-          "version": "2.4.2",
-          "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
-          "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
-          "requires": {
-            "ansi-styles": "^3.2.1",
-            "escape-string-regexp": "^1.0.5",
-            "supports-color": "^5.3.0"
-          }
+          "version": "3.2.3",
+          "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz",
+          "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g=="
         }
       }
     },
@@ -7732,9 +7740,9 @@
       }
     },
     "minimist": {
-      "version": "1.2.5",
-      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
-      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
+      "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="
     },
     "mkdirp": {
       "version": "0.5.5",
@@ -7820,9 +7828,9 @@
       }
     },
     "node-forge": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.2.1.tgz",
-      "integrity": "sha512-Fcvtbb+zBcZXbTTVwqGA5W+MKBj56UjVRevvchv5XrcyXbmNdesfZL37nlcWOfpgHhgmxApw3tQbTr4CqNmX4w=="
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
+      "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA=="
     },
     "node-gyp-build": {
       "version": "4.3.0",
diff --git a/package.json b/package.json
index 05d4d87..b77f730 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
     "prop-types": "^15.7.2",
     "react": "^17.0.2",
     "react-dom": "^17.0.2",
+    "react-fast-compare": "^3.2.0",
     "react-scripts": "5.0.0",
     "react-select": "^5.2.2",
     "reactstrap": "^9.0.1",
diff --git a/src/App.js b/src/App.js
index 0dfa7a2..d60d3ec 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,51 +1,30 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import './App.css';
-import HeaderForm from './components/HeaderForm';
-import TubeMapContainer from './components/TubeMapContainer';
-import CustomizationAccordion from './components/CustomizationAccordion';
-import { dataOriginTypes } from './enums';
-import * as tubeMap from './util/tubemap';
-import config from './config.json';
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import isEqual from "react-fast-compare";
+
+import "./App.css";
+import HeaderForm from "./components/HeaderForm";
+import TubeMapContainer from "./components/TubeMapContainer";
+import { urlParamsToViewTarget } from "./components/CopyLink";
+import CustomizationAccordion from "./components/CustomizationAccordion";
+import { dataOriginTypes } from "./enums";
+import * as tubeMap from "./util/tubemap";
+import config from "./config.json";
 
 class App extends Component {
   constructor(props) {
     super(props);
-    const ds = config.DATA_SOURCES[0];
-    let xgFile = ds.xgFile;
-    let region = ds.defaultPosition;
-    let gamFile = undefined;
-    if(ds.gamFile){
-      gamFile = ds.gamFile;
-    }
-    let gbwtFile = undefined;
-    if(ds.gbwtFile){
-      gbwtFile = ds.gbwtFile;
-    }
-    let bedFile = undefined;
-    if(ds.bedFile){
-      bedFile = ds.bedFile;
-    }
-    let dataPath = 'default';
-    if(ds.useMountedPath){
-      dataPath = 'mounted';
-    }
+
+    // Set ds to either URL params (if present) or the first example
+    const ds =
+      urlParamsToViewTarget(document.location) ?? config.DATA_SOURCES[0];
     this.state = {
       // These describe the files on the server side that we are working on.
-      fetchParams: {
-        // This is the query (like path:start-end) we are displaying.
-        region: region,
-        xgFile: xgFile,
-        gbwtFile: gbwtFile,
-        gamFile: gamFile,
-        bedFile: bedFile,
-        // This is the type of data paths we are working with, such as "mounted".
-        // All the paths are scoped to a type on the server side.
-        dataPath: dataPath
-      },
-      // This is a little like dataPath, but lets us toggle between data from
-      // the server and local test data. TODO: Unify?
+      // This is a little like dataPath (inside viewTarget, which specifies if we're using mounted/built-in data),
+      // but lets us toggle between data from
+      // the server and local test data
       dataOrigin: dataOriginTypes.API,
+      viewTarget: ds,
       // These are the current rendering settings.
       visOptions: {
         removeRedundantNodes: true,
@@ -53,12 +32,12 @@ class App extends Component {
         transparentNodes: false,
         showReads: true,
         showSoftClips: true,
-        haplotypeColors: 'ygreys',
-        forwardReadColors: 'reds',
-        reverseReadColors: 'blues',
+        haplotypeColors: "ygreys",
+        forwardReadColors: "reds",
+        reverseReadColors: "blues",
         colorReadsByMappingQuality: false,
-        mappingQualityCutoff: 0
-      }
+        mappingQualityCutoff: 0,
+      },
     };
   }
 
@@ -71,50 +50,77 @@ class App extends Component {
     tubeMap.setTransparentNodesFlag(visOptions.transparentNodes);
     tubeMap.setShowReadsFlag(visOptions.showReads);
     tubeMap.setSoftClipsFlag(visOptions.showSoftClips);
-    tubeMap.setColorSet('haplotypeColors', visOptions.haplotypeColors);
-    tubeMap.setColorSet('forwardReadColors', visOptions.forwardReadColors);
-    tubeMap.setColorSet('reverseReadColors', visOptions.reverseReadColors);
+    tubeMap.setColorSet("haplotypeColors", visOptions.haplotypeColors);
+    tubeMap.setColorSet("forwardReadColors", visOptions.forwardReadColors);
+    tubeMap.setColorSet("reverseReadColors", visOptions.reverseReadColors);
     tubeMap.setColorReadsByMappingQualityFlag(
       visOptions.colorReadsByMappingQuality
     );
     tubeMap.setMappingQualityCutoff(visOptions.mappingQualityCutoff);
   }
+  /*
+   * Drop undefined values
+   * See https://stackoverflow.com/questions/286141/remove-blank-attributes-from-an-object-in-javascript/38340730#38340730
+   */
+  removeUndefined = (obj) => {
+    return Object.fromEntries(
+      Object.entries(obj).filter(([_, v]) => v != null)
+    );
+  };
+
+  /**
+   * @param {ViewTarget} viewTarget - The new data that is selected to view
+   *    setCurrentViewTarget updates the current viewTarget to the new viewTarget that's passed in
+   *    before calling setCurrentViewTarget, viewTarget refers to what is currently being displayed
+   *    See types.ts for more info on viewTarget typ
+   */
+  setCurrentViewTarget = (viewTarget) => {
+    // Update the viewTarge
+    // Remove undefined for equality check
+    const newViewTarget = this.removeUndefined(viewTarget);
 
-  setFetchParams = fetchParams => {
-    this.setState({
-      fetchParams: fetchParams,
-      dataOrigin: dataOriginTypes.API
-    });
+    if (
+      !isEqual(this.state.viewTarget, newViewTarget) ||
+      this.state.dataOrigin !== dataOriginTypes.API
+    ) {
+      this.setState({
+        viewTarget: newViewTarget,
+        dataOrigin: dataOriginTypes.API,
+      });
+    }
+  };
+  getCurrentViewTarget = () => {
+    return this.removeUndefined(this.state.viewTarget);
   };
 
-  toggleVisOptionFlag = flagName => {
-    this.setState(state => ({
+  toggleVisOptionFlag = (flagName) => {
+    this.setState((state) => ({
       visOptions: {
         ...state.visOptions,
-        [flagName]: !state.visOptions[flagName]
-      }
+        [flagName]: !state.visOptions[flagName],
+      },
     }));
   };
 
-  handleMappingQualityCutoffChange = value => {
-    this.setState(state => ({
+  handleMappingQualityCutoffChange = (value) => {
+    this.setState((state) => ({
       visOptions: {
         ...state.visOptions,
-        mappingQualityCutoff: value
-      }
+        mappingQualityCutoff: value,
+      },
     }));
   };
 
   setColorSetting = (key, value) => {
-    this.setState(state => ({
+    this.setState((state) => ({
       visOptions: {
         ...state.visOptions,
-        [key]: value
-      }
+        [key]: value,
+      },
     }));
   };
 
-  setDataOrigin = dataOrigin => {
+  setDataOrigin = (dataOrigin) => {
     this.setState({ dataOrigin });
   };
 
@@ -122,14 +128,16 @@ class App extends Component {
     return (
       <div>
         <HeaderForm
-          setFetchParams={this.setFetchParams}
+          setCurrentViewTarget={this.setCurrentViewTarget}
           setDataOrigin={this.setDataOrigin}
           setColorSetting={this.setColorSetting}
           dataOrigin={this.state.dataOrigin}
           apiUrl={this.props.apiUrl}
+          defaultViewTarget={urlParamsToViewTarget(document.location)}
+          getCurrentViewTarget={this.getCurrentViewTarget}
         />
         <TubeMapContainer
-          fetchParams={this.state.fetchParams}
+          viewTarget={this.state.viewTarget}
           dataOrigin={this.state.dataOrigin}
           apiUrl={this.props.apiUrl}
         />
@@ -147,15 +155,15 @@ class App extends Component {
 }
 
 App.propTypes = {
-  apiUrl: PropTypes.string
-}
+  apiUrl: PropTypes.string,
+};
 
 App.defaultProps = {
   // Backend the whole app will hit against. Usually should be picked up from
   // the config or the browser, but needs to be swapped out in the fake
   // browser testing environment to point to a real testing backend.
   // Note that host includes the port.
-  apiUrl: (config.BACKEND_URL || `http://${window.location.host}`) + '/api/v0'
+  apiUrl: (config.BACKEND_URL || `http://${window.location.host}`) + "/api/v0",
 };
 
 export default App;
diff --git a/src/Types.ts b/src/Types.ts
new file mode 100644
index 0000000..15a53cd
--- /dev/null
+++ b/src/Types.ts
@@ -0,0 +1,25 @@
+// Type file for documentation
+// (not actually enforced by Typescript)
+
+// All the paths are scoped to a type on the server side.
+// This is the type of data paths we are working with, such as "mounted", "upload", or "default" - see server.js.
+type DataPath = "mounted" | "default" | "upload";
+
+// Describes whether a built-in example, user uploaded file, mounted, or synthetic example
+// Fills input for the "Data:" dropdown in the HeaderForm
+type DataType = "built-in" | "file-upload" | "mounted files" | "examples";
+
+// Describes something the Tube Map can look at, specifically a region and the files the region is in.
+// These parameters can be extracted from defaults (config), Header Form input, or URL parameters
+type ViewTarget = {
+  region: string; // Format: path:start-end
+  xgFile: string;
+  gbwtFile?: string;
+  gamFile?: string;
+  bedFile?: string;
+
+  // Non-essential to server, used for examples
+  name?: string;
+  dataPath: DataPath;
+  dataType: DataType;
+};
diff --git a/src/components/BedRegionsFormRow.js b/src/components/BedRegionsFormRow.js
index a1520f5..fa0c657 100644
--- a/src/components/BedRegionsFormRow.js
+++ b/src/components/BedRegionsFormRow.js
@@ -4,6 +4,7 @@ import { Label } from 'reactstrap';
 import SelectionDropdown from "./SelectionDropdown";
 
 class BedRegionsFormRow extends Component {
+  // TODO: Possibly blend this with the form dropdown in a datalist 
   render() {
     return (
       <React.Fragment>
@@ -13,6 +14,7 @@ class BedRegionsFormRow extends Component {
         >
           Region:
         </Label>
+        
         <SelectionDropdown
           className="customDataMounted dropdown mb-2 mr-sm-4 mb-sm-0"
           id="regionSelect"
diff --git a/src/components/CopyLink.js b/src/components/CopyLink.js
new file mode 100644
index 0000000..72ce775
--- /dev/null
+++ b/src/components/CopyLink.js
@@ -0,0 +1,63 @@
+import React, { useState } from "react";
+import { Button } from "reactstrap";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faLink } from "@fortawesome/free-solid-svg-icons";
+
+const UNCLICKED_TEXT = " Copy link to data";
+const CLICKED_TEXT = " Copied link!";
+
+export const writeToClipboard = (text) => {
+  navigator.clipboard.writeText(text);
+};
+
+// For testing purposes
+let copyCallback = writeToClipboard;
+export const setCopyCallback = (callback) => (copyCallback = callback);
+export function CopyLink(props) {
+  // Button to copy a link with viewTarget to the data selected
+
+  const [text, setText] = useState(UNCLICKED_TEXT);
+
+  const handleCopyLink = () => {
+    // Turn viewTarget into a URL query string
+    const viewTarget = props.getCurrentViewTarget();
+
+    const params = new URLSearchParams(viewTarget).toString();
+    const full = window.location.host + "?" + params;
+    copyCallback(full);
+
+    // Write link to clipboard
+    // Update button text to show we've copied
+    setText(CLICKED_TEXT);
+  };
+  return (
+    <Button id="copyLinkButton" color="primary" onClick={handleCopyLink}>
+      <FontAwesomeIcon icon={faLink} size="lg" />
+      {text}
+    </Button>
+  );
+}
+
+export const urlParamsToViewTarget = (url) => {
+  // @param {string} url - url containing search params
+  //  normally should be document.location
+  // Take any saved parameters in the query part of the URL
+  // and turn to object to populate in HeaderForm state and viewTarget
+  // Returns: Object (viewTarget) - see App.js
+  // Source for parsing: https://stackoverflow.com/questions/8648892/how-to-convert-url-parameters-to-a-javascript-object
+
+  // Get portion of URL after '?'
+  const params = new URL(url).searchParams;
+  if (params.toString() === "") return null;
+  // Parse the parameters from the URL
+  const urlParams = new URLSearchParams(params);
+  const entries = urlParams.entries();
+  const result = {};
+  for (const [key, value] of entries) {
+    // each 'entry' is a [key, value] tuple
+    if (value !== undefined) {
+      result[key] = value;
+    }
+  }
+  return result;
+};
diff --git a/src/components/DataPositionFormRow.js b/src/components/DataPositionFormRow.js
index 2ea9259..add3c77 100644
--- a/src/components/DataPositionFormRow.js
+++ b/src/components/DataPositionFormRow.js
@@ -1,15 +1,16 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import { Form, Label, Input, Button } from 'reactstrap';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { CopyLink } from "./CopyLink";
+import { Form, Label, Input, Button } from "reactstrap";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 import {
   faStepBackward,
   faStepForward,
   faSearchPlus,
   faSearchMinus,
-  faQuestionCircle
-} from '@fortawesome/free-solid-svg-icons';
-import * as tubeMap from '../util/tubemap';
+  faQuestionCircle,
+} from "@fortawesome/free-solid-svg-icons";
+import * as tubeMap from "../util/tubemap";
 
 const ZOOM_FACTOR = 2.0;
 
@@ -28,21 +29,21 @@ class DataPositionFormRow extends Component {
   };
 
   handleDownloadButton = () => {
-    const svgN = document.getElementById('svg');
+    const svgN = document.getElementById("svg");
     const svgData = new XMLSerializer().serializeToString(svgN);
     const svgBlob = new Blob([svgData], {
-      type: 'image/svg+xml;charset=utf-8'
+      type: "image/svg+xml;charset=utf-8",
     });
     const svgUrl = URL.createObjectURL(svgBlob);
 
-    const downloadLink = document.createElement('a');
+    const downloadLink = document.createElement("a");
     downloadLink.href = svgUrl;
-    downloadLink.download = 'graph.svg';
+    downloadLink.download = "graph.svg";
     document.body.appendChild(downloadLink);
     downloadLink.click();
     document.body.removeChild(downloadLink);
   };
-  
+
   onKeyUp(event) {
     if (event.key === "Enter") {
       event.preventDefault();
@@ -55,9 +56,8 @@ class DataPositionFormRow extends Component {
   // TODO: React-ify
   helpPopupFunction = () => {
     var popup = document.getElementById("helpPopup");
-    console.log('HELP');
     popup.classList.toggle("show");
-  }
+  };
 
   render() {
     return (
@@ -72,16 +72,19 @@ class DataPositionFormRow extends Component {
           size="36"
           value={this.props.region}
           onChange={this.props.handleInputChange}
-	  onKeyPress={this.onKeyUp}
+          onKeyPress={this.onKeyUp}
         />
         &nbsp;
         {this.props.uploadInProgress && (
           <div className="smallLoader" id="fileUploadSpinner" />
         )}
         <div className="popup" onClick={this.helpPopupFunction}>
-          <FontAwesomeIcon icon={faQuestionCircle} size="lg" />
+          <FontAwesomeIcon icon={faQuestionCircle} size="xl" />
           <span className="popuptext" id="helpPopup">
-            Seach for a coordinate range (e.g. "chr1:1-100"), a node ID ranges (e.g. "node:100-110"), a start position and a distance (e.g. "chr1:1+100"), or a node ID anchor and a distance (e.g. "node:100+10").
+            Seach for a coordinate range (e.g. "chr1:1-100"), a node ID ranges
+            (e.g. "node:100-110"), a start position and a distance (e.g.
+            "chr1:1+100"), or a node ID anchor and a distance (e.g.
+            "node:100+10").
           </span>
         </div>
         <Button
@@ -119,6 +122,7 @@ class DataPositionFormRow extends Component {
         >
           Download Image
         </Button>
+        <CopyLink getCurrentViewTarget={this.props.getCurrentViewTarget} />
       </Form>
     );
   }
@@ -129,8 +133,9 @@ DataPositionFormRow.propTypes = {
   handleGoLeft: PropTypes.func.isRequired,
   handleGoRight: PropTypes.func.isRequired,
   handleInputChange: PropTypes.func.isRequired,
-  region: PropTypes.string.isRequired,
+  region: PropTypes.string,
   uploadInProgress: PropTypes.bool.isRequired,
+  getCurrentViewTarget: PropTypes.func.isRequired,
 };
 
 export default DataPositionFormRow;
diff --git a/src/components/HeaderForm.js b/src/components/HeaderForm.js
index f5edba3..ce9b704 100644
--- a/src/components/HeaderForm.js
+++ b/src/components/HeaderForm.js
@@ -1,48 +1,49 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import { Container, Row, Col, Form, Label, Input, Alert } from 'reactstrap';
-import { dataOriginTypes } from '../enums';
-import { fetchAndParse } from '../fetchAndParse';
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { Container, Row, Col, Form, Label,  Alert } from "reactstrap";
+import { dataOriginTypes } from "../enums";
+import { fetchAndParse } from "../fetchAndParse";
 // import defaultConfig from '../config.default.json';
-import config from '../config.json';
-import DataPositionFormRow from './DataPositionFormRow';
-import MountedDataFormRow from './MountedDataFormRow';
-import BedRegionsFormRow from './BedRegionsFormRow';
-import PathNamesFormRow from './PathNamesFormRow';
-import FileUploadFormRow from './FileUploadFormRow';
-import ExampleSelectButtons from './ExampleSelectButtons';
+import config from "../config.json";
+import DataPositionFormRow from "./DataPositionFormRow";
+import MountedDataFormRow from "./MountedDataFormRow";
+import BedRegionsFormRow from "./BedRegionsFormRow";
+import PathNamesFormRow from "./PathNamesFormRow";
+import FileUploadFormRow from "./FileUploadFormRow";
+import ExampleSelectButtons from "./ExampleSelectButtons";
+// See src/Types.ts
 
 const DATA_SOURCES = config.DATA_SOURCES;
-const MAX_UPLOAD_SIZE_DESCRIPTION = '5 MB';
+const MAX_UPLOAD_SIZE_DESCRIPTION = "5 MB";
 const dataTypes = {
-  BUILT_IN: 'built-in',
-  FILE_UPLOAD: 'file-upload',
-  MOUNTED_FILES: 'mounted files',
-  EXAMPLES: 'examples'
+  BUILT_IN: "built-in",
+  FILE_UPLOAD: "file-upload",
+  MOUNTED_FILES: "mounted files",
+  EXAMPLES: "examples",
 };
 
 class HeaderForm extends Component {
   state = {
-    xgSelectOptions: ['none'],
-    xgSelect: 'none',
+    xgSelectOptions: ["none"],
+    xgSelect: "none",
 
-    gbwtSelectOptions: ['none'],
-    gbwtSelect: 'none',
+    gbwtSelectOptions: ["none"],
+    gbwtSelect: "none",
 
-    gamSelectOptions: ['none'],
-    gamSelect: 'none',
+    gamSelectOptions: ["none"],
+    gamSelect: "none",
 
-    bedSelectOptions: ['none'],
-    bedSelect: 'none',
+    bedSelectOptions: ["none"],
+    bedSelect: "none",
 
-    regionSelectOptions: ['none'],
+    regionSelectOptions: ["none"],
     // This tracks several arrays of BED region data, stored by data type, with
     // one entry in each array per region.
     regionInfo: {},
-    regionSelect: 'none',
+    regionSelect: "none",
 
-    pathSelectOptions: ['none'],
-    pathSelect: 'none',
+    pathSelectOptions: ["none"],
+    pathSelect: "none",
 
     xgFile: undefined,
     gbwtFile: undefined,
@@ -50,11 +51,14 @@ class HeaderForm extends Component {
     bedFile: undefined,
     dataPath: undefined,
     region: undefined,
+    name: undefined,
 
     dataType: dataTypes.BUILT_IN,
     fileSizeAlert: false,
     uploadInProgress: false,
-    error: null
+    error: null,
+
+    viewTarget: undefined,
   };
 
   componentDidMount() {
@@ -62,22 +66,23 @@ class HeaderForm extends Component {
     this.getMountedFilenames();
     this.setUpWebsocket();
   }
+  DATA_NAMES = DATA_SOURCES.map((source) => source.name);
 
-  // init with the first data source
   initState = () => {
-    let ds = DATA_SOURCES[0];
-    const xgSelect = ds.xgFile ? ds.xgFile : 'none';
-    const bedSelect = ds.bedFile ? ds.bedFile : 'none';
-    const dataPath = ds.useMountedPath ? 'mounted' : 'default';
-
-    this.setState(state => {
-      if (bedSelect !== 'none'){
+    // Populate state with either viewTarget or the first example
+    let ds = this.props.defaultViewTarget ?? DATA_SOURCES[0];
+    const xgSelect = ds.xgFile ? ds.xgFile : "none";
+    const bedSelect = ds.bedFile ? ds.bedFile : "none";
+    const dataPath = ds.dataPath;
+
+    this.setState((state) => {
+      if (bedSelect !== "none") {
         this.getBedRegions(bedSelect, dataPath);
       }
-      if (xgSelect !== 'none'){
+      if (xgSelect !== "none") {
         this.getPathNames(xgSelect, dataPath);
       }
-      return {
+      const stateVals = {
         xgFile: ds.xgFile,
         xgSelect: xgSelect,
         gbwtFile: ds.gbwtFile,
@@ -85,50 +90,52 @@ class HeaderForm extends Component {
         bedFile: ds.bedFile,
         bedSelect: bedSelect,
         dataPath: dataPath,
-        region: ds.defaultPosition,
-        dataType: dataTypes.BUILT_IN
+        region: ds.region,
+        dataType: ds.dataType,
+        name: ds.name,
       };
+      return stateVals;
     });
-  }
+  };
 
   getMountedFilenames = async () => {
     this.setState({ error: null });
     try {
       const json = await fetchAndParse(`${this.props.apiUrl}/getFilenames`, {
-        method: 'GET',
+        method: "GET",
         headers: {
-          'Content-Type': 'application/json'
-        }
+          "Content-Type": "application/json",
+        },
       });
       if (json.xgFiles === undefined) {
         // We did not get back a graph, only (possibly) an error.
-        const error = json.error || 'Listing file names';
+        const error = json.error || "Listing file names";
         this.setState({ error: error });
       } else {
-        json.xgFiles.unshift('none');
-        json.gbwtFiles.unshift('none');
-        json.gamIndices.unshift('none');
-        json.bedFiles.unshift('none');
+        json.xgFiles.unshift("none");
+        json.gbwtFiles.unshift("none");
+        json.gamIndices.unshift("none");
+        json.bedFiles.unshift("none");
 
-        if(this.state.dataPath === 'mounted'){
-          this.setState(state => {
+        if (this.state.dataPath === "mounted") {
+          this.setState((state) => {
             const xgSelect = json.xgFiles.includes(state.xgSelect)
-                  ? state.xgSelect
-                  : 'none';
+              ? state.xgSelect
+              : "none";
             const gbwtSelect = json.gbwtFiles.includes(state.gbwtSelect)
-                  ? state.gbwtSelect
-                  : 'none';
+              ? state.gbwtSelect
+              : "none";
             const gamSelect = json.gamIndices.includes(state.gamSelect)
-                  ? state.gamSelect
-                  : 'none';
+              ? state.gamSelect
+              : "none";
             const bedSelect = json.bedFiles.includes(state.bedSelect)
-                  ? state.bedSelect
-                  : 'none';
-            if (bedSelect !== 'none'){
-              this.getBedRegions(bedSelect, 'mounted');
+              ? state.bedSelect
+              : "none";
+            if (bedSelect !== "none") {
+              this.getBedRegions(bedSelect, "mounted");
             }
-            if (xgSelect !== 'none'){
-              this.getPathNames(xgSelect, 'mounted');
+            if (xgSelect !== "none") {
+              this.getPathNames(xgSelect, "mounted");
             }
             return {
               xgSelectOptions: json.xgFiles,
@@ -138,18 +145,18 @@ class HeaderForm extends Component {
               xgSelect,
               gbwtSelect,
               gamSelect,
-              bedSelect
+              bedSelect,
             };
           });
         } else {
-          this.setState(state => {
+          this.setState((state) => {
             return {
               xgSelectOptions: json.xgFiles,
               gbwtSelectOptions: json.gbwtFiles,
               gamSelectOptions: json.gamIndices,
-              bedSelectOptions: json.bedFiles
+              bedSelectOptions: json.bedFiles,
             };
-          });          
+          });
         }
       }
     } catch (error) {
@@ -162,25 +169,27 @@ class HeaderForm extends Component {
     this.setState({ error: null });
     try {
       const json = await fetchAndParse(`${this.props.apiUrl}/getBedRegions`, {
-        method: 'POST',
+        method: "POST",
         headers: {
-          'Content-Type': 'application/json'
+          "Content-Type": "application/json",
         },
-        body: JSON.stringify({ bedFile, dataPath })
+        body: JSON.stringify({ bedFile, dataPath }),
       });
       // We need to do all our parsing here, if we expect the catch to catch errors.
-      let bedRegionsDesc = json.bedRegions['desc'];
+      let bedRegionsDesc = json.bedRegions["desc"];
       if (!(bedRegionsDesc instanceof Array)) {
-        throw new Error("Server did not send back an array of BED region descriptions");
+        throw new Error(
+          "Server did not send back an array of BED region descriptions"
+        );
       }
-      this.setState(state => {
+      this.setState((state) => {
         const regionSelect = bedRegionsDesc.includes(state.regionSelect)
           ? state.regionSelect
           : bedRegionsDesc[0];
         return {
           regionInfo: json.bedRegions,
           regionSelectOptions: bedRegionsDesc,
-          regionSelect: regionSelect
+          regionSelect: regionSelect,
         };
       });
     } catch (error) {
@@ -191,9 +200,9 @@ class HeaderForm extends Component {
 
   resetBedRegions = () => {
     this.setState({
-      regionSelect: 'none',
+      regionSelect: "none",
       regionInfo: {},
-      regionSelectOptions: ['none']
+      regionSelectOptions: ["none"],
     });
   };
 
@@ -201,24 +210,24 @@ class HeaderForm extends Component {
     this.setState({ error: null });
     try {
       const json = await fetchAndParse(`${this.props.apiUrl}/getPathNames`, {
-        method: 'POST',
+        method: "POST",
         headers: {
-          'Content-Type': 'application/json'
+          "Content-Type": "application/json",
         },
-        body: JSON.stringify({ xgFile, dataPath })
+        body: JSON.stringify({ xgFile, dataPath }),
       });
       // We need to do all our parsing here, if we expect the catch to catch errors.
       let pathNames = json.pathNames;
       if (!(pathNames instanceof Array)) {
         throw new Error("Server did not send back an array of path names");
       }
-      this.setState(state => {
+      this.setState((state) => {
         const pathSelect = pathNames.includes(state.pathSelect)
           ? state.pathSelect
           : pathNames[0];
         return {
           pathSelectOptions: pathNames,
-          pathSelect
+          pathSelect,
         };
       });
     } catch (error) {
@@ -229,18 +238,18 @@ class HeaderForm extends Component {
 
   resetPathNames = () => {
     this.setState({
-      pathSelectOptions: ['none'],
-      pathSelect: 'none'
+      pathSelectOptions: ["none"],
+      pathSelect: "none",
     });
   };
 
-  handleDataSourceChange = event => {
+  handleDataSourceChange = (event) => {
     const value = event.target.value;
-    DATA_SOURCES.forEach(ds => {
+    DATA_SOURCES.forEach((ds) => {
       if (ds.name === value) {
-        let dataPath = ds.useMountedPath ? 'mounted' : 'default';
-        let bedSelect = 'none';
-        if(ds.bedFile){
+        let dataPath = ds.dataPath;
+        let bedSelect = "none";
+        if (ds.bedFile) {
           this.getBedRegions(ds.bedFile, dataPath);
           bedSelect = ds.bedFile;
         }
@@ -253,80 +262,94 @@ class HeaderForm extends Component {
           bedFile: ds.bedFile,
           bedSelect: bedSelect,
           dataPath: dataPath,
-          region: ds.defaultPosition,
-          dataType: dataTypes.BUILT_IN
+          region: ds.region,
+          dataType: dataTypes.BUILT_IN,
+          name: ds.name,
         });
         return;
       }
     });
-    if (value === 'customFileUpload') {
-      this.setState(state => {
+    if (value === dataTypes.FILE_UPLOAD) {
+      this.setState((state) => {
         return {
           xgFile: state.xgSelect,
           gbwtFile: state.gbwtSelect,
           gamFile: state.gamSelect,
           bedFile: state.bedSelect,
-          dataPath: 'upload',
-          dataType: dataTypes.FILE_UPLOAD
+          dataPath: "upload",
+          dataType: dataTypes.FILE_UPLOAD,
         };
       });
-    } else if (value === 'customMounted') {
-      this.setState(state => {
+    } else if (value === dataTypes.MOUNTED_FILES) {
+      this.setState((state) => {
         return {
           xgFile: state.xgSelect,
           gbwtFile: state.gbwtSelect,
           gamFile: state.gamSelect,
           bedFile: state.bedSelect,
-          dataPath: 'mounted',
-          dataType: dataTypes.MOUNTED_FILES
+          dataPath: "mounted",
+          dataType: dataTypes.MOUNTED_FILES,
         };
       });
-    } else if (value === 'syntheticExamples') {
+    } else if (value === dataTypes.EXAMPLES) {
+      // Synthetic data examples in dropdown
       this.setState({ dataType: dataTypes.EXAMPLES });
+    } else {
+      // TODO: name and others
+      this.setState({ dataType: dataTypes.BUILT_IN });
     }
   };
+  getNextViewTarget = () => ({
+    name: this.state.name,
+    region: this.state.region,
+    xgFile: this.state.xgFile,
+    gbwtFile: this.state.gbwtFile,
+    gamFile: this.state.gamFile,
+    bedFile: this.state.bedFile,
+    dataPath: this.state.dataPath,
+    dataType: this.state.dataType,
+  });
 
   handleGoButton = () => {
     if (this.props.dataOrigin !== dataOriginTypes.API) {
-      this.props.setColorSetting('haplotypeColors', 'ygreys');
-      this.props.setColorSetting('forwardReadColors', 'reds');
+      this.props.setColorSetting("haplotypeColors", "ygreys");
+      this.props.setColorSetting("forwardReadColors", "reds");
     }
-    const fetchParams = {
-      region: this.state.region,
-      xgFile: this.state.xgFile,
-      gbwtFile: this.state.gbwtFile,
-      gamFile: this.state.gamFile,
-      bedFile: this.state.bedFile,
-      dataPath: this.state.dataPath,
-    };
-    this.props.setFetchParams(fetchParams);
+    const viewTarget = this.getNextViewTarget();
+    this.props.setCurrentViewTarget(viewTarget);
   };
 
-  handleInputChange = event => {
+  handleInputChange = (event) => {
     const id = event.target.id;
     const value = event.target.value;
     this.setState({ [id]: value });
-    if (id === 'xgSelect') {
+    if (id === "xgSelect") {
       this.getPathNames(value, this.state.dataPath);
       this.setState({ xgFile: value });
-    } else if (id === 'gbwtSelect') {
+    } else if (id === "gbwtSelect") {
       this.setState({ gbwtFile: value });
-    } else if (id === 'gamSelect') {
+    } else if (id === "gamSelect") {
       this.setState({ gamFile: value });
-    } else if (id === 'bedSelect') {
+    } else if (id === "bedSelect") {
       this.getBedRegions(value, this.state.dataPath);
       this.setState({ bedFile: value });
-    } else if (id === 'pathSelect') {
-      this.setState({ region: value.concat(':') });
-    } else if (id === 'regionSelect') {
+    } else if (id === "pathSelect") {
+      this.setState({ region: value.concat(":") });
+    } else if (id === "regionSelect") {
       // find which region corresponds to this region label/desc
       let i = 0;
-      while (i < this.state.regionInfo['desc'].length && this.state.regionInfo['desc'][i] !== value) i += 1;
-      if (i < this.state.regionInfo['desc'].length){
-        let region_chr = this.state.regionInfo['chr'][i];
-        let region_start = this.state.regionInfo['start'][i];
-        let region_end = this.state.regionInfo['end'][i];
-        this.setState({ region: region_chr.concat(':', region_start, '-', region_end) });
+      while (
+        i < this.state.regionInfo["desc"].length &&
+        this.state.regionInfo["desc"][i] !== value
+      )
+        i += 1;
+      if (i < this.state.regionInfo["desc"].length) {
+        let region_chr = this.state.regionInfo["chr"][i];
+        let region_start = this.state.regionInfo["start"][i];
+        let region_end = this.state.regionInfo["end"][i];
+        this.setState({
+          region: region_chr.concat(":", region_start, "-", region_end),
+        });
       }
     }
   };
@@ -340,8 +363,8 @@ class HeaderForm extends Component {
     r_start = Math.round(r_start + shift);
     r_end = Math.round(r_end + shift);
     this.setState(
-      state => ({
-        region: region_col[0].concat(":", r_start, "-", r_end)
+      (state) => ({
+        region: region_col[0].concat(":", r_start, "-", r_end),
       }),
       () => this.handleGoButton()
     );
@@ -356,8 +379,8 @@ class HeaderForm extends Component {
     r_start = Math.max(0, Math.round(r_start - shift));
     r_end = Math.max(0, Math.round(r_end - shift));
     this.setState(
-      state => ({
-        region: region_col[0].concat(":", r_start, "-", r_end)
+      (state) => ({
+        region: region_col[0].concat(":", r_start, "-", r_end),
       }),
       () => this.handleGoButton()
     );
@@ -371,19 +394,19 @@ class HeaderForm extends Component {
     this.setState({ fileSizeAlert: true });
   };
 
-  setUploadInProgress = val => {
+  setUploadInProgress = (val) => {
     this.setState({ uploadInProgress: val });
   };
 
   setUpWebsocket = () => {
-    this.ws = new WebSocket(this.props.apiUrl.replace(/^http/, 'ws'));
-    this.ws.onmessage = message => {
+    this.ws = new WebSocket(this.props.apiUrl.replace(/^http/, "ws"));
+    this.ws.onmessage = (message) => {
       this.getMountedFilenames();
     };
-    this.ws.onclose = event => {
+    this.ws.onclose = (event) => {
       setTimeout(this.setUpWebsocket, 1000);
     };
-    this.ws.onerror = event => {
+    this.ws.onerror = (event) => {
       this.ws.close();
     };
   };
@@ -392,7 +415,9 @@ class HeaderForm extends Component {
     let errorDiv = null;
     if (this.state.error) {
       console.log("Header error: " + this.state.error);
-      const message = this.state.error.message ? this.state.error.message : this.state.error;
+      const message = this.state.error.message
+        ? this.state.error.message
+        : this.state.error;
       // We drop the error message into a div and leave most of the UI so the
       // user can potentially recover by picking something else.
       errorDiv = (
@@ -406,7 +431,7 @@ class HeaderForm extends Component {
       );
     }
 
-    let dataSourceDropdownOptions = DATA_SOURCES.map(ds => {
+    let dataSourceDropdownOptions = DATA_SOURCES.map((ds) => {
       return (
         <option value={ds.name} key={ds.name}>
           {ds.name}
@@ -414,13 +439,14 @@ class HeaderForm extends Component {
       );
     });
     dataSourceDropdownOptions.push(
-      <option value="syntheticExamples" key="syntheticExamples">
+      <option value={dataTypes.EXAMPLES} key="syntheticExamples">
         synthetic data examples
       </option>,
-      <option value="customFileUpload" key="customFileUpload">
+      <option value={dataTypes.FILE_UPLOAD} key="customFileUpload">
         custom (file upload)
       </option>,
-      <option value="customMounted" key="customMounted">
+      <option value={dataTypes.MOUNTED_FILES} key={dataTypes.MOUNTED_FILES}>
+        {" "}
         custom (mounted files)
       </option>
     );
@@ -428,9 +454,9 @@ class HeaderForm extends Component {
     const mountedFilesFlag = this.state.dataType === dataTypes.MOUNTED_FILES;
     const uploadFilesFlag = this.state.dataType === dataTypes.FILE_UPLOAD;
     const examplesFlag = this.state.dataType === dataTypes.EXAMPLES;
-    const bedRegionsFlag = this.state.bedSelect !== 'none';
-    const pathNamesFlag = this.state.xgSelect !== 'none';
-    
+    const bedRegionsFlag = this.state.bedSelect !== "none";
+    const pathNamesFlag = this.state.xgSelect !== "none";
+
     return (
       <div>
         {errorDiv}
@@ -447,14 +473,20 @@ class HeaderForm extends Component {
                 >
                   Data:
                 </Label>
-                <Input
+                <select
                   type="select"
+                  value={
+                    this.state.dataType === dataTypes.BUILT_IN
+                      ? this.state.name
+                      : this.state.dataType
+                  }
                   id="dataSourceSelect"
-                  className="custom-select mb-2 mr-sm-4 mb-sm-0"
+                  className="form-select
+                  mb-2 mr-sm-4 mb-sm-0"
                   onChange={this.handleDataSourceChange}
                 >
                   {dataSourceDropdownOptions}
-                </Input>
+                </select>
                 {mountedFilesFlag && (
                   <MountedDataFormRow
                     xgSelect={this.state.xgSelect}
@@ -509,7 +541,7 @@ class HeaderForm extends Component {
                 className="mt-3"
               >
                 <strong>File size too big! </strong>
-                You may only upload files with a maximum size of{' '}
+                You may only upload files with a maximum size of{" "}
                 {MAX_UPLOAD_SIZE_DESCRIPTION}.
               </Alert>
               {examplesFlag ? (
@@ -525,6 +557,7 @@ class HeaderForm extends Component {
                   handleGoRight={this.handleGoRight}
                   handleGoButton={this.handleGoButton}
                   uploadInProgress={this.state.uploadInProgress}
+                  getCurrentViewTarget={this.props.getCurrentViewTarget}
                 />
               )}
             </Col>
@@ -540,7 +573,8 @@ HeaderForm.propTypes = {
   dataOrigin: PropTypes.string.isRequired,
   setColorSetting: PropTypes.func.isRequired,
   setDataOrigin: PropTypes.func.isRequired,
-  setFetchParams: PropTypes.func.isRequired
+  setCurrentViewTarget: PropTypes.func.isRequired,
+  defaultViewTarget: PropTypes.any, // Header Form State, may be null if no params in URL. see Types.ts
 };
 
 export default HeaderForm;
diff --git a/src/components/TubeMapContainer.js b/src/components/TubeMapContainer.js
index 0307b63..592e4a0 100644
--- a/src/components/TubeMapContainer.js
+++ b/src/components/TubeMapContainer.js
@@ -1,15 +1,17 @@
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import TubeMap from './TubeMap';
-import { Container, Row, Alert } from 'reactstrap';
-import * as tubeMap from '../util/tubemap';
-import { dataOriginTypes } from '../enums';
-import { fetchAndParse } from '../fetchAndParse';
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { Container, Row, Alert } from "reactstrap";
+import isEqual from "react-fast-compare";
+
+import TubeMap from "./TubeMap";
+import * as tubeMap from "../util/tubemap";
+import { dataOriginTypes } from "../enums";
+import { fetchAndParse } from "../fetchAndParse";
 
 class TubeMapContainer extends Component {
   state = {
     isLoading: true,
-    error: null
+    error: null,
   };
 
   componentDidMount() {
@@ -22,19 +24,19 @@ class TubeMapContainer extends Component {
     // a loading state and immediately do another update, which then means we
     // have to mess around with deep comparison to see we don't need yet a
     // third update. Is there a way to let React keep track of the fact that we
-    // aren't up to date with the requested state yet? 
+    // aren't up to date with the requested state yet?
     if (this.props.dataOrigin !== prevProps.dataOrigin) {
       this.props.dataOrigin === dataOriginTypes.API
         ? this.getRemoteTubeMapData()
         : this.getExampleData();
     } else {
-      if (JSON.stringify(this.props.fetchParams) !== JSON.stringify(prevProps.fetchParams)) {
+      if (!isEqual(this.props.viewTarget, prevProps.viewTarget)) {
         // We need to compare the fetch parameters with stringification because
         // they will get swapped out for a different object all the time, and we
         // don't want to compare object identity. TODO: stringify isn't
         // guaranteed to be stable so we can still make extra requests.
         this.getRemoteTubeMapData();
-      } 
+      }
     }
   }
 
@@ -87,27 +89,27 @@ class TubeMapContainer extends Component {
     this.setState({ isLoading: true, error: null });
     try {
       const json = await fetchAndParse(`${this.props.apiUrl}/getChunkedData`, {
-        method: 'POST',
+        method: "POST",
         headers: {
-          'Content-Type': 'application/json'
+          "Content-Type": "application/json",
         },
-        body: JSON.stringify(this.props.fetchParams)
+        body: JSON.stringify(this.props.viewTarget),
       });
       if (json.graph === undefined) {
         // We did not get back a graph, even if we didn't get an error either.
-        const error = 'Fetching remote data returned error';
+        const error = "Fetching remote data returned error";
         this.setState({ error: error, isLoading: false });
       } else {
         const nodes = tubeMap.vgExtractNodes(json.graph);
         const tracks = tubeMap.vgExtractTracks(json.graph);
         const reads = tubeMap.vgExtractReads(nodes, tracks, json.gam);
-        const region = json.region; 
+        const region = json.region;
         this.setState({
           isLoading: false,
           nodes,
           tracks,
           reads,
-	  region
+          region,
         });
       }
     } catch (error) {
@@ -122,7 +124,7 @@ class TubeMapContainer extends Component {
     let tracks = [];
     let reads = [];
     let region = [];
-    const data = await import('../util/demo-data');
+    const data = await import("../util/demo-data");
     nodes = data.inputNodes;
     switch (this.props.dataOrigin) {
       case dataOriginTypes.EXAMPLE_1:
@@ -154,16 +156,16 @@ class TubeMapContainer extends Component {
         // Leave the data empty.
         break;
       default:
-        console.log('invalid example data origin type:', this.props.dataOrigin);
+        console.log("invalid example data origin type:", this.props.dataOrigin);
     }
 
-    this.setState({ isLoading: false, nodes, tracks, reads, region});
+    this.setState({ isLoading: false, nodes, tracks, reads, region });
   };
 
-  readsFromStringToArray = readsString => {
-    const lines = readsString.split('\n');
+  readsFromStringToArray = (readsString) => {
+    const lines = readsString.split("\n");
     const result = [];
-    lines.forEach(line => {
+    lines.forEach((line) => {
       if (line.length > 0) {
         result.push(JSON.parse(line));
       }
@@ -175,7 +177,7 @@ class TubeMapContainer extends Component {
 TubeMapContainer.propTypes = {
   apiUrl: PropTypes.string.isRequired,
   dataOrigin: PropTypes.oneOf(Object.values(dataOriginTypes)).isRequired,
-  fetchParams: PropTypes.object.isRequired 
+  viewTarget: PropTypes.object.isRequired,
 };
 
 export default TubeMapContainer;
diff --git a/src/config.json b/src/config.json
index 6798aa5..d0d14ec 100644
--- a/src/config.json
+++ b/src/config.json
@@ -5,24 +5,27 @@
       "name": "snp1kg-BRCA1",
       "xgFile": "snp1kg-BRCA1.vg.xg",
       "gamFile": "NA12878-BRCA1.sorted.gam",
-      "useMountedPath": false,
-      "defaultPosition": "17:1-100",
-      "bedFile": "snp1kg-BRCA1.bed"
+      "dataPath": "default",
+      "region": "17:1-100",
+      "bedFile": "snp1kg-BRCA1.bed",
+      "dataType": "built-in"
     },
     {
       "name": "vg \"small\" example",
       "xgFile": "x.vg.xg",
       "gbwtFile": "x.vg.gbwt",
-      "useMountedPath": true,
-      "defaultPosition": "x:1-100"
+      "dataPath": "mounted",
+      "dataType": "built-in",
+      "region": "x:1-100"
     },
     {
       "name": "cactus",
       "xgFile": "cactus.vg.xg",
       "gamFile": "cactus-NA12879.sorted.gam",
-      "useMountedPath": true,
+      "dataPath": "mounted",
       "bedFile": "cactus.bed",
-      "defaultPosition": "ref:1-100"
+      "region": "ref:1-100",
+      "dataType": "built-in"
     }
   ],
   "vgPath": "",
diff --git a/src/server.js b/src/server.js
index 5517239..42ac921 100644
--- a/src/server.js
+++ b/src/server.js
@@ -896,7 +896,7 @@ function getBedRegions(bedFile, dataPath) {
     throw new BadRequestError("BED file path not allowed: " + bedFile);
   }
   if(!fs.existsSync(bed_path)){
-    result.error = "BED file not found: " + bedFile;
+    throw new BadRequestError("BED file not found: " + bedFile);
   }
     
   let bed_info = {chr:[], start:[], end:[], desc:[], chunk:[]};


Test patch:
diff --git a/src/App.test.js b/src/App.test.js
index ab8bb67..ca435d8 100644
--- a/src/App.test.js
+++ b/src/App.test.js
@@ -3,6 +3,7 @@ import { render, screen, act } from '@testing-library/react';
 import '@testing-library/jest-dom/extend-expect';
 import userEvent from '@testing-library/user-event';
 import App from './App';
+// Tests functionality without server
 
 it('renders without crashing', () => {
   render(<App />);
diff --git a/src/end-to-end.test.js b/src/end-to-end.test.js
index 077b828..466a098 100644
--- a/src/end-to-end.test.js
+++ b/src/end-to-end.test.js
@@ -1,12 +1,13 @@
 // End to end tests that test the frontend against a backend over HTTP
 
-import server from './server'
-import React from 'react';
+import server from "./server";
+import React from "react";
 // testing-library provides a render() that auto-cleans-up from the global DOM.
-import { render, screen, act } from '@testing-library/react';
-import '@testing-library/jest-dom/extend-expect';
-import userEvent from '@testing-library/user-event';
-import App from './App';
+import { render, screen, act } from "@testing-library/react";
+import { setCopyCallback, writeToClipboard } from "./components/CopyLink";
+import "@testing-library/jest-dom/extend-expect";
+import userEvent from "@testing-library/user-event";
+import App from "./App";
 
 // This holds the running server for the duration of each test.
 let serverState = undefined;
@@ -14,17 +15,23 @@ let serverState = undefined;
 // This holds the root element of the app
 let root = undefined;
 
+// Mock clipboard (string)
+let fakeClipboard = undefined;
+
 // This needs to be called by global and per-scope beforeEach
 async function setUp() {
   // Start the server
   serverState = await server.start();
-  
+
+  setCopyCallback((value) => (fakeClipboard = value));
+
   // Create the application.
-  render(<App apiUrl={serverState.getApiUrl()} />)
+  render(<App apiUrl={serverState.getApiUrl()} />);
 }
 
 // This needs to be called by global and per-scope afterEach
 async function tearDown() {
+  setCopyCallback(writeToClipboard);
   try {
     // Shut down the server
     await serverState.close();
@@ -38,7 +45,7 @@ async function tearDown() {
 async function waitForLoadStart() {
   return new Promise((resolve, reject) => {
     function waitAround() {
-      let loader = document.getElementById('loader');
+      let loader = document.getElementById("loader");
       if (!loader) {
         setTimeout(waitAround, 100);
       } else {
@@ -46,14 +53,14 @@ async function waitForLoadStart() {
       }
     }
     waitAround();
-  })
+  });
 }
 
 // Wait for the loading throbber to disappear
 async function waitForLoadEnd() {
   return new Promise((resolve, reject) => {
     function waitAround() {
-      let loader = document.getElementById('loader');
+      let loader = document.getElementById("loader");
       if (loader) {
         setTimeout(waitAround, 100);
       } else {
@@ -61,33 +68,48 @@ async function waitForLoadEnd() {
       }
     }
     waitAround();
-  })
+  });
+}
+
+async function clickCopyLink() {
+  // Click copy link button to test clipboard
+  await act(async () => {
+    let copyButton = document.getElementById("copyLinkButton");
+    await userEvent.click(copyButton);
+  });
+}
+function clickGoButton() {
+  // Press go
+  act(() => {
+    let go = document.getElementById("goButton");
+    userEvent.click(go);
+  });
 }
 
 beforeEach(async () => {
   await setUp();
-})
+});
 
 afterEach(async () => {
   await tearDown();
-})
+});
 
 it("initially renders as loading", () => {
-  let loader = document.getElementById('loader');
+  let loader = document.getElementById("loader");
   expect(loader).toBeTruthy();
 });
 
-it('populates the available example dropdown', () => {
+it("populates the available example dropdown", () => {
   // Make sure the dropdown exists in the div
-  let dropdown = document.getElementById('dataSourceSelect');
+  let dropdown = document.getElementById("dataSourceSelect");
   expect(dropdown).toBeTruthy();
 
   // Make sure it has a particular example value
   let wantedEntry = undefined;
-  for (let item of dropdown.getElementsByTagName('option')) {
+  for (let item of dropdown.getElementsByTagName("option")) {
     // Note that we don't have innerText in React's jsdom:
     // https://github.com/jsdom/jsdom/issues/1245
-    if (item.textContent == 'snp1kg-BRCA1') {
+    if (item.textContent == "snp1kg-BRCA1") {
       // Scan for this particular option.
       wantedEntry = item;
     }
@@ -95,85 +117,131 @@ it('populates the available example dropdown', () => {
   expect(wantedEntry).toBeTruthy();
 });
 
-describe('When we wait for it to load', () => {
+describe("When we wait for it to load", () => {
   beforeEach(async () => {
     // Wait for the loader to go away the new way.
     // See https://jasmine.github.io/2.0/upgrading.html#section-9
     // Note that Jest imposes a 5000 ms timeout for being done.
     await waitForLoadEnd();
   });
-  
-  it('eventually stops rendering as loading', () => {
-    let loader = document.getElementById('loader');
+
+  it("eventually stops rendering as loading", () => {
+    let loader = document.getElementById("loader");
     expect(loader).toBeFalsy();
   });
 
-  it('does not reload if we click the go button without changing settings', () => {
-    let loader = document.getElementById('loader');
+  it("does not reload if we click the go button without changing settings", () => {
+    let loader = document.getElementById("loader");
     expect(loader).toBeFalsy();
 
     act(() => {
-      let go = document.getElementById('goButton');
+      let go = document.getElementById("goButton");
       userEvent.click(go);
     });
 
-    loader = document.getElementById('loader');
+    loader = document.getElementById("loader");
     expect(loader).toBeFalsy();
   });
-  
-  it('the regions from the BED files are loaded', () => {
-    let regionlist = document.getElementById('regionSelect');
+
+  it("the regions from the BED files are loaded", () => {
+    let regionlist = document.getElementById("regionSelect");
     expect(regionlist).toBeInTheDocument();
   });
-  
-  it('draws an SVG for synthetic data example 1', async () => {
+
+  it("draws an SVG for synthetic data example 1", async () => {
     await act(async () => {
-      let dropdown = document.getElementById('dataSourceSelect');
-      await userEvent.selectOptions(screen.getByLabelText(/Data/i), 'synthetic data examples');
+      let dropdown = document.getElementById("dataSourceSelect");
+      await userEvent.selectOptions(
+        screen.getByLabelText(/Data/i),
+        "synthetic data examples"
+      );
     });
-    
+
     await act(async () => {
-      let example1 = document.getElementById('example1');
-      console.log('Clicking button for example 1');
+      let example1 = document.getElementById("example1");
+      console.log("Clicking button for example 1");
       await userEvent.click(example1);
     });
-    
+
     // We're agnostic as to whether we will see a loader when rendering the
-    // example data. 
-    
+    // example data.
+
     await waitForLoadEnd();
-    
-    let svg = document.getElementById('svg');
+
+    let svg = document.getElementById("svg");
     expect(svg).toBeTruthy();
   });
 
-  
   it('draws the right SVG for vg "small"', async () => {
-  
     await act(async () => {
-      let dropdown = document.getElementById('dataSourceSelect');
-      let region = document.getElementById('region');
-      
-      await userEvent.selectOptions(screen.getByLabelText(/Data/i), 'vg "small" example');
+      let dropdown = document.getElementById("dataSourceSelect");
+      let region = document.getElementById("region");
+
+      await userEvent.selectOptions(
+        screen.getByLabelText(/Data/i),
+        'vg "small" example'
+      );
       await userEvent.clear(screen.getByLabelText(/Region/i));
-      await userEvent.type(screen.getByLabelText(/Region/i), 'node:1+10');
-      
-      console.log(dropdown.value); 
-      console.log(dropdown.outerHTML); 
+      await userEvent.type(screen.getByLabelText(/Region/i), "node:1+10");
+
+      console.log(dropdown.value);
+      console.log(dropdown.outerHTML);
       console.log(region.outerHTML);
-    
-      let go = document.getElementById('goButton');
-      console.log('Clicking button for small');
+
+      let go = document.getElementById("goButton");
+      console.log("Clicking button for small");
       await userEvent.click(go);
     });
-    
-    let loader = document.getElementById('loader');
+
+    let loader = document.getElementById("loader");
     expect(loader).toBeTruthy();
-    
+
     await waitForLoadEnd();
-  
-    let svg = document.getElementById('svg');
+
+    let svg = document.getElementById("svg");
     expect(svg).toBeTruthy();
-    expect(svg.getElementsByTagName('title').length).toEqual(65);
+    expect(svg.getElementsByTagName("title").length).toEqual(65);
+  });
+});
+
+it("produces correct link for view before & after go is pressed", async () => {
+  // First test that after pressing go, the link reflects the dat form
+  const expectedLinkBRCA1 =
+    "localhost?name=snp1kg-BRCA1&xgFile=snp1kg-BRCA1.vg.xg&gamFile=NA12878-BRCA1.sorted.gam&dataPath=default&region=17%3A1-100&bedFile=snp1kg-BRCA1.bed&dataType=built-in"
+  // Set up dropdown
+  await act(async () => {
+    let dropdown = document.getElementById("dataSourceSelect");
+    await userEvent.selectOptions(
+      screen.getByLabelText(/Data/i),
+      "snp1kg-BRCA1"
+    );
+  });
+  // Wait for server to load / avoid console yelling 
+  await waitForLoadEnd();
+
+  clickGoButton();
+
+  await clickCopyLink();
+  // Ensure link reflects our selected data
+  expect(fakeClipboard).toEqual(expectedLinkBRCA1);
+
+  // Set up dropdown
+  await act(async () => {
+    let dropdown = document.getElementById("dataSourceSelect");
+    await userEvent.selectOptions(screen.getByLabelText(/Data/i), "cactus");
   });
+  // Wait for server to load
+  await waitForLoadEnd();
+
+  await clickCopyLink();
+  // Make sure clipboard has not changed
+  expect(fakeClipboard).toEqual(expectedLinkBRCA1);
+  clickGoButton();
+  await waitForLoadEnd();
+  await clickCopyLink();
+
+  const expectedLinkCactus =
+    "localhost?name=cactus&region=ref%3A1-100&xgFile=cactus.vg.xg&gamFile=cactus-NA12879.sorted.gam&bedFile=cactus.bed&dataPath=mounted&dataType=built-in";
+  // Make sure link has changed after pressing go
+  expect(fakeClipboard).toEqual(expectedLinkCactus);
 });