diff --git a/README.md b/README.md
index 462c26e..408d760 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,8 @@
# LocalZoom
-This is a demonstration of loading GWAS results via the web browser, fetching only the data
-required for that region. It works with Tabixed GWAS data files in a variety of formats.
+This is a tool for creating interactive [LocusZoom.js](https://github.com/statgen/locuszoom/) GWAS plots via the web browser, without uploading sensitive data to a remote web server. It works with Tabix-indexed data files in a variety of formats, and supports adding companion tracks (such as BED files).
-A live [demonstration](https://statgen.github.io/localzoom/) is available.
+Try it for yourself at [https://statgen.github.io/localzoom/](https://statgen.github.io/localzoom/)
## Getting help
@@ -26,7 +25,7 @@ npm run serve
### Building for production
The production build is a minified, concatenated bundle suitable for distribution on a server.
-In order to use the Sentry error logging and Google Analytics feature, you will need to create a
+In order to use the Sentry error logging and Google Analytics features, you will need to create a
file named *.env.production.local* (ignored by git) with the following contents
(both values are optional if you don't want to use these features):
```dotenv
@@ -47,7 +46,7 @@ npm run deploy
When ready, verify the built app and push to production.
-### Lints and fixes files
+### Lint and fixes files
This project uses a style and syntax checker for code quality. The following command can help to
identify (and automatically fix) common issues.
```
diff --git a/package-lock.json b/package-lock.json
index fa8ee59..9836e52 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "localzoom",
- "version": "0.7.5",
+ "version": "0.8.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -1424,9 +1424,9 @@
"dev": true
},
"@vue/test-utils": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-1.1.3.tgz",
- "integrity": "sha512-BAY1Cwe9JpkJseimC295EW3YlAmgIJI9OPkg2FSP62+PHZooB0B+wceDi9TYyU57oqzL0yLbcP73JKFpKiLc9A==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-1.2.2.tgz",
+ "integrity": "sha512-P+yiAsszoy8z1TqXiVUnAZaJj0WGGz5fCxm4bOSI6Cpwy1+PNYwYxDv0ROAA/SUtOPppV+aD8tp/QWwxf8ROJw==",
"dev": true,
"requires": {
"dom-event-types": "^1.0.0",
@@ -2026,16 +2026,16 @@
"dev": true
},
"autoprefixer": {
- "version": "9.8.6",
- "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.6.tgz",
- "integrity": "sha512-XrvP4VVHdRBCdX1S3WXVD8+RyG9qeb1D5Sn1DeLiG2xfSpzellk5k54xbUERJ3M5DggQxes39UGOTP8CFrEGbg==",
+ "version": "9.8.8",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.8.tgz",
+ "integrity": "sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==",
"dev": true,
"requires": {
"browserslist": "^4.12.0",
"caniuse-lite": "^1.0.30001109",
- "colorette": "^1.2.1",
"normalize-range": "^0.1.2",
"num2fraction": "^1.2.2",
+ "picocolors": "^0.2.1",
"postcss": "^7.0.32",
"postcss-value-parser": "^4.1.0"
}
@@ -2850,16 +2850,16 @@
"dev": true
},
"chai": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.0.tgz",
- "integrity": "sha512-/BFd2J30EcOwmdOgXvVsmM48l0Br0nmZPlO0uOW4XKh6kpsUumRXBgPV+IlaqFaqr9cYbeoZAM1Npx0i4A+aiA==",
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz",
+ "integrity": "sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==",
"dev": true,
"requires": {
"assertion-error": "^1.1.0",
"check-error": "^1.0.2",
"deep-eql": "^3.0.1",
"get-func-name": "^2.0.0",
- "pathval": "^1.1.0",
+ "pathval": "^1.1.1",
"type-detect": "^4.0.5"
}
},
@@ -3241,9 +3241,9 @@
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"color-string": {
- "version": "1.5.4",
- "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.4.tgz",
- "integrity": "sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz",
+ "integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==",
"dev": true,
"requires": {
"color-name": "^1.0.0",
@@ -3384,9 +3384,9 @@
}
},
"config-chain": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.12.tgz",
- "integrity": "sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA==",
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
+ "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
"dev": true,
"requires": {
"ini": "^1.3.4",
@@ -3810,21 +3810,78 @@
"dev": true
},
"cssnano": {
- "version": "4.1.10",
- "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-4.1.10.tgz",
- "integrity": "sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ==",
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-4.1.11.tgz",
+ "integrity": "sha512-6gZm2htn7xIPJOHY824ERgj8cNPgPxyCSnkXc4v7YvNW+TdVfzgngHcEhy/8D11kUWRUMbke+tC+AUcUsnMz2g==",
"dev": true,
"requires": {
"cosmiconfig": "^5.0.0",
- "cssnano-preset-default": "^4.0.7",
+ "cssnano-preset-default": "^4.0.8",
"is-resolvable": "^1.0.0",
"postcss": "^7.0.0"
+ },
+ "dependencies": {
+ "cssnano-preset-default": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.8.tgz",
+ "integrity": "sha512-LdAyHuq+VRyeVREFmuxUZR1TXjQm8QQU/ktoo/x7bz+SdOge1YKc5eMN6pRW7YWBmyq59CqYba1dJ5cUukEjLQ==",
+ "dev": true,
+ "requires": {
+ "css-declaration-sorter": "^4.0.1",
+ "cssnano-util-raw-cache": "^4.0.1",
+ "postcss": "^7.0.0",
+ "postcss-calc": "^7.0.1",
+ "postcss-colormin": "^4.0.3",
+ "postcss-convert-values": "^4.0.1",
+ "postcss-discard-comments": "^4.0.2",
+ "postcss-discard-duplicates": "^4.0.2",
+ "postcss-discard-empty": "^4.0.1",
+ "postcss-discard-overridden": "^4.0.1",
+ "postcss-merge-longhand": "^4.0.11",
+ "postcss-merge-rules": "^4.0.3",
+ "postcss-minify-font-values": "^4.0.2",
+ "postcss-minify-gradients": "^4.0.2",
+ "postcss-minify-params": "^4.0.2",
+ "postcss-minify-selectors": "^4.0.2",
+ "postcss-normalize-charset": "^4.0.1",
+ "postcss-normalize-display-values": "^4.0.2",
+ "postcss-normalize-positions": "^4.0.2",
+ "postcss-normalize-repeat-style": "^4.0.2",
+ "postcss-normalize-string": "^4.0.2",
+ "postcss-normalize-timing-functions": "^4.0.2",
+ "postcss-normalize-unicode": "^4.0.1",
+ "postcss-normalize-url": "^4.0.1",
+ "postcss-normalize-whitespace": "^4.0.2",
+ "postcss-ordered-values": "^4.1.2",
+ "postcss-reduce-initial": "^4.0.3",
+ "postcss-reduce-transforms": "^4.0.2",
+ "postcss-svgo": "^4.0.3",
+ "postcss-unique-selectors": "^4.0.1"
+ }
+ },
+ "postcss-svgo": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-4.0.3.tgz",
+ "integrity": "sha512-NoRbrcMWTtUghzuKSoIm6XV+sJdvZ7GZSc3wdBN0W19FTtp2ko8NqLsgoh/m9CzNhU3KLPvQmjIwtaNFkaFTvw==",
+ "dev": true,
+ "requires": {
+ "postcss": "^7.0.0",
+ "postcss-value-parser": "^3.0.0",
+ "svgo": "^1.0.0"
+ }
+ },
+ "postcss-value-parser": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
+ "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==",
+ "dev": true
+ }
}
},
"cssnano-preset-default": {
- "version": "4.0.7",
- "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz",
- "integrity": "sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA==",
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.8.tgz",
+ "integrity": "sha512-LdAyHuq+VRyeVREFmuxUZR1TXjQm8QQU/ktoo/x7bz+SdOge1YKc5eMN6pRW7YWBmyq59CqYba1dJ5cUukEjLQ==",
"dev": true,
"requires": {
"css-declaration-sorter": "^4.0.1",
@@ -3855,7 +3912,7 @@
"postcss-ordered-values": "^4.1.2",
"postcss-reduce-initial": "^4.0.3",
"postcss-reduce-transforms": "^4.0.2",
- "postcss-svgo": "^4.0.2",
+ "postcss-svgo": "^4.0.3",
"postcss-unique-selectors": "^4.0.1"
}
},
@@ -6554,12 +6611,6 @@
"integrity": "sha1-wc56MWjIxmFAM6S194d/OyJfnDg=",
"dev": true
},
- "html-comment-regex": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.2.tgz",
- "integrity": "sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==",
- "dev": true
- },
"html-encoding-sniffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz",
@@ -7332,15 +7383,6 @@
"integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==",
"dev": true
},
- "is-svg": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-3.0.0.tgz",
- "integrity": "sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ==",
- "dev": true,
- "requires": {
- "html-comment-regex": "^1.1.0"
- }
- },
"is-symbol": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz",
@@ -7419,24 +7461,17 @@
"dev": true
},
"js-beautify": {
- "version": "1.13.5",
- "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.13.5.tgz",
- "integrity": "sha512-MsXlH6Z/BiRYSkSRW3clNDqDjSpiSNOiG8xYVUBXt4k0LnGvDhlTGOlHX1VFtAdoLmtwjxMG5qiWKy/g+Ipv5w==",
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.0.tgz",
+ "integrity": "sha512-yuck9KirNSCAwyNJbqW+BxJqJ0NLJ4PwBUzQQACl5O3qHMBXVkXb/rD0ilh/Lat/tn88zSZ+CAHOlk0DsY7GuQ==",
"dev": true,
"requires": {
"config-chain": "^1.1.12",
"editorconfig": "^0.15.3",
"glob": "^7.1.3",
- "mkdirp": "^1.0.4",
"nopt": "^5.0.0"
},
"dependencies": {
- "mkdirp": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
- "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
- "dev": true
- },
"nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@@ -7616,8 +7651,13 @@
}
},
"jszlib": {
- "version": "git+ssh://git@github.com/dasmoth/jszlib.git#4e562c7b88749f3e1e402737ddef5f96bf0906c6",
- "from": "jszlib@git+https://github.com/dasmoth/jszlib.git#4e562c7"
+ "version": "git+https://github.com/dasmoth/jszlib.git#4e562c7b88749f3e1e402737ddef5f96bf0906c6",
+ "from": "git+https://github.com/dasmoth/jszlib.git#4e562c7"
+ },
+ "just-clone": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/just-clone/-/just-clone-3.2.1.tgz",
+ "integrity": "sha512-PFotEVrrzAnwuWTUOFquDShWrHnUnhxNrVs1VFqkNfnoH3Sn5XUlDOePYn2Vv5cN8xV2y69jf8qEoQHm7eoLnw=="
},
"killable": {
"version": "1.0.1",
@@ -7803,10 +7843,14 @@
}
},
"locuszoom": {
- "version": "git+https://github.com/statgen/locuszoom.git#ea3894e8d06a040a0036844d83a222b577a3ca40",
- "from": "git+https://github.com/statgen/locuszoom.git#ea3894e",
+ "version": "git+https://github.com/statgen/locuszoom.git#773eb8770a7935ee503eb08a9fb33084680ceac0",
+ "from": "git+https://github.com/statgen/locuszoom.git#773eb87",
"requires": {
- "d3": "^5.16.0"
+ "d3": "^5.16.0",
+ "gwas-credible-sets": "^0.1.0",
+ "just-clone": "^3.2.1",
+ "tabix-reader": "^1.0.1",
+ "undercomplicate": "^0.1.1"
}
},
"lodash": {
@@ -9503,9 +9547,9 @@
"integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A="
},
"path-parse": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
- "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"path-to-regexp": {
@@ -9556,6 +9600,12 @@
"integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
"dev": true
},
+ "picocolors": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz",
+ "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==",
+ "dev": true
+ },
"picomatch": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
@@ -10312,12 +10362,11 @@
}
},
"postcss-svgo": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-4.0.2.tgz",
- "integrity": "sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-4.0.3.tgz",
+ "integrity": "sha512-NoRbrcMWTtUghzuKSoIm6XV+sJdvZ7GZSc3wdBN0W19FTtp2ko8NqLsgoh/m9CzNhU3KLPvQmjIwtaNFkaFTvw==",
"dev": true,
"requires": {
- "is-svg": "^3.0.0",
"postcss": "^7.0.0",
"postcss-value-parser": "^3.0.0",
"svgo": "^1.0.0"
@@ -10904,9 +10953,9 @@
}
},
"nth-check": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.0.tgz",
- "integrity": "sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz",
+ "integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==",
"dev": true,
"requires": {
"boolbase": "^1.0.0"
@@ -11178,9 +11227,9 @@
}
},
"rxjs": {
- "version": "6.6.6",
- "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.6.tgz",
- "integrity": "sha512-/oTwee4N4iWzAMAL9xdGKjkEHmIwupR3oXbQjCKywF1BeFohswF3vZdogbmEF6pZkOsXTzWkrZszrWpQTByYVg==",
+ "version": "6.6.7",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
+ "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==",
"requires": {
"tslib": "^1.9.0"
}
@@ -12298,6 +12347,7 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+ "dev": true,
"requires": {
"ansi-regex": "^4.1.0"
}
@@ -12405,13 +12455,13 @@
"integrity": "sha512-AtGAUP9yLamN8X6yJqWHZqDlTb2DcEyoBHlNVRrVjyLyshVUVnVi0Fwvl4Ag8n49yGH7YoRYgMemmy5L0pQ6lg==",
"requires": {
"eslint": "^5.16.0",
- "jszlib": "jszlib@git+https://github.com/dasmoth/jszlib.git#4e562c7"
+ "jszlib": "git+https://github.com/dasmoth/jszlib.git#4e562c7"
},
"dependencies": {
"acorn-jsx": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz",
- "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng=="
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="
},
"ajv": {
"version": "6.12.6",
@@ -12424,11 +12474,6 @@
"uri-js": "^4.2.2"
}
},
- "ansi-regex": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
- "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
- },
"chardet": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
@@ -12495,16 +12540,6 @@
"strip-json-comments": "^2.0.1",
"table": "^5.2.3",
"text-table": "^0.2.0"
- },
- "dependencies": {
- "strip-ansi": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
- "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
- "requires": {
- "ansi-regex": "^3.0.0"
- }
- }
}
},
"eslint-scope": {
@@ -12591,6 +12626,16 @@
"string-width": "^2.1.0",
"strip-ansi": "^5.1.0",
"through": "^2.3.6"
+ },
+ "dependencies": {
+ "strip-ansi": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+ "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+ "requires": {
+ "ansi-regex": "^4.1.0"
+ }
+ }
}
},
"json-schema-traverse": {
@@ -12631,6 +12676,21 @@
"is-fullwidth-code-point": "^2.0.0"
}
},
+ "strip-ansi": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+ "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+ "requires": {
+ "ansi-regex": "^3.0.0"
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+ "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+ }
+ }
+ },
"table": {
"version": "5.4.6",
"resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz",
@@ -12651,6 +12711,14 @@
"is-fullwidth-code-point": "^2.0.0",
"strip-ansi": "^5.1.0"
}
+ },
+ "strip-ansi": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+ "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+ "requires": {
+ "ansi-regex": "^4.1.0"
+ }
}
}
},
@@ -13236,6 +13304,30 @@
}
}
},
+ "undercomplicate": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/undercomplicate/-/undercomplicate-0.1.1.tgz",
+ "integrity": "sha512-F/rOQ2x3YgH5YcEU+xUKxbv1GGF1znTrBi7MRvqtcDgwubNQ/HGLZbgZh2mHpX0z9AM0T2Pg3zqPwKWYK+VJhQ==",
+ "requires": {
+ "@hapi/topo": "^5.1.0",
+ "just-clone": "^3.2.1"
+ },
+ "dependencies": {
+ "@hapi/hoek": {
+ "version": "9.2.1",
+ "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.1.tgz",
+ "integrity": "sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw=="
+ },
+ "@hapi/topo": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
+ "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
+ "requires": {
+ "@hapi/hoek": "^9.0.0"
+ }
+ }
+ }
+ },
"unicode-canonical-property-names-ecmascript": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz",
@@ -13478,9 +13570,9 @@
}
},
"url-parse": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz",
- "integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==",
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
+ "integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
"dev": true,
"requires": {
"querystringify": "^2.1.1",
@@ -13584,9 +13676,9 @@
"dev": true
},
"vue": {
- "version": "2.6.12",
- "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.12.tgz",
- "integrity": "sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg=="
+ "version": "2.6.14",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.14.tgz",
+ "integrity": "sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ=="
},
"vue-bootstrap-typeahead": {
"version": "0.2.6",
@@ -13659,9 +13751,9 @@
}
},
"vue-template-compiler": {
- "version": "2.6.12",
- "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.6.12.tgz",
- "integrity": "sha512-OzzZ52zS41YUbkCBfdXShQTe69j1gQDZ9HIX8miuC9C3rBCk9wIRjLiZZLrmX9V+Ftq/YEyv1JaVr5Y/hNtByg==",
+ "version": "2.6.14",
+ "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.6.14.tgz",
+ "integrity": "sha512-ODQS1SyMbjKoO1JBJZojSw6FE4qnh9rIpUZn2EUT86FKizx9uH5z6uXiIrm4/Nb/gwxTi/o17ZDEGWAXHvtC7g==",
"dev": true,
"requires": {
"de-indent": "^1.0.2",
diff --git a/package.json b/package.json
index 9897d0a..188ddad 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "localzoom",
- "version": "0.7.5",
+ "version": "0.8.0",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
@@ -20,12 +20,10 @@
"@sentry/browser": "^4.5.2",
"bootstrap": "^4.4.1",
"bootstrap-vue": "^2.21.2",
- "gwas-credible-sets": "^0.1.0",
- "locuszoom": "https://github.com/statgen/locuszoom#ea3894e",
+ "locuszoom": "git+https://github.com/statgen/locuszoom.git#773eb87",
"lodash": "^4.17.11",
- "tabix-reader": "^1.0.1",
- "tabulator-tables": "^4.1.4",
- "vue": "^2.6.10",
+ "tabulator-tables": "^4.9.0",
+ "vue": "^2.6.14",
"vue-bootstrap-typeahead": "^0.2.6"
},
"devDependencies": {
@@ -34,12 +32,12 @@
"@vue/cli-plugin-unit-mocha": "^3.0.0",
"@vue/cli-service": "^3.0.0",
"@vue/eslint-config-airbnb": "^4.0.0",
- "@vue/test-utils": "^1.0.0-beta.20",
- "autoprefixer": "^9.8.4",
- "chai": "^4.3.0",
+ "@vue/test-utils": "^1.2.2",
+ "autoprefixer": "^9.8.8",
+ "chai": "^4.3.4",
"node-sass": "^4.9.0",
"sass-loader": "^7.0.1",
"source-map-loader": "^0.2.4",
- "vue-template-compiler": "^2.5.17"
+ "vue-template-compiler": "^2.6.14"
}
}
diff --git a/public/index.html b/public/index.html
index 10630dd..e2de21a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -17,7 +17,12 @@
}
-
+
<% if (process.env.NODE_ENV === 'production' && process.env.VUE_APP_GOOGLE_ANALYTICS_KEY) { %>
@@ -133,25 +122,13 @@ export default {
v-if="!batch_mode_active"
class="col-sm-6">
@@ -173,7 +156,7 @@ export default {
@@ -184,9 +167,9 @@ export default {
title="Export">
+ @requested-data="subscribeToData"/>
@@ -199,7 +182,7 @@ export default {
.placeholder-plot {
width: 100%;
height: 500px;
- border-style: dashed;
+ border-style: dotted;
}
.scroll-extra {
overflow-x: scroll;
diff --git a/src/components/RegionPicker.vue b/src/components/RegionPicker.vue
index f7edaaa..f911294 100644
--- a/src/components/RegionPicker.vue
+++ b/src/components/RegionPicker.vue
@@ -13,10 +13,10 @@
+
+
+
+
diff --git a/src/gwas/parser_utils.js b/src/gwas/parser_utils.js
deleted file mode 100644
index 68051c9..0000000
--- a/src/gwas/parser_utils.js
+++ /dev/null
@@ -1,128 +0,0 @@
-/**
- * Constant values used by GWAS parser
- */
-
-const MISSING_VALUES = new Set(['', '.', 'NA', 'N/A', 'n/a', 'nan', '-nan', 'NaN', '-NaN', 'null', 'NULL', 'None', null]);
-const REGEX_MARKER = /^(?:chr)?([a-zA-Z0-9]+?)[_:-](\d+)[_:|-]?(\w+)?[/_:|-]?([^_]+)?_?(.*)?/;
-const REGEX_PVAL = /([\d.-]+)([\sxeE]*)([0-9-]*)/;
-
-
-/**
- * Utility helper that checks for the presence of a numeric value (incl 0),
- * eg "has column specified"
- * @param num
- * @returns {boolean}
- */
-function has(num) {
- return Number.isInteger(num);
-}
-
-/**
- * Convert all missing values to a standardized input form
- * Useful for columns like pvalue, where a missing value explicitly allowed
- */
-function missingToNull(values, nulls = MISSING_VALUES, placeholder = null) {
- // TODO Make this operate on a single value; cache for efficiency?
- return values.map((v) => (nulls.has(v) ? placeholder : v));
-}
-
-/**
- * Parse a single marker, cleaning up values as necessary
- * @param {String} value
- * @param {boolean} test If called in testing mode, do not throw an exception
- * @returns {[string, number, string|null, string|null] | null} chr, pos, ref, alt (if match found)
- */
-function parseMarker(value, test = false) {
- const match = value.match(REGEX_MARKER);
- if (match) {
- return match.slice(1);
- }
- if (!test) {
- throw new Error('Could not understand marker format. Must be of format chr:pos or chr:pos_ref/alt');
- } else {
- return null;
- }
-}
-
-/**
- * Parse (and validate) a given number, and return the -log10 pvalue.
- * @param value
- * @param {boolean} is_neg_log
- * @returns {number||null} The -log10 pvalue
- */
-function parsePvalToLog(value, is_neg_log = false) {
- if (value === null) {
- return value;
- }
- const val = +value;
- if (is_neg_log) { // Take as is
- return val;
- }
- // Regular pvalue: validate and convert
- if (val < 0 || val > 1) {
- throw new Error('p value is not in the allowed range');
- }
- // 0-values are explicitly allowed and will convert to infinity by design, as they often
- // indicate underflow errors in the input data.
- if (val === 0) {
- // Determine whether underflow is due to the source data, or value conversion
- if (value === '0') {
- // The source data is bad, so insert an obvious placeholder value
- return Infinity;
- }
- // h/t @welchr: aggressively turn the underflowing string value into -log10 via regex
- // Only do this if absolutely necessary, because it is a performance hit
-
- let [, base, , exponent] = value.match(REGEX_PVAL);
- base = +base;
-
- if (exponent !== '') {
- exponent = +exponent;
- } else {
- exponent = 0;
- }
- if (base === 0) {
- return Infinity;
- }
- return -(Math.log10(+base) + +exponent);
- }
- return -Math.log10(val);
-}
-
-function parseAlleleFrequency({ freq, allele_count, n_samples, is_alt_effect = true }) {
- if (freq !== undefined && allele_count !== undefined) {
- throw new Error('Frequency and allele count options are mutually exclusive');
- }
-
- let result;
- if (freq === undefined && (MISSING_VALUES.has(allele_count) || MISSING_VALUES.has(n_samples))) {
- // Allele count parsing
- return null;
- }
- if (freq === undefined && allele_count !== undefined) {
- result = +allele_count / +n_samples / 2;
- } else if (MISSING_VALUES.has(freq)) { // Frequency-based parsing
- return null;
- } else {
- result = +freq;
- }
-
- // No matter how the frequency is specified, this stuff is always done
- if (result < 0 || result > 1) {
- throw new Error('Allele frequency is not in the allowed range');
- }
- if (!is_alt_effect) { // Orient the frequency to the alt allele
- return 1 - result;
- }
- return result;
-}
-
-export {
- MISSING_VALUES, REGEX_MARKER,
- missingToNull as _missingToNull,
- has,
- // Exports for unit testing
- parseAlleleFrequency,
- parseMarker,
- parsePvalToLog,
-};
diff --git a/src/gwas/parsers.js b/src/gwas/parsers.js
deleted file mode 100644
index 3b97bd1..0000000
--- a/src/gwas/parsers.js
+++ /dev/null
@@ -1,184 +0,0 @@
-import {
- MISSING_VALUES,
- has,
- parseAlleleFrequency,
- parseMarker,
- parsePvalToLog,
-} from './parser_utils';
-
-
-/**
- * Specify how to parse a GWAS file, given certain column information.
- * Outputs an object with fields in portal API format.
- * @param [marker_col]
- * @param [chrom_col]
- * @param [pos_col]
- * @param [ref_col]
- * @param [alt_col]
- * @param [rsid_col]
- * @param pvalue_col
- * @param [beta_col]
- * @param [stderr_beta_col]
- * @param [allele_freq_col]
- * @param [allele_count_col]
- * @param [n_samples_col]
- * @param [is_alt_effect=true]
- * @param [is_neg_log_pvalue=false]
- * @param [delimiter='\t']
- * @return {function(*): {chromosome: *, position: number, ref_allele: *,
- * log_pvalue: number, variant: string}}
- */
-function makeParser(
- {
- // Required fields
- marker_col, // position
- chrom_col,
- pos_col,
- ref_col,
- alt_col,
- rsid_col,
- pvalue_col, // pvalue
- // Optional fields
- beta_col,
- stderr_beta_col,
- allele_freq_col, // Frequency: given directly, OR in terms of counts
- allele_count_col,
- n_samples_col,
- is_alt_effect = true, // whether effect allele is oriented towards alt
- is_neg_log_pvalue = false,
- delimiter = '\t',
- } = {},
-) {
- // Column IDs should be 1-indexed (human friendly)
- if (has(marker_col) && has(chrom_col) && has(pos_col)) {
- throw new Error('Must specify either marker OR chr + pos');
- }
- if (!(has(marker_col) || (has(chrom_col) && has(pos_col)))) {
- throw new Error('Must specify how to locate marker');
- }
-
- if (has(allele_count_col) && has(allele_freq_col)) {
- throw new Error('Allele count and frequency options are mutually exclusive');
- }
- if (has(allele_count_col) && !has(n_samples_col)) {
- throw new Error('To calculate allele frequency from counts, you must also provide n_samples');
- }
-
-
- return (line) => {
- const fields = line.split(delimiter);
- let chr;
- let pos;
- let ref;
- let alt;
- let rsid = null;
-
- let freq;
- let beta = null;
- let stderr_beta = null;
- let alt_allele_freq = null;
- let allele_count;
- let n_samples;
-
- if (has(marker_col)) {
- [chr, pos, ref, alt] = parseMarker(fields[marker_col - 1], false);
- } else if (has(chrom_col) && has(pos_col)) {
- chr = fields[chrom_col - 1].replace(/^chr/g, '');
- pos = fields[pos_col - 1];
- } else {
- throw new Error('Must specify all fields required to identify the variant');
- }
-
- chr = chr.toUpperCase();
- if (chr.startsWith('RS')) {
- throw new Error(`Invalid chromosome specified: value "${chr}" is an rsID`);
- }
-
- if (has(ref_col)) {
- ref = fields[ref_col - 1];
- }
-
- if (has(alt_col)) {
- alt = fields[alt_col - 1];
- }
-
- if (has(rsid_col)) {
- rsid = fields[rsid_col - 1];
- }
-
- if (MISSING_VALUES.has(ref)) {
- ref = null;
- }
- if (MISSING_VALUES.has(alt)) {
- alt = null;
- }
-
- if (MISSING_VALUES.has(rsid)) {
- rsid = null;
- } else if (rsid) {
- rsid = rsid.toLowerCase();
- if (!rsid.startsWith('rs')) {
- rsid = `rs${rsid}`;
- }
- }
-
- const log_pval = parsePvalToLog(fields[pvalue_col - 1], is_neg_log_pvalue);
- ref = ref || null;
- alt = alt || null;
-
- if (has(allele_freq_col)) {
- freq = fields[allele_freq_col - 1];
- }
- if (has(allele_count_col)) {
- allele_count = fields[allele_count_col - 1];
- n_samples = fields[n_samples_col - 1];
- }
-
- if (has(beta_col)) {
- beta = fields[beta_col - 1];
- beta = MISSING_VALUES.has(beta) ? null : (+beta);
- }
-
- if (has(stderr_beta_col)) {
- stderr_beta = fields[stderr_beta_col - 1];
- stderr_beta = MISSING_VALUES.has(stderr_beta) ? null : (+stderr_beta);
- }
-
- if (allele_freq_col || allele_count_col) {
- alt_allele_freq = parseAlleleFrequency({
- freq,
- allele_count,
- n_samples,
- is_alt_effect,
- });
- }
- const ref_alt = (ref && alt) ? `_${ref}/${alt}` : '';
- return {
- chromosome: chr,
- position: +pos,
- ref_allele: ref ? ref.toUpperCase() : null,
- alt_allele: alt ? alt.toUpperCase() : null,
- variant: `${chr}:${pos}${ref_alt}`,
- rsid,
- log_pvalue: log_pval,
- beta,
- stderr_beta,
- alt_allele_freq,
- };
- };
-}
-
-// Preconfigured parser with defaults for a standard file format
-const standard_gwas_parser = makeParser({
- chrom_col: 1,
- pos_col: 2,
- ref_col: 3,
- alt_col: 4,
- pvalue_col: 5,
- beta_col: 6,
- stderr_beta_col: 7,
- is_neg_log_pvalue: true,
- delimiter: '\t',
-});
-
-export { makeParser, standard_gwas_parser };
diff --git a/src/gwas/sniffers.js b/src/gwas/sniffers.js
deleted file mode 100644
index 57b5868..0000000
--- a/src/gwas/sniffers.js
+++ /dev/null
@@ -1,257 +0,0 @@
-/**
- * Sniffers: auto detect file format and parsing options for GWAS files.
- * TODO: Reorganize code base and move more logic here
- */
-
-import { MISSING_VALUES, parsePvalToLog, parseMarker, _missingToNull } from './parser_utils';
-
-function isNumeric(val) {
- // Check whether an unparsed string is a numeric value"
- if (MISSING_VALUES.has(val)) {
- return true;
- }
- return !Number.isNaN(+val);
-}
-
-function isHeader(row, { comment_char = '#', delimiter = '\t' } = {}) {
- // This assumes two basic rules: the line is not a comment, and gwas data is more likely
- // to be numeric than headers
- return row.startsWith(comment_char) || row.split(delimiter).every((item) => !isNumeric(item));
-}
-
-/**
- * Compute the levenshtein distance between two strings. Useful for finding the single best column
- * name that matches a given rule.
- * @private
- */
-function levenshtein(a, b) { // https://github.com/trekhleb/javascript-algorithms
- // Create empty edit distance matrix for all possible modifications of
- // substrings of a to substrings of b.
- const distanceMatrix = Array(b.length + 1)
- .fill(null)
- .map(() => Array(a.length + 1)
- .fill(null));
-
- // Fill the first row of the matrix.
- // If this is first row then we're transforming empty string to a.
- // In this case the number of transformations equals to size of a substring.
- for (let i = 0; i <= a.length; i += 1) {
- distanceMatrix[0][i] = i;
- }
-
- // Fill the first column of the matrix.
- // If this is first column then we're transforming empty string to b.
- // In this case the number of transformations equals to size of b substring.
- for (let j = 0; j <= b.length; j += 1) {
- distanceMatrix[j][0] = j;
- }
-
- for (let j = 1; j <= b.length; j += 1) {
- for (let i = 1; i <= a.length; i += 1) {
- const indicator = a[i - 1] === b[j - 1] ? 0 : 1;
- distanceMatrix[j][i] = Math.min(
- distanceMatrix[j][i - 1] + 1, // deletion
- distanceMatrix[j - 1][i] + 1, // insertion
- distanceMatrix[j - 1][i - 1] + indicator, // substitution
- );
- }
- }
- return distanceMatrix[b.length][a.length];
-}
-
-/**
- * Return the index of the first column name that meets acceptance criteria
- * @param {String[]} column_synonyms
- * @param {String[]}header_names
- * @param {Number} threshold Tolerance for fuzzy matching (# edits)
- * @return {Number|null} Index of the best matching column, or null if no match possible
- */
-function findColumn(column_synonyms, header_names, threshold = 2) {
- // Find the column name that best matches
- let best_score = threshold + 1;
- let best_match = null;
- for (let i = 0; i < header_names.length; i++) {
- const header = header_names[i];
- if (header === null) {
- // If header is empty, don't consider it for a match
- // Nulling a header provides a way to exclude something from future searching
- continue; // eslint-disable-line no-continue
- }
- const score = Math.min(...column_synonyms.map((s) => levenshtein(header, s)));
- if (score < best_score) {
- best_score = score;
- best_match = i;
- }
- }
- return best_match;
-}
-
-
-/**
- * Return parser configuration for pvalues
- *
- * Returns 1-based column indices, for compatibility with parsers
- * @param header_row
- * @param data_rows
- * @returns {{}}
- */
-function getPvalColumn(header_row, data_rows) {
- // TODO: Allow overrides
- const LOGPVALUE_FIELDS = ['neg_log_pvalue', 'log_pvalue', 'log_pval', 'logpvalue'];
- const PVALUE_FIELDS = ['pvalue', 'p.value', 'p-value', 'pval', 'p_score', 'p'];
-
- let ps;
- const validateP = (col, data, is_log) => { // Validate pvalues
- const cleaned_vals = _missingToNull(data.map((row) => row[col]));
- try {
- ps = cleaned_vals.map((p) => parsePvalToLog(p, is_log));
- } catch (e) {
- return false;
- }
- return ps.every((val) => !Number.isNaN(val));
- };
-
- const log_p_col = findColumn(LOGPVALUE_FIELDS, header_row);
- const p_col = findColumn(PVALUE_FIELDS, header_row);
-
- if (log_p_col !== null && validateP(log_p_col, data_rows, true)) {
- return {
- pvalue_col: log_p_col + 1,
- is_neg_log_pvalue: true,
- };
- }
- if (p_col && validateP(p_col, data_rows, false)) {
- return {
- pvalue_col: p_col + 1,
- is_neg_log_pvalue: false,
- };
- }
- // Could not auto-determine an appropriate pvalue column
- return null;
-}
-
-
-function getChromPosRefAltColumns(header_row, data_rows) {
- // Returns 1-based column indices, for compatibility with parsers
- // Get from either a marker, or 4 separate columns
- const MARKER_FIELDS = ['snpid', 'marker', 'markerid', 'snpmarker', 'chr:position'];
- const CHR_FIELDS = ['chrom', 'chr'];
- const POS_FIELDS = ['position', 'pos', 'begin', 'beg', 'bp', 'end', 'ps'];
-
- // TODO: How to handle orienting ref vs effect?
- // Order matters: consider ambiguous field names for ref before alt
- const REF_FIELDS = ['A1', 'ref', 'reference', 'allele0', 'allele1'];
- const ALT_FIELDS = ['A2', 'alt', 'alternate', 'allele1', 'allele2'];
-
- const first_row = data_rows[0];
- let marker_col = findColumn(MARKER_FIELDS, header_row);
- if (marker_col !== null && parseMarker(first_row[marker_col], true)) {
- marker_col += 1;
- return { marker_col };
- }
-
- // If single columns were incomplete, attempt to auto detect 4 separate columns. All 4 must
- // be found for this function to report a match.
- const headers_marked = header_row.slice();
- const find = [
- ['chrom_col', CHR_FIELDS],
- ['pos_col', POS_FIELDS],
- ['ref_col', REF_FIELDS],
- ['alt_col', ALT_FIELDS],
- ];
- const config = {};
- for (let i = 0; i < find.length; i++) {
- const [col_name, choices] = find[i];
- const col = findColumn(choices, headers_marked);
- if (col === null) {
- return null;
- }
- config[col_name] = col + 1;
- // Once a column has been assigned, remove it from consideration
- headers_marked[col] = null;
- }
- return config;
-}
-
-/**
- * Identify which columns contain effect size (beta) and stderr of the effect size
- * @param {String[]} header_names
- * @param {Array[]} data_rows
- * @returns {{}}
- */
-function getEffectSizeColumns(header_names, data_rows) {
- const BETA_FIELDS = ['beta', 'effect_size', 'alt_effsize', 'effect'];
- const STDERR_BETA_FIELDS = ['stderr_beta', 'stderr', 'sebeta', 'effect_size_sd', 'se'];
-
- function validate_numeric(col, data) {
- const cleaned_vals = _missingToNull(data.map((row) => row[col]));
- let nums;
- try {
- nums = cleaned_vals.filter((val) => val !== null).map((val) => +val);
- } catch (e) {
- return false;
- }
- return nums.every((val) => !Number.isNaN(val));
- }
-
- const beta_col = findColumn(BETA_FIELDS, header_names, 0);
- const stderr_beta_col = findColumn(STDERR_BETA_FIELDS, header_names, 0);
-
- const ret = {};
- if (beta_col !== null && validate_numeric(beta_col, data_rows)) {
- ret.beta_col = beta_col + 1;
- }
- if (stderr_beta_col !== null && validate_numeric(stderr_beta_col, data_rows)) {
- ret.stderr_beta_col = stderr_beta_col + 1;
- }
- return ret;
-}
-/**
- * Attempt to guess the correct parser settings given a set of header rows and a set of data rows
- * @param {String[]} header_row
- * @param {String[][]} data_rows
- * @param {int} offset Used to convert between 0 and 1-based indexing.
- */
-function guessGWAS(header_row, data_rows, offset = 1) {
- // 1. Find a specific set of info: marker OR chr/pos/ref/alt ; pvalue OR log_pvalue
- // 2. Validate that we will be able to parse the required info: fields present and make sense
- // 3. Based on the field names selected, attempt to infer meaning: verify whether log is used,
- // and check ref/alt vs effect/noneffect
- // 4. Return a parser config object if all tests pass, OR null.
-
- // Normalize case and remove leading comment marks from line for easier comparison
- const headers = header_row.map((item) => (item ? item.toLowerCase() : item));
- headers[0].replace('/^#+/', '');
- // Lists of fields are drawn from Encore (AssocResultReader) and Pheweb (conf_utils.py)
- const pval_config = getPvalColumn(headers, data_rows, offset);
- if (!pval_config) {
- return null;
- }
- headers[pval_config.pvalue_col - 1] = null; // Remove this column from consideration
- const position_config = getChromPosRefAltColumns(headers, data_rows);
- if (!position_config) {
- return null;
- }
- // Remove the position config from consideration for future matches
- Object.keys(position_config).forEach((key) => {
- headers[position_config[key]] = null;
- });
-
- const beta_config = getEffectSizeColumns(headers, data_rows);
-
- if (pval_config && position_config) {
- return Object.assign({}, pval_config, position_config, beta_config || {});
- }
- return null;
-}
-
-export {
- // Public members
- guessGWAS,
- isNumeric,
- isHeader,
- // Symbols exported for testing
- getPvalColumn as _getPvalColumn,
- findColumn as _findColumn,
- levenshtein as _levenshtein,
-};
diff --git a/src/util/constants.js b/src/util/constants.js
index 368385e..edb6268 100644
--- a/src/util/constants.js
+++ b/src/util/constants.js
@@ -4,8 +4,10 @@ const LD_SERVER_BASE_URL = 'https://portaldev.sph.umich.edu/ld/';
const REGEX_POSITION = /^(?:chr)?(\w+)\s*:\s*(\d+)$/;
const REGEX_REGION = /^(?:chr)?(\w+)\s*:\s*(\d+)-(\d+)$/;
+const DEFAULT_REGION_SIZE = 500000;
export {
+ DEFAULT_REGION_SIZE,
REGEX_REGION, REGEX_POSITION,
PORTAL_API_BASE_URL, LD_SERVER_BASE_URL,
};
diff --git a/src/util/entity-helpers.js b/src/util/entity-helpers.js
index ef71ba8..5bbd5fb 100644
--- a/src/util/entity-helpers.js
+++ b/src/util/entity-helpers.js
@@ -3,8 +3,7 @@
* that appear in a wide range of genetic datasets
*/
-import { REGEX_POSITION, REGEX_REGION } from './constants';
-
+import { DEFAULT_REGION_SIZE, REGEX_POSITION, REGEX_REGION } from './constants';
/**
* Parse a single position and return a range, based on the region size
@@ -12,20 +11,32 @@ import { REGEX_POSITION, REGEX_REGION } from './constants';
* @param {Number} region_size
* @returns {Number[]} [start, end]
*/
-function positionToRange(position, { region_size = 500000 }) {
+function positionToMidRange(position, { region_size = DEFAULT_REGION_SIZE } = {}) {
const bounds = Math.floor(region_size / 2);
return [Math.max(position - bounds, 1), position + bounds];
}
+/**
+ * Parse a single position and return a range with this position near the start (with some padding)
+ * @param position
+ * @param DEFAULT_REGION_SIZE
+ * @returns {Number[]} [start, end]
+ */
+function positionToStartRange(position, { region_size = DEFAULT_REGION_SIZE, padding = 0.025 } = {}) {
+ const start = Math.max(1, position - region_size * padding);
+ const end = start + region_size * (1 + padding);
+ return [start, end];
+}
+
/**
* Parse a variant/region identifier, in the format chr:start-end or chr:center. Return start
* and end positions.
*
* @param {string} spec
- * @param {number} [region_size=500000] If specified, enforces a max region size.
+ * @param {number} [region_size=DEFAULT_REGION_SIZE] If specified, enforces a max region size.
* @returns {[string, number, number]} [chr, start, end]
*/
-function parseRegion(spec, { region_size = 500000 }) {
+function parseRegion(spec, { region_size = DEFAULT_REGION_SIZE }) {
const range_match = spec.match(REGEX_REGION);
const single_match = spec.match(REGEX_POSITION);
let chr;
@@ -40,7 +51,7 @@ function parseRegion(spec, { region_size = 500000 }) {
let pos;
[chr, pos] = single_match.slice(1);
pos = +pos;
- [start, end] = positionToRange(pos, { region_size });
+ [start, end] = positionToMidRange(pos, { region_size });
} else {
throw new Error(`Could not parse the specified range: ${spec}`);
}
@@ -55,4 +66,4 @@ function parseRegion(spec, { region_size = 500000 }) {
return { chr, start, end };
}
-export { parseRegion, positionToRange };
+export { parseRegion, positionToMidRange, positionToStartRange };
diff --git a/src/util/lz-helpers.js b/src/util/lz-helpers.js
index 187de6e..c3e0d37 100644
--- a/src/util/lz-helpers.js
+++ b/src/util/lz-helpers.js
@@ -1,42 +1,25 @@
import LocusZoom from 'locuszoom';
-import { AssociationLZ } from 'locuszoom/esm/data/adapters';
import credibleSets from 'locuszoom/esm/ext/lz-credible-sets';
+import tabixSource from 'locuszoom/esm/ext/lz-tabix-source';
+import intervalTracks from 'locuszoom/esm/ext/lz-intervals-track';
+import lzParsers from 'locuszoom/esm/ext/lz-parsers';
import { PORTAL_API_BASE_URL, LD_SERVER_BASE_URL } from './constants';
-import { makeParser } from '../gwas/parsers';
LocusZoom.use(credibleSets);
+LocusZoom.use(tabixSource);
+LocusZoom.use(intervalTracks);
+LocusZoom.use(lzParsers);
-const stateUrlMapping = Object.freeze({ chr: 'chrom', start: 'start', end: 'end' });
+const stateUrlMapping = Object.freeze({ chr: 'chrom', start: 'start', end: 'end', ldrefvar: 'ld_variant' });
-class TabixAssociationLZ extends AssociationLZ {
- parseInit(init) {
- this.params = init.params; // Used to create a parser
- this.parser = makeParser(this.params);
- this.reader = init.tabix_reader;
- }
-
- getCacheKey(state, chain, fields) {
- return [state.chr, state.start, state.end].join('_');
- }
-
- fetchRequest(state, chain, fields) {
- const self = this;
- return new Promise((resolve, reject) => {
- self.reader.fetch(state.chr, state.start, state.end, (data, err) => {
- if (err) {
- reject(new Error('Could not read requested region. This may indicate an error with the .tbi index.'));
- }
- resolve(data);
- });
- });
- }
-
- normalizeResponse(data) {
+const TabixUrlSource = LocusZoom.Adapters.get('TabixUrlSource');
+class TabixAssociationLZ extends TabixUrlSource {
+ _annotateRecords(records) {
// Some GWAS files will include variant rows, even if no pvalue can be calculated.
// Eg, EPACTS fills in "NA" for pvalue in this case. These rows are not useful for a
// scatter plot, and this data source should ignore them.
- return data.map(this.parser).filter((item) => !Number.isNaN(item.log_pvalue));
+ return records.filter((item) => !Number.isNaN(item['log_pvalue']));
}
}
@@ -49,41 +32,41 @@ LocusZoom.Adapters.add('TabixAssociationLZ', TabixAssociationLZ);
* @return {string}
*/
function sourceName(display_name) {
+ // FIXME: incorporate datatype when generating source name; fix various caller locations
return display_name.replace(/[^A-Za-z0-9_]/g, '_');
}
/**
* Create customized panel layout(s) for a single association study, with functionality from all
* of the features selected.
- * @param {string} source_label
+ * @param {string} track_id Internal track ID, expected to be unique across the entire plot.
+ * @param {string} display_name
* @param annotations
- * @param build
* @return {*[]}
*/
-function createStudyLayout(
- source_label,
- annotations = { credible_sets: false, gwas_catalog: true },
- build,
+function createGwasStudyLayout(
+ track_id,
+ display_name,
+ annotations = { has_credible_sets: false, has_gwas_catalog: true },
) {
const new_panels = [];
- const source_name = sourceName(source_label);
// Other namespaces won't be overridden; they will be reused as is.
const namespace = {
- assoc: `assoc_${source_name}`,
- credset: `credset_${source_name}`,
+ assoc: `assoc_${track_id}`,
+ credset: `credset_${track_id}`,
catalog: 'catalog',
};
const assoc_panel = LocusZoom.Layouts.get('panel', 'association', {
- id: `association_${source_name}`,
- title: { text: source_label },
+ id: `association_${track_id}`,
+ title: { text: display_name },
height: 275,
namespace,
});
const assoc_layer = assoc_panel.data_layers[2]; // layer 1 = recomb rate
assoc_layer.label = {
- text: '{{#if {{namespace[assoc]}}rsid}}{{{{namespace[assoc]}}rsid}}{{#else}}{{{{namespace[assoc]}}variant}}{{/if}}',
+ text: '{{#if assoc:rsid}}{{assoc:rsid}}{{#else}}{{assoc:variant}}{{/if}}',
spacing: 12,
lines: { style: { 'stroke-width': '2px', stroke: '#333333', 'stroke-dasharray': '2px 2px' } },
filters: [
@@ -94,10 +77,10 @@ function createStudyLayout(
assoc_layer.tooltip = LocusZoom.Layouts.get('tooltip', 'standard_association_with_label', { namespace });
const assoc_tooltip = assoc_layer.tooltip;
- assoc_tooltip.html += '{{#if {{namespace[assoc]}}beta|is_numeric}}
β: {{{{namespace[assoc]}}beta|scinotation|htmlescape}}{{/if}}';
- assoc_tooltip.html += '{{#if {{namespace[assoc]}}stderr_beta|is_numeric}}
SE (β): {{{{namespace[assoc]}}stderr_beta|scinotation|htmlescape}}{{/if}}';
- assoc_tooltip.html += '{{#if {{namespace[assoc]}}alt_allele_freq|is_numeric}}
Alt. freq: {{{{namespace[assoc]}}alt_allele_freq|scinotation|htmlescape}} {{/if}}';
- assoc_tooltip.html += '{{#if {{namespace[assoc]}}rsid}}
rsID: {{{{namespace[assoc]}}rsid|htmlescape}}{{/if}}';
+ assoc_tooltip.html += `{{#if assoc:beta|is_numeric}}
β: {{assoc:beta|scinotation|htmlescape}}{{/if}}
+{{#if assoc:stderr_beta|is_numeric}}
SE (β): {{assoc:stderr_beta|scinotation|htmlescape}}{{/if}}
+{{#if assoc:alt_allele_freq|is_numeric}}
Alt. freq: {{assoc:alt_allele_freq|scinotation|htmlescape}} {{/if}}
+{{#if assoc:rsid}}
rsID: {{assoc:rsid|htmlescape}}{{/if}}`;
const dash_extra = []; // Build Display options widget & add to toolbar iff features selected
if (Object.values(annotations).some((item) => !!item)) {
@@ -114,41 +97,30 @@ function createStudyLayout(
options: [],
});
}
- const fields_extra = [
- '{{namespace[assoc]}}rsid',
- '{{namespace[assoc]}}beta',
- '{{namespace[assoc]}}stderr_beta',
- '{{namespace[assoc]}}alt_allele_freq',
- ];
- if (annotations.credible_sets) {
+ if (annotations.has_credible_sets) {
// Grab the options object from a pre-existing layout
const basis = LocusZoom.Layouts.get('panel', 'association_credible_set', { namespace });
dash_extra[0].options.push(...basis.toolbar.widgets.pop().options);
- fields_extra.push('{{namespace[credset]}}posterior_prob', '{{namespace[credset]}}contrib_fraction', '{{namespace[credset]}}is_member');
- assoc_tooltip.html += '{{#if {{namespace[credset]}}posterior_prob}}
Posterior probability: {{{{namespace[credset]}}posterior_prob|scinotation}}{{/if}}
';
+ assoc_tooltip.html += '{{#if credset:posterior_prob}}
Posterior probability: {{credset:posterior_prob|scinotation}}{{/if}}
';
}
- if (annotations.gwas_catalog) {
+ if (annotations.has_gwas_catalog) {
// Grab the options object from a pre-existing layout
const basis = LocusZoom.Layouts.get('panel', 'association_catalog', { namespace });
// TODO Clarify this; make small registry pieces more reusable
dash_extra[0].options.push(...basis.toolbar.widgets.pop().options);
- fields_extra.push('{{namespace[catalog]}}rsid', '{{namespace[catalog]}}trait', '{{namespace[catalog]}}log_pvalue');
- assoc_tooltip.html += '{{#if {{namespace[catalog]}}rsid}}
See hits in GWAS catalog{{/if}}';
+ assoc_tooltip.html += '{{#if catalog:rsid}}
See hits in GWAS catalog{{/if}}';
}
-
- assoc_layer.fields.push(...fields_extra);
assoc_panel.toolbar.widgets.push(...dash_extra);
// After all custom options added, run mods through Layouts.get once more to apply namespacing
// TODO: rewrite this
new_panels.push(LocusZoom.Layouts.get('panel', 'association', assoc_panel));
- if (annotations.gwas_catalog) {
+ if (annotations.has_gwas_catalog) {
new_panels.push(LocusZoom.Layouts.get('panel', 'annotation_catalog', {
- id: `catalog_${source_name}`,
+ id: `catalog_${track_id}`,
namespace,
- toolbar: { widgets: [] },
title: {
- text: 'Hits in GWAS Catalog',
+ text: `GWAS Catalog hits for ${display_name}`,
style: { 'font-size': '14px' },
x: 50,
},
@@ -158,29 +130,76 @@ function createStudyLayout(
}
/**
- * Create all the datasources needed to plot a specific study, from Tabixed data
+ * Create an appropriate layout based on datatype
+ */
+function createStudyLayouts (data_type, filename, display_name, annotations) {
+ const track_id = `${data_type}_${sourceName(filename)}`;
+
+ if (data_type === 'gwas') {
+ return createGwasStudyLayout(track_id, display_name, annotations);
+ } else if (data_type === 'bed') {
+ return [
+ LocusZoom.Layouts.get('panel', 'bed_intervals', {
+ id: track_id,
+ namespace: { intervals: track_id },
+ title: { text: display_name },
+ }),
+ ];
+ } else if (data_type === 'plink_ld') {
+ throw new Error('Not yet implemented');
+ } else {
+ throw new Error('Unrecognized datatype');
+ }
+}
+
+/**
+ * Create all the datasources needed to plot a specific assoc study, including study-specific annotations
* @return Array Return an array of [name, source_config] entries
*/
-function createStudyTabixSources(label, tabix_reader, parser_options) {
- const assoc_name = `assoc_${sourceName(label)}`;
- const source_params = { ...parser_options, id_field: 'variant' };
+function createGwasTabixSources(track_id, tabix_reader, parser_func) {
+ const assoc_name = `assoc_${track_id}`;
return [
- [assoc_name, ['TabixAssociationLZ', { tabix_reader, params: source_params }]],
+ [assoc_name, ['TabixAssociationLZ', { reader: tabix_reader, parser_func }]],
[ // Always add a credible set source; it won't be called unless used in a layout
- `credset_${sourceName(label)}`, [
- 'CredibleSetLZ',
- { params: { fields: { log_pvalue: `${assoc_name}:log_pvalue` }, threshold: 0.95 } },
- ],
+ `credset_${track_id}`, ['CredibleSetLZ', { threshold: 0.95 }],
],
];
}
+/**
+ * Create appropriate LocusZoom sources for the study of interest
+ * @param data_type
+ * @param filename A unique track identifier (typically the filename). Used to construct the internal datasource ID name.
+ * @param tabix_reader
+ * @param parser_func
+ * @returns {[[string, [string, {reader: *, parser_func: function(string)}]], [string, [string, {threshold: number}]]]|(string|[string, {reader, parser_func: ((function(*): {blockCount: *, score: *, chromStart: *, thickStart: *, chromEnd: *, strand: *, blockSizes: *, name: *, itemRgb: *, blockStarts: *, thickEnd: *, chrom: *})|*)}])[]}
+ */
+function createStudySources(data_type, tabix_reader, filename, parser_func) {
+ // todo rename to GET from CREATE, for consistency
+ const track_id = `${data_type}_${sourceName(filename)}`;
+ if (data_type === 'gwas') {
+ return createGwasTabixSources(track_id, tabix_reader, parser_func);
+ } else if (data_type === 'bed') {
+ return [
+ [track_id, ['TabixUrlSource', {reader: tabix_reader, parser_func }]],
+ ];
+ } else if (data_type === 'plink_ld') {
+ throw new Error('Not yet implemented');
+ } else {
+ throw new Error('Unrecognized datatype');
+ }
+}
+
+
function addPanels(plot, data_sources, panel_options, source_options) {
- source_options.forEach((source) => data_sources.add(...source));
+ source_options.forEach(([name, options]) => {
+ if (!data_sources.has(name)) {
+ data_sources.add(name, options);
+ }
+ });
panel_options.forEach((panel_layout) => {
panel_layout.y_index = -1; // Make sure genes track is always the last one
- const panel = plot.addPanel(panel_layout);
- panel.addBasicLoader();
+ plot.addPanel(panel_layout);
});
}
@@ -191,10 +210,12 @@ function addPanels(plot, data_sources, panel_options, source_options) {
function getBasicSources(study_sources = []) {
return [
...study_sources,
+ // Used by GWAS scatter plots
+ ['recomb', ['RecombLZ', { url: `${PORTAL_API_BASE_URL}annotation/recomb/results/` }]],
['catalog', ['GwasCatalogLZ', { url: `${PORTAL_API_BASE_URL}annotation/gwascatalog/results/` }]],
- ['ld', ['LDLZ2', { url: LD_SERVER_BASE_URL, params: { source: '1000G', population: 'ALL' } }]],
+ ['ld', ['LDLZ2', { url: LD_SERVER_BASE_URL, source: '1000G', population: 'ALL' }]],
+ // Genes track
['gene', ['GeneLZ', { url: `${PORTAL_API_BASE_URL}annotation/genes/` }]],
- ['recomb', ['RecombLZ', { url: `${PORTAL_API_BASE_URL}annotation/recomb/results/` }]],
['constraint', ['GeneConstraintLZ', { url: 'https://gnomad.broadinstitute.org/api/' }]],
];
}
@@ -212,37 +233,12 @@ function getBasicLayout(initial_state = {}, study_panels = [], mods = {}) {
return LocusZoom.Layouts.get('plot', 'standard_association', extra);
}
-/**
- * Remove the `sourcename:` prefix from field names in the data returned by an LZ datasource
- *
- * This is a convenience method for writing external widgets (like tables) that subscribe to the
- * plot; typically we don't want to have to redefine the table layout every time someone selects
- * a different association study.
- * As with all convenience methods, it has limits: don't use it if the same field name is requested
- * from two different sources!
- * @param {Object} data An object representing the fields for one row of data
- * @param {String} [prefer] Sometimes, two sources provide a field with same name. Specify which
- * source will take precedence in the event of a conflict.
- */
-function deNamespace(data, prefer) {
- return Object.keys(data).reduce((acc, key) => {
- const new_key = key.replace(/.*?:/, '');
- if (!Object.prototype.hasOwnProperty.call(acc, new_key)
- || (!prefer || key.startsWith(prefer))) {
- acc[new_key] = data[key];
- }
- return acc;
- }, {});
-}
-
export {
// Basic definitions
getBasicSources, getBasicLayout,
- createStudyTabixSources, createStudyLayout,
+ createStudySources, createStudyLayouts,
// Plot manipulation
sourceName, addPanels,
- // General helpers
- deNamespace,
// Constants
stateUrlMapping,
};
diff --git a/src/util/metrics.js b/src/util/metrics.js
index 10521c1..0083eeb 100644
--- a/src/util/metrics.js
+++ b/src/util/metrics.js
@@ -91,5 +91,20 @@ if (window.gtag) {
window.addEventListener('pushState', count_region_view);
}
-export default count_region_view;
-export { setup_feature_metrics };
+/**
+ * Report when new tracks/ datatypes are added to the plot, so we can have a rough guess as
+ * to which visualization features get used most.
+ * @param data_type
+ */
+const count_add_track = (data_type) => {
+ if (!window.gtag) {
+ return;
+ }
+
+ window.gtag('event', 'add_track', {
+ event_category: 'features',
+ event_label: data_type,
+ });
+};
+
+export { count_add_track, count_region_view, setup_feature_metrics };
diff --git a/tests/unit/constants.spec.js b/tests/unit/constants.spec.js
index faf0200..3845551 100644
--- a/tests/unit/constants.spec.js
+++ b/tests/unit/constants.spec.js
@@ -1,45 +1,7 @@
import { assert } from 'chai';
import { REGEX_POSITION, REGEX_REGION } from '../../src/util/constants';
-import { REGEX_MARKER } from '@/gwas/parser_utils';
-describe('REGEX_MARKER', () => {
- it('handles various marker formats', () => {
- const has_chr_pos = ['chr1:23', 'chrX:23', '1:23', 'X:23'];
- const has_chr_pos_refalt = ['chr1:23_A/C', '1:23_A/C', 'chr:1:23:AAAA:G', '1:23_A|C', 'chr1:281876_AC/A'];
- const has_chr_pos_refalt_extra = [
- 'chr1:23_A/C_gibberish', '1:23_A/C_gibberish', '1:23_A|C_gibberish',
- '1:51873951_G/GT_1:51873951_G/GT',
- ];
-
- has_chr_pos.forEach((item) => {
- const match = item.match(REGEX_MARKER);
- assert.ok(match, `Match found for ${item}`);
- assert.lengthOf(match.filter((e) => !!e), 3, `Found chr:pos for ${item}`);
- });
- has_chr_pos_refalt.forEach((item) => {
- const match = item.match(REGEX_MARKER);
- assert.ok(match, `Match found for ${item}`);
- assert.lengthOf(match.filter((e) => !!e), 5, `Found chr:pos_ref/alt for ${item}`);
- });
- has_chr_pos_refalt_extra.forEach((item) => {
- const match = item.match(REGEX_MARKER);
- assert.ok(match, `Match found for ${item}`);
- assert.lengthOf(match.filter((e) => !!e), 6, `Found chr:pos_ref/alt_extra for ${item}`);
- });
-
- // Pathological edge cases
- let match = '1:51873951_G/GT_1:51873951_G/GT'.match(REGEX_MARKER);
- assert.equal(match[1], '1', 'Found correct chrom');
- assert.equal(match[2], '51873951', 'Found correct pos');
- assert.equal(match[3], 'G', 'Found correct ref');
- assert.equal(match[4], 'GT', 'Found correct alt');
-
- match = 'sentence_goes_here_1:51873951_G/GT'.match(REGEX_MARKER);
- assert.isNotOk(match, 'Marker must be at start of string');
- });
-});
-
describe('REGEX_POSITION', () => {
it('handles various region formats', () => {
const scenarios = [
diff --git a/tests/unit/parser_utils.spec.js b/tests/unit/parser_utils.spec.js
deleted file mode 100644
index bc0d32d..0000000
--- a/tests/unit/parser_utils.spec.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import { assert } from 'chai';
-import { _missingToNull, parseAlleleFrequency, parsePvalToLog } from '@/gwas/parser_utils';
-
-
-describe('missingToNull', () => {
- it('converts a range of missing values to null values', () => {
- // Every other one should get converted
- const values = [0, null, 5, 'n/a', 'bob', '-NaN'];
- const result = _missingToNull(values);
- assert.deepStrictEqual(result, [0, null, 5, null, 'bob', null]);
- });
-});
-
-describe('parsePvalToLog', () => {
- it('sidesteps language underflow', () => {
- const val = '1.93e-780';
- const res = parsePvalToLog(val, false);
- assert.equal(res, 779.7144426909922, 'Handled value that would otherwise have underflowed');
- });
-
- it('handles underflow that occurred in the source data', () => {
- let val = '0';
- let res = parsePvalToLog(val);
- assert.equal(res, Infinity, 'Provides a placeholder when the input data underflowed');
-
- val = '0.0';
- res = parsePvalToLog(val);
- assert.equal(res, Infinity, 'Provides a placeholder when the input data underflowed (slow path)');
- });
-});
-
-describe('parseAlleleFrequency', () => {
- it('returns freq given frequency', () => {
- const res = parseAlleleFrequency({ freq: '0.25', is_alt_effect: true });
- assert.equal(res, 0.25);
- });
-
- it('returns freq given frequency, and orients to alt', () => {
- const res = parseAlleleFrequency({ freq: '0.25', is_alt_effect: false });
- assert.equal(res, 0.75);
- });
-
- it('handles missing data', () => {
- const res = parseAlleleFrequency({ freq: 'NA', is_alt_effect: true });
- assert.equal(res, null);
- });
-
- it('calculates freq from counts', () => {
- const res = parseAlleleFrequency({ allele_count: '25', n_samples: '100' });
- assert.equal(res, 0.125);
- });
-
- it('calculates freq from counts, and orients to alt', () => {
- const res = parseAlleleFrequency({ allele_count: '75', n_samples: '100', is_alt_effect: false });
- assert.equal(res, 0.625);
- });
-
- it('handles missing data when working with counts', () => {
- const res = parseAlleleFrequency({ allele_count: 'NA', n_samples: '100' });
- assert.equal(res, null);
- });
-});
diff --git a/tests/unit/parsers.spec.js b/tests/unit/parsers.spec.js
deleted file mode 100644
index 0d7c71f..0000000
--- a/tests/unit/parsers.spec.js
+++ /dev/null
@@ -1,139 +0,0 @@
-import { assert } from 'chai';
-import { makeParser } from '@/gwas/parsers';
-
-
-describe('GWAS parsing', () => {
- describe('Mode selection', () => {
- it.skip('Warns if no marker could be identified', () => {});
- });
-
- describe('Handles sample data correctly', () => {
- it.skip('parses EPACTS data', () => {
- // FIXME: Alan notes edge cases that may not be handled yet:
- // -when *PVALUE = 0*, it always indicates a variant is very significant (such that it
- // underflows R's precision limit), and *should be plotted*
- // -when *PVALUE = NA*, it indicates that no test was run for that variant because there
- // were too few copies of the alt allele in the sample, and running the test is a waste
- // of time since it will never be significant. *These can be safely skipped.*"
- });
-
- it('parses SAIGE data', () => {
- const saige_sample = 'chr1\t76792\tchr1:76792:A:C\tA\tC\t57\t0.00168639048933983\t16900\t0.573681678183941\t0.663806747906141\t1.30193005902619\t0.387461577915637\t0.387461577915637\t1\t2.2694293866027\t2.41152256615949';
- const parser = makeParser({ marker_col: 3, pvalue_col: 12, is_neg_log_pvalue: false });
- const actual = parser(saige_sample);
- assert.deepEqual(actual, {
- alt_allele: 'C',
- chromosome: '1',
- log_pvalue: 0.41177135722616476,
- position: 76792,
- ref_allele: 'A',
- variant: '1:76792_A/C',
- rsid: null,
- beta: null,
- stderr_beta: null,
- alt_allele_freq: null,
- });
- });
-
- it('parses RVTESTS data', () => {
- const rvtests_sample = '1\t761893\tG\tT\t19292\t2.59624e-05:0.000655308:0\t1:1:0\t0.998289:0.996068:0.998381\t1:1:1\t19258:759:18499\t1:1:0\t0:0:0\t1.33113\t0.268484\t18.4664\t7.12493e-07';
- const parser = makeParser({
- chrom_col: 1,
- pos_col: 2,
- ref_col: 3,
- alt_col: 4,
- pvalue_col: 16,
- is_neg_log_pvalue: false,
- alt_allele_freq: null,
- });
- const actual = parser(rvtests_sample);
- assert.deepEqual(actual, {
- alt_allele: 'T',
- chromosome: '1',
- log_pvalue: 6.147219398093217,
- position: 761893,
- ref_allele: 'G',
- variant: '1:761893_G/T',
- rsid: null,
- beta: null,
- stderr_beta: null,
- alt_allele_freq: null,
- });
- });
-
- it('parses beta and stderr where appropriate', () => {
- const line = 'X:12_A/T\t0.1\t0.5\t0.6';
- const parser = makeParser({
- marker_col: 1,
- pvalue_col: 2,
- beta_col: 3,
- stderr_beta_col: 4,
- });
- const actual = parser(line);
-
- assert.deepEqual(actual, {
- chromosome: 'X',
- position: 12,
- ref_allele: 'A',
- alt_allele: 'T',
- variant: 'X:12_A/T',
- rsid: null,
- log_pvalue: 1,
- beta: 0.5,
- stderr_beta: 0.6,
- alt_allele_freq: null,
- });
- // Also handles missing data for beta
- const line2 = 'X:12_A/T\t0.1\t.\t.';
- const actual2 = parser(line2);
- assert.equal(actual2.beta, null);
- assert.equal(actual2.stderr_beta, null);
- });
-
- it('ensures that ref and alt are uppercase', () => {
- const line = 'X:12\ta\tNA\t0.1';
- const parser = makeParser({
- marker_col: 1,
- ref_col: 2,
- alt_col: 3,
- pvalue_col: 4,
- });
- const actual = parser(line);
-
- assert.deepEqual(actual, {
- chromosome: 'X',
- position: 12,
- ref_allele: 'A',
- alt_allele: null,
- variant: 'X:12',
- rsid: null,
- log_pvalue: 1,
- beta: null,
- stderr_beta: null,
- alt_allele_freq: null,
- });
- });
-
- it('handles rsid in various formats', () => {
- const parser = makeParser({
- marker_col: 1,
- ref_col: 2,
- alt_col: 3,
- pvalue_col: 4,
- rsid_col: 5,
- });
-
- const scenarios = [
- ['.', null],
- ['14', 'rs14'],
- ['RS14', 'rs14'],
- ];
- const line = 'X:12\ta\tNA\t0.1\t';
-
- scenarios.forEach(([raw, parsed]) => {
- const actual = parser(line + raw);
- assert.equal(actual.rsid, parsed);
- });
- });
- });
-});
diff --git a/tests/unit/sniffers.spec.js b/tests/unit/sniffers.spec.js
deleted file mode 100644
index d0adfce..0000000
--- a/tests/unit/sniffers.spec.js
+++ /dev/null
@@ -1,341 +0,0 @@
-import { assert } from 'chai';
-import { isHeader, _findColumn, _getPvalColumn, _levenshtein, guessGWAS } from '@/gwas/sniffers';
-
-describe('Automatic header detection', () => {
- it('Correctly identifies various header rules', () => {
- assert.isOk(isHeader('#Comment'), 'Comment lines are headers!');
- assert.isOk(isHeader('Header\tLabels'), 'Headers tend to be text');
- assert.isNotOk(isHeader('X\t100'), 'Data has numbers');
- assert.isNotOk(isHeader('X\t.'), 'Missing data is still data');
- assert.isNotOk(isHeader('X,100', { delimiter: ',' }), 'Handles data as csv');
- assert.isOk(isHeader('//100', { comment_char: '//' }), 'Handles different comments');
- });
-});
-
-
-describe('Levenshtein distance metric', () => {
- it('Computes levenshtein distance for sample strings', () => {
- const scenarios = [
- ['bob', 'bob', 0],
- ['bob', 'bib', 1],
- ['alice', 'bob', 5],
- ['pvalue', 'p.value', 1],
- ['p.value', 'pvalue', 1],
- ['pvalue', 'log_pvalue', 4],
- ];
- scenarios.forEach((s) => {
- const [a, b, score] = s;
- const val = _levenshtein(a, b);
- assert.equal(score, val, `Incorrect match score for ${a}, ${b}`);
- });
- });
-});
-
-describe('_findColumn can fuzzy match column names', () => {
- const pval_names = ['pvalue', 'p.value', 'pval', 'p_score'];
-
- it('finds the first header that exactly matches a synonym', () => {
- const headers = ['chr', 'pos', 'p.value', 'marker'];
- const match = _findColumn(pval_names, headers);
- assert.equal(match, 2);
- });
-
- it('chooses the first exact match when more than one is present', () => {
- const headers = ['chr', 'pvalue', 'p.value', 'marker'];
- const match = _findColumn(pval_names, headers);
- assert.equal(match, 1);
- });
-
- it('prefers exact matches over fuzzy matches', () => {
- const headers = ['chr1', 'pos1', 'pvalues', 'p.value', '1marker'];
- const match = _findColumn(pval_names, headers);
- assert.equal(match, 3);
- });
-
- it('finds the first header that closely matches a synonym', () => {
- const headers = ['chr', 'pos', 'marker', 'p-value'];
- const match = _findColumn(pval_names, headers);
- assert.equal(match, 3);
- });
-
- it('returns null if no good match can be found', () => {
- const headers = ['chr', 'pos', 'marker', 'pval_score'];
- const match = _findColumn(pval_names, headers);
- assert.equal(match, null);
- });
-
- it('will match based on a configurable threshold', () => {
- const headers = ['chr', 'marker', 'pval_score'];
- const match = _findColumn(pval_names, headers, 3);
- assert.equal(match, 2);
- });
-
- it('skips headers with a null value', () => {
- const headers = ['chr', null, 'marker', 'pval'];
- const match = _findColumn(pval_names, headers);
- assert.equal(match, 3);
- });
-});
-
-describe('getPvalColumn', () => {
- it('finds logp before p', () => {
- const headers = ['logpvalue', 'pval'];
- const data_rows = [[0.5, 0.5]];
-
- const actual = _getPvalColumn(headers, data_rows);
- assert.deepEqual(actual, { pvalue_col: 1, is_neg_log_pvalue: true });
- });
-
- it('checks that pvalues are in a realistic range 0..1', () => {
- const headers = ['pval'];
- const data_rows = [[100]];
-
- const actual = _getPvalColumn(headers, data_rows);
- assert.deepEqual(actual, null);
- });
-});
-
-describe('guessGWAS format detection', () => {
- it('Returns null if columns could not be identified', () => {
- const headers = ['rsid', 'pval'];
- const data = [['rs1234', 0.5]];
-
- const actual = guessGWAS(headers, data);
- assert.deepEqual(actual, null);
- });
-
- it('handles zorp standard format', () => {
- const headers = ['#chrom', 'pos', 'ref', 'alt', 'neg_log_pvalue', 'beta', 'stderr_beta'];
- const data = [['1', '762320', 'C', 'T', '0.36947042857317597', '0.5', '0.1']];
-
- const actual = guessGWAS(headers, data);
- assert.deepEqual(actual, {
- chrom_col: 1,
- pos_col: 2,
- ref_col: 3,
- alt_col: 4,
- pvalue_col: 5,
- is_neg_log_pvalue: true,
- beta_col: 6,
- stderr_beta_col: 7,
- });
- });
-
- it('handles BOLT-LMM', () => {
- // https://data.broadinstitute.org/alkesgroup/BOLT-LMM/#x1-450008.1
- // This sample drawn from:
- // ftp://ftp.ebi.ac.uk/pub/databases/gwas/summary_statistics/ZhuZ_30940143_GCST007609
- // TODO: The official format spec may use other pvalue col names; add tests for that
- const headers = ['SNP', 'CHR', 'BP', 'A1', 'A0', 'MAF', 'HWEP', 'INFO', 'BETA', 'SE', 'P'];
- const data = [['10:48698435_A_G', '10', '48698435', 'A', 'G', '0.01353', '0.02719', '0.960443', '0.0959329', '0.0941266', '3.3E-01']];
-
- const actual = guessGWAS(headers, data);
- assert.deepEqual(actual, {
- marker_col: 1,
- pvalue_col: 11,
- is_neg_log_pvalue: false,
- beta_col: 9,
- stderr_beta_col: 10,
- });
- });
-
- it('handles EPACTS', () => {
- // https://genome.sph.umich.edu/wiki/EPACTS#Output_Text_of_All_Test_Statistics
- const headers = ['#CHROM', 'BEGIN', 'END', 'MARKER_ID', 'NS', 'AC', 'CALLRATE', 'MAF', 'PVALUE', 'SCORE', 'N.CASE', 'N.CTRL', 'AF.CASE', 'AF.CTRL'];
- const data = [['20', '1610894', '1610894', '20:1610894_G/A_Synonymous:SIRPG', '266', '138.64', '1', '0.26061', '6.9939e-05', '3.9765', '145', '121', '0.65177', '0.36476']];
-
- const actual = guessGWAS(headers, data);
- assert.deepEqual(actual, { marker_col: 4, pvalue_col: 9, is_neg_log_pvalue: false });
- });
-
- it('handles EMMAX-EPACTS', () => {
- // Sample from a file that used multiple tools
- const headers = ['#CHROM', 'BEG', 'END', 'MARKER_ID', 'NS', 'AC', 'CALLRATE', 'GENOCNT', 'MAF', 'STAT', 'PVALUE', 'BETA', 'SEBETA', 'R2'];
- const data = [['1', '762320', '762320', '1:762320_C/T_rs75333668', '3805', '100.00', '1.00000', '3707/96/2', '0.01314', '0.7942', '0.4271', '0.08034', '0.1012', '0.0001658']];
-
- const actual = guessGWAS(headers, data);
- assert.deepEqual(actual, {
- marker_col: 4,
- pvalue_col: 11,
- is_neg_log_pvalue: false,
- beta_col: 12,
- stderr_beta_col: 13,
- });
- });
-
- it('handles METAL', () => {
- const headers = ['#CHROM', 'POS', 'REF', 'ALT', 'N', 'POOLED_ALT_AF', 'DIRECTION_BY_STUDY', 'EFFECT_SIZE', 'EFFECT_SIZE_SD', 'H2', 'PVALUE'];
- const data = [['1', '10177', 'A', 'AC', '491984', '0.00511094', '?-????????????????-????+???????????????????????????????????????????????????????????????????-????????????????????????????????????????????????????????????????????????????????', '-0.0257947', '0.028959', '1.61266e-06', '0.373073']];
-
- const actual = guessGWAS(headers, data);
- assert.deepEqual(
- actual,
- {
- chrom_col: 1,
- pos_col: 2,
- ref_col: 3,
- alt_col: 4,
- pvalue_col: 11,
- is_neg_log_pvalue: false,
- beta_col: 8,
- stderr_beta_col: 9,
- },
- );
- });
-
- it('handles PLINK', () => {
- // Format: https://www.cog-genomics.org/plink2/formats
- // Sample: https://github.com/babelomics/babelomics/wiki/plink.assoc
- // h/t Josh Weinstock
- const headers = ['CHR', 'SNP', 'BP', 'A1', 'F_A', 'F_U', 'A2', 'CHISQ', 'P'];
- const data = [['1', 'rs3094315', '742429', 'C', '0.1509', '0.1394', 'T', '0.0759', '0.782', '1.097']];
- const actual = guessGWAS(headers, data);
- assert.deepEqual(
- actual,
- {
- chrom_col: 1,
- pos_col: 3,
- ref_col: 4,
- alt_col: 7,
- pvalue_col: 9,
- is_neg_log_pvalue: false,
- },
- );
- });
-
- it('handles RAREMETAL', () => {
- const headers = ['#CHROM', 'POS', 'REF', 'ALT', 'N', 'POOLED_ALT_AF', 'DIRECTION_BY_STUDY', 'EFFECT_SIZE', 'EFFECT_SIZE_SD', 'H2', 'PVALUE'];
- const data = [['1', '10177', 'A', 'AC', '491984', '0.00511094', '?-????????????????-????+???????????????????????????????????????????????????????????????????-????????????????????????????????????????????????????????????????????????????????', '-0.0257947', '0.028959', '1.61266e-06', '0.373073']];
-
- const actual = guessGWAS(headers, data);
- assert.deepEqual(
- actual,
- {
- chrom_col: 1,
- pos_col: 2,
- ref_col: 3,
- alt_col: 4,
- pvalue_col: 11,
- is_neg_log_pvalue: false,
- beta_col: 8,
- stderr_beta_col: 9,
- },
- );
- });
-
- it('handles RAREMETALWORKER', () => {
- const headers = ['#CHROM', 'POS', 'REF', 'ALT', 'N_INFORMATIVE', 'FOUNDER_AF', 'ALL_AF', 'INFORMATIVE_ALT_AC', 'CALL_RATE', 'HWE_PVALUE', 'N_REF', 'N_HET', 'N_ALT', 'U_STAT', 'SQRT_V_STAT', 'ALT_EFFSIZE', 'PVALUE'];
- const data = [['9', '400066155', 'T', 'C', '432', '0', '0', '0', '1', '1', '432', '0', '0', 'NA', 'NA', 'NA', 'NA']];
-
- const actual = guessGWAS(headers, data);
- assert.deepEqual(
- actual,
- {
- chrom_col: 1,
- pos_col: 2,
- ref_col: 3,
- alt_col: 4,
- pvalue_col: 17,
- is_neg_log_pvalue: false,
- beta_col: 16,
- },
- );
- });
-
- it('handles RVTESTS', () => {
- // Courtesy of xyyin and gzajac
- const headers = ['CHROM', 'POS', 'REF', 'ALT', 'N_INFORMATIVE', 'AF', 'INFORMATIVE_ALT_AC', 'CALL_RATE', 'HWE_PVALUE', 'N_REF', 'N_HET', 'N_ALT', 'U_STAT', 'SQRT_V_STAT', 'ALT_EFFSIZE', 'PVALUE'];
- const data = [['1', '761893', 'G', 'T', '19292', '2.59624e-05:0.000655308:0', '1:1:0', '0.998289:0.996068:0.998381', '1:1:1', '19258:759:18499', '1:1:0', '0:0:0', '1.33113', '0.268484', '18.4664', '7.12493e-07']];
-
- const actual = guessGWAS(headers, data);
- assert.deepEqual(
- actual,
- {
- chrom_col: 1,
- pos_col: 2,
- ref_col: 3,
- alt_col: 4,
- pvalue_col: 16,
- is_neg_log_pvalue: false,
- beta_col: 15,
- },
- );
- });
-
- it('handles SAIGE', () => {
- // https://github.com/weizhouUMICH/SAIGE/wiki/SAIGE-Hands-On-Practical
- const headers = ['CHR', 'POS', 'SNPID', 'Allele1', 'Allele2', 'AC_Allele2', 'AF_Allele2', 'N', 'BETA', 'SE', 'Tstat', 'p.value', 'p.value.NA', 'Is.SPA.converge', 'varT', 'varTstar'];
- const data = [['chr1', '76792', 'chr1:76792:A:C', 'A', 'C', '57', '0.00168639048933983', '16900', '0.573681678183941', '0.663806747906141', '1.30193005902619', '0.387461577915637', '0.387461577915637', '1', '2.2694293866027', '2.41152256615949']];
-
- const actual = guessGWAS(headers, data);
- assert.deepEqual(
- actual,
- {
- marker_col: 3,
- pvalue_col: 12,
- is_neg_log_pvalue: false,
- beta_col: 9,
- stderr_beta_col: 10,
- },
- );
- });
-
- it('parses a mystery format', () => {
- // TODO: Identify the program used and make test more explicit
- // FIXME: This test underscores difficulty of reliable ref/alt detection- a1 comes
- // before a0, but it might be more valid to switch the order of these columns
- const headers = ['chr', 'rs', 'ps', 'n_mis', 'n_obs', 'allele1', 'allele0', 'af', 'beta', 'se', 'p_score'];
- const data = [['1', 'rs75333668', '762320', '0', '3610', 'T', 'C', '0.013', '-5.667138e-02', '1.027936e-01', '5.814536e-01']];
- const actual = guessGWAS(headers, data);
- assert.deepEqual(
- actual,
- {
- chrom_col: 1,
- pos_col: 3,
- ref_col: 6,
- alt_col: 7,
- pvalue_col: 11,
- is_neg_log_pvalue: false,
- beta_col: 9,
- stderr_beta_col: 10,
- },
- );
- });
-
- it('handles output of AlisaM pipeline', () => {
- const headers = ['MarkerName', 'chr', 'pos', 'ref', 'alt', 'minor.allele', 'maf', 'mac', 'n', 'pvalue', 'SNPID', 'BETA', 'SE', 'ALTFreq', 'SNPMarker'];
- const data = [['chr1-281876-AC-A', 'chr1', '281876', 'AC', 'A', 'alt', '0.231428578495979', '1053', '2275', '0.447865946615285', 'rs72502741', '-0.0872936159370696', '0.115014743551501', '0.231428578495979', 'chr1:281876_AC/A']];
-
- const actual = guessGWAS(headers, data);
- assert.deepEqual(
- actual,
- {
- chrom_col: 2,
- pos_col: 3,
- ref_col: 4,
- alt_col: 5,
- pvalue_col: 10,
- is_neg_log_pvalue: false,
- beta_col: 12,
- stderr_beta_col: 13,
- },
- );
- });
-
- it('handles whatever diagram was using', () => {
- const headers = ['Chr:Position', 'Allele1', 'Allele2', 'Effect', 'StdErr', 'P-value', 'TotalSampleSize'];
- const data = [['5:29439275', 'T', 'C', '-0.0003', '0.015', '0.99', '111309']];
-
- const actual = guessGWAS(headers, data);
- assert.deepEqual(
- actual,
- {
- marker_col: 1,
- pvalue_col: 6,
- is_neg_log_pvalue: false,
- beta_col: 4,
- stderr_beta_col: 5,
- },
- );
- });
-});